From 855c70a98e5d28cf33923793f5a81e51f830ac80 Mon Sep 17 00:00:00 2001 From: Maxim Date: Tue, 1 Sep 2026 19:09:11 +0200 Subject: [PATCH 01/23] feat(composio): port the config, scopes, and classification into the agent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First step of moving Composio out of the Channel and into the agent, so the agent has the capability on its own and one approval mechanism covers every gated action instead of two. Nothing is wired into the graph yet. This is the part that needs no identity and no network: the environment contract, which identities a turn acts as, the effect classification, and a per-identity session cache. Carried over deliberately, because each was paid for once already: - A toolkit named in both toolkit lists resolves to the personal scope only, unconditionally. Picking whichever session loaded first would attribute an action to a person or to the shared account depending on restart order, and an unidentified turn must get no access rather than falling through to the shared account. - An unclassified tool is a write, and an empty tag list is unclassified. - One unreachable identity is logged and dropped, not raised: a broken personal account must not take the team's shared toolkits down for the turn. New here, from reading the Python SDK rather than assuming parity with the TypeScript one: `sandbox` and `workbench` are separate keyword arguments, the latter a deprecated alias, and passing both raises. The sandbox — a remote shell and a remote Python tool, on by default — is disabled explicitly, since the SDK only defaults it off under a session preset we do not use. The package is `composio_tools`, not `composio`: this directory is on the agent's import path and would otherwise shadow the SDK. Both packaging tests now derive their expectations from what is on disk. The wheel assertion listed its packages by hand, and the image assertion checked only the coding package — either would have let a new package pass locally and crash the container on first import. --- .gitignore | 6 ++ agent/composio_tools/__init__.py | 6 ++ agent/composio_tools/classify.py | 34 ++++++++ agent/composio_tools/config.py | 118 +++++++++++++++++++++++++ agent/composio_tools/scopes.py | 121 ++++++++++++++++++++++++++ agent/composio_tools/sessions.py | 109 +++++++++++++++++++++++ agent/pyproject.toml | 3 +- agent/tests/test_composio_classify.py | 42 +++++++++ agent/tests/test_composio_config.py | 87 ++++++++++++++++++ agent/tests/test_composio_scopes.py | 78 +++++++++++++++++ agent/tests/test_composio_sessions.py | 111 +++++++++++++++++++++++ agent/tests/test_packaging.py | 24 ++++- agent/uv.lock | 67 +++++++++++++- deployment/docker/agent.Dockerfile | 1 + 14 files changed, 800 insertions(+), 7 deletions(-) create mode 100644 agent/composio_tools/__init__.py create mode 100644 agent/composio_tools/classify.py create mode 100644 agent/composio_tools/config.py create mode 100644 agent/composio_tools/scopes.py create mode 100644 agent/composio_tools/sessions.py create mode 100644 agent/tests/test_composio_classify.py create mode 100644 agent/tests/test_composio_config.py create mode 100644 agent/tests/test_composio_scopes.py create mode 100644 agent/tests/test_composio_sessions.py diff --git a/.gitignore b/.gitignore index 2a1463b..d3c4a1f 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,9 @@ state.db-wal # AWS CDK synthesis artifacts deployment/aws/cdk.out deployment/aws/cdk.context.json + +# Composio working documents — design, plan, and the agent-port plan. Kept +# local for the same reason 0577c63 removed docs/superpowers specs and plans. +docs/composio-tools-design.md +docs/composio-tools-plan.md +docs/composio-agent-port-plan.md diff --git a/agent/composio_tools/__init__.py b/agent/composio_tools/__init__.py new file mode 100644 index 0000000..68a1e5f --- /dev/null +++ b/agent/composio_tools/__init__.py @@ -0,0 +1,6 @@ +"""Composio integration for the OpenTag agent. + +Named `composio_tools`, not `composio`: this directory sits on the agent's +import path, so a package called `composio` would shadow the SDK of the same +name and `import composio` inside these modules would find itself. +""" diff --git a/agent/composio_tools/classify.py b/agent/composio_tools/classify.py new file mode 100644 index 0000000..6c7c544 --- /dev/null +++ b/agent/composio_tools/classify.py @@ -0,0 +1,34 @@ +"""Effect classification from Composio's MCP behaviour tags. + +A tool's tags are a plain list of strings that defaults to empty, so an empty +list cannot be told apart from "nobody classified this". Anything not positively +marked read-only is therefore treated as a write, which fails safe and matches +how `internal_sources.py` already treats an unclassified MCP tool. +""" + +from __future__ import annotations + +from collections.abc import Iterable + +READ = "read" +WRITE = "write" +DESTRUCTIVE = "destructive" + + +def effect_of(tags: Iterable[str] | None) -> str: + """The effect a tool's tags claim, erring towards the more dangerous read.""" + present = set(tags or ()) + if "destructiveHint" in present: + return DESTRUCTIVE + if "readOnlyHint" in present: + return READ + return WRITE + + +def needs_approval(effect: str, mode: str) -> bool: + """Whether an effect must be confirmed by a person under this approval mode.""" + if mode == "off": + return False + if mode == "destructive": + return effect == DESTRUCTIVE + return effect != READ diff --git a/agent/composio_tools/config.py b/agent/composio_tools/config.py new file mode 100644 index 0000000..9805a69 --- /dev/null +++ b/agent/composio_tools/config.py @@ -0,0 +1,118 @@ +"""Environment contract for the optional Composio integration. + +Absent `COMPOSIO_API_KEY` returns `None` and nothing downstream is constructed — +absent, not disabled, so the agent never carries a tool it can see but must not +call. + +The variable names and their meanings are unchanged from the channel-side +implementation this replaces. An operator who configured that one does not have +to relearn anything. +""" + +from __future__ import annotations + +import os +from collections.abc import Mapping +from dataclasses import dataclass, field + +APPROVAL_MODES = ("off", "destructive", "writes") + + +class ComposioConfigError(ValueError): + """An operator set a Composio variable to something unusable.""" + + +@dataclass(frozen=True) +class ComposioConfig: + api_key: str + workspace_toolkits: tuple[str, ...] + user_toolkits: tuple[str, ...] + approvals: str + workspace_user_id: str + #: Read only by the operator connect script; no turn consumes it. + #: `session.authorize()` resolves an auth config from the project itself and + #: takes no id, so a toolkit with several cannot be pinned per call. This + #: pins the choice when an operator connects a shared toolkit by hand. + auth_configs: Mapping[str, str] = field(default_factory=dict) + + +def _env(env: Mapping[str, str] | None) -> Mapping[str, str]: + return os.environ if env is None else env + + +def _value(source: Mapping[str, str], name: str) -> str: + return (source.get(name) or "").strip() + + +def _slug_list(raw: str) -> tuple[str, ...]: + return tuple( + slug for slug in (item.strip().lower() for item in raw.split(",")) if slug + ) + + +def _approval_mode(raw: str) -> str: + """ + Empty or whitespace-only means unset, not invalid. + + `COMPOSIO_APPROVALS=` is routine in `.env` files and in compose passthrough, + and must not take the agent down at boot. + """ + value = raw.strip().lower() or "destructive" + if value not in APPROVAL_MODES: + raise ComposioConfigError( + f'Invalid COMPOSIO_APPROVALS: "{raw}" — expected one of ' + + ", ".join(APPROVAL_MODES) + ) + return value + + +def _auth_config_map(raw: str) -> dict[str, str]: + """ + Parse `toolkit:auth_config_id` pairs. + + Toolkit keys are lowercased to match the toolkit lists. Ids are preserved + verbatim, because real ones are mixed case (`ac_ExAmPle1-aB`) and a + lowercased id does not resolve. Splits on the first colon only, so an id + containing one is not truncated. + """ + pairs: dict[str, str] = {} + for entry in raw.split(","): + separator = entry.find(":") + if separator == -1: + continue + toolkit = entry[:separator].strip().lower() + identifier = entry[separator + 1 :].strip() + if toolkit and identifier: + pairs[toolkit] = identifier + return pairs + + +def read_composio_config( + env: Mapping[str, str] | None = None, + *, + default_user_id: str, +) -> ComposioConfig | None: + """Read the Composio contract, or `None` when the feature is not configured.""" + source = _env(env) + api_key = _value(source, "COMPOSIO_API_KEY") + if not api_key: + return None + + workspace_toolkits = _slug_list(_value(source, "COMPOSIO_TOOLKITS")) + user_toolkits = _slug_list(_value(source, "COMPOSIO_USER_TOOLKITS")) + # A key with no toolkits names nothing to reach. Treated as unconfigured + # rather than as an empty-but-enabled integration, so the agent does not + # advertise tools that can only answer "nothing is set up". + if not workspace_toolkits and not user_toolkits: + return None + + return ComposioConfig( + api_key=api_key, + workspace_toolkits=workspace_toolkits, + user_toolkits=user_toolkits, + approvals=_approval_mode(_value(source, "COMPOSIO_APPROVALS")), + workspace_user_id=( + _value(source, "COMPOSIO_WORKSPACE_USER_ID") or default_user_id + ), + auth_configs=_auth_config_map(_value(source, "COMPOSIO_AUTH_CONFIGS")), + ) diff --git a/agent/composio_tools/scopes.py b/agent/composio_tools/scopes.py new file mode 100644 index 0000000..8018ee7 --- /dev/null +++ b/agent/composio_tools/scopes.py @@ -0,0 +1,121 @@ +"""Which Composio identities a turn acts as, and what to say at startup. + +The actor here is the one the Channel forwarded with the run — the platform's own +word for who spoke. It is never a value the model produced, which is the whole +reason this code can live in the agent at all. +""" + +from __future__ import annotations + +import logging +import os +from collections.abc import Mapping +from dataclasses import dataclass + +from composio_tools.config import ComposioConfig + +logger = logging.getLogger(__name__) + +#: Apps whose data is one person's, not a team's. +PERSONAL_TOOLKITS = frozenset({"gmail", "googlecalendar", "outlook", "googledrive"}) + +#: Composio toolkit slug -> the variable that enables the same app over MCP. +MCP_EQUIVALENTS = { + "linear": "LINEAR_API_KEY", + "notion": "NOTION_MCP_AUTH_TOKEN", + "posthog": "POSTHOG_PERSONAL_API_KEY", + "github": "GITHUB_PERSONAL_ACCESS_TOKEN", +} + + +@dataclass(frozen=True) +class ResolvedScope: + user_id: str + toolkits: tuple[str, ...] + #: True when this scope acts as the person who spoke rather than as the + #: shared team identity. Only that person may approve one of its calls. + personal: bool + + +def resolve_scopes( + config: ComposioConfig, + actor_id: str | None, +) -> tuple[ResolvedScope, ...]: + """ + Every applicable scope, not the first match — one turn can be both the + shared team identity and the person who sent the message. + + A toolkit named in both lists resolves to the personal scope only. Routing + by slug is ambiguous when a slug lives in two sessions, and picking whichever + loaded first would attribute an action to a person or to a shared account + depending on restart order. + + That de-duplication is unconditional: it does not depend on the personal + scope actually resolving. Naming a toolkit in `COMPOSIO_USER_TOOLKITS` is the + operator saying it must run as the person, so an unidentified turn gets no + access to it rather than quietly falling through to the shared account. + """ + scopes: list[ResolvedScope] = [] + + # The single place a personal identity is admitted. Blank is not an identity: + # an empty or whitespace-only id is as unverified as no actor at all. + actor = (actor_id or "").strip() or None + + workspace_toolkits = tuple( + slug for slug in config.workspace_toolkits if slug not in config.user_toolkits + ) + + if workspace_toolkits: + scopes.append( + ResolvedScope( + user_id=config.workspace_user_id, + toolkits=workspace_toolkits, + personal=False, + ) + ) + if actor is not None and config.user_toolkits: + scopes.append( + ResolvedScope( + user_id=actor, + toolkits=tuple(config.user_toolkits), + personal=True, + ) + ) + return tuple(scopes) + + +def startup_warnings( + config: ComposioConfig, + env: Mapping[str, str] | None = None, +) -> tuple[str, ...]: + """Misconfigurations worth saying out loud once, at boot rather than per turn.""" + source = os.environ if env is None else env + warnings: list[str] = [] + + for slug in dict.fromkeys(config.workspace_toolkits): + if slug in config.user_toolkits: + warnings.append( + f'"{slug}" is in both COMPOSIO_TOOLKITS and COMPOSIO_USER_TOOLKITS. ' + "Using each person's own account; the shared one is ignored for " + "this app." + ) + continue + if slug in PERSONAL_TOOLKITS: + warnings.append( + f'"{slug}" is in COMPOSIO_TOOLKITS (shared). Every Slack user will ' + "act through ONE account. If you meant each person to use their " + "own, move it to COMPOSIO_USER_TOOLKITS." + ) + + for slug in dict.fromkeys((*config.workspace_toolkits, *config.user_toolkits)): + mcp_var = MCP_EQUIVALENTS.get(slug) + if not mcp_var or not (source.get(mcp_var) or "").strip(): + continue + warnings.append( + f'"{slug}" is configured twice: via Composio and via {mcp_var}. The ' + "agent will see two sets of tools for it and may pick either, so " + "whether an action asks for approval will vary. Remove one to make " + "this predictable." + ) + + return tuple(warnings) diff --git a/agent/composio_tools/sessions.py b/agent/composio_tools/sessions.py new file mode 100644 index 0000000..d8b01bb --- /dev/null +++ b/agent/composio_tools/sessions.py @@ -0,0 +1,109 @@ +"""Composio sessions, cached per identity for the life of the process. + +Composio stores connected accounts on its own side, keyed by user id, so this +cache holds no credential and losing it costs one round trip rather than a +re-authentication. A restart is invisible to everyone who has already connected. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import Any, Protocol + +from composio import Composio + +from composio_tools.config import ComposioConfig +from composio_tools.scopes import ResolvedScope + +logger = logging.getLogger(__name__) + + +class Session(Protocol): + """The part of a Composio session this package uses.""" + + def search(self, *, query: str) -> Any: ... + + def execute(self, slug: str, arguments: dict[str, Any]) -> Any: ... + + def authorize(self, toolkit: str) -> Any: ... + + def toolkits(self) -> Any: ... + + +@dataclass(frozen=True) +class ScopedSession: + """One live session, plus the scope that decides who may approve its calls.""" + + session: Session + scope: ResolvedScope + + +class SessionCache: + """ + Sessions keyed by identity and toolkit set. + + An instance rather than module state so a test gets a clean cache without + reaching into globals, and so two configurations cannot share entries. + """ + + def __init__(self, config: ComposioConfig, *, client: Any | None = None) -> None: + self._config = config + self._client = client + self._sessions: dict[tuple[str, tuple[str, ...]], Session] = {} + + def _composio(self) -> Any: + if self._client is None: + self._client = Composio(api_key=self._config.api_key) + return self._client + + def for_scope(self, scope: ResolvedScope) -> ScopedSession: + """The session for one scope, created on first use and reused after.""" + key = (scope.user_id, scope.toolkits) + session = self._sessions.get(key) + if session is None: + session = self._composio().sessions.create( + user_id=scope.user_id, + toolkits=list(scope.toolkits), + # Explicit, and not optional. A default session hands back a + # remote shell and a remote Python tool with no opt-in, and the + # SDK only defaults them off under the direct-tools preset. The + # agent already has a sandbox behind its own credentials in + # `coding/`; a second ungated one arriving as a side effect of a + # toolkit list is a security surprise. + # + # `sandbox`, not `workbench`: the latter is a deprecated alias + # and passing both raises. + sandbox={"enable": False}, + ) + self._sessions[key] = session + return ScopedSession(session=session, scope=scope) + + def resolve(self, scopes: tuple[ResolvedScope, ...]) -> tuple[ScopedSession, ...]: + """ + Live sessions for every scope that can produce one. + + A scope whose session cannot be created is logged and dropped rather + than raising. One unreachable personal account must not take the team's + shared toolkits down for the turn, and a turn that runs with fewer tools + can still answer — while one that raises here answers nothing and + explains nothing. + + The log names the scope so an operator can tell whose account went + missing, and the provider's reason so they can tell why. Neither is a + credential: the api key never leaves this module, and a failure to + create a session is not itself a capability. + """ + resolved: list[ScopedSession] = [] + for scope in scopes: + try: + resolved.append(self.for_scope(scope)) + except Exception as error: # noqa: BLE001 - provider errors vary + logger.warning( + "[composio] no session for user=%s toolkits=%s — " + "running the turn without it: %s", + scope.user_id, + ",".join(scope.toolkits), + error, + ) + return tuple(resolved) diff --git a/agent/pyproject.toml b/agent/pyproject.toml index 85d4362..695af9f 100644 --- a/agent/pyproject.toml +++ b/agent/pyproject.toml @@ -5,6 +5,7 @@ description = "OpenTag general-purpose team knowledge-work agent — CopilotKit requires-python = ">=3.12" dependencies = [ "ag-ui-langgraph>=0.0.23", + "composio>=0.9.0", "copilotkit>=0.1.76", "deepagents>=0.6.12", "fastapi>=0.115.14", @@ -24,7 +25,7 @@ dependencies = [ dev = ["pytest>=8.0.0"] [tool.setuptools] -packages = ["prompts", "coding"] +packages = ["prompts", "coding", "composio_tools"] py-modules = [ "agent", "agui", diff --git a/agent/tests/test_composio_classify.py b/agent/tests/test_composio_classify.py new file mode 100644 index 0000000..24263e2 --- /dev/null +++ b/agent/tests/test_composio_classify.py @@ -0,0 +1,42 @@ +"""Effect classification and the approval decision it feeds.""" + +from __future__ import annotations + +import pytest + +from composio_tools.classify import effect_of, needs_approval + + +@pytest.mark.parametrize( + ("tags", "expected"), + [ + (["readOnlyHint"], "read"), + (["destructiveHint"], "destructive"), + # Both present: the dangerous claim wins. + (["readOnlyHint", "destructiveHint"], "destructive"), + (["somethingElse"], "write"), + # An empty list cannot be told apart from "nobody classified this", so + # it is not read-only. + ([], "write"), + (None, "write"), + ], +) +def test_effect_of_tags(tags, expected): + assert effect_of(tags) == expected + + +@pytest.mark.parametrize( + ("effect", "mode", "expected"), + [ + ("destructive", "off", False), + ("write", "off", False), + ("destructive", "destructive", True), + ("write", "destructive", False), + ("read", "destructive", False), + ("destructive", "writes", True), + ("write", "writes", True), + ("read", "writes", False), + ], +) +def test_needs_approval(effect, mode, expected): + assert needs_approval(effect, mode) is expected diff --git a/agent/tests/test_composio_config.py b/agent/tests/test_composio_config.py new file mode 100644 index 0000000..a77299a --- /dev/null +++ b/agent/tests/test_composio_config.py @@ -0,0 +1,87 @@ +"""The Composio environment contract.""" + +from __future__ import annotations + +import pytest + +from composio_tools.config import ComposioConfigError, read_composio_config + + +def test_no_api_key_reports_unconfigured(): + assert read_composio_config({}, default_user_id="open-tag") is None + + +def test_api_key_without_toolkits_reports_unconfigured(): + # A key naming no toolkit can reach nothing, so the agent must not advertise + # tools whose only possible answer is "nothing is set up". + config = read_composio_config( + {"COMPOSIO_API_KEY": "ak_test"}, default_user_id="open-tag" + ) + assert config is None + + +def test_toolkit_lists_are_split_trimmed_and_lowercased(): + config = read_composio_config( + { + "COMPOSIO_API_KEY": "ak_test", + "COMPOSIO_TOOLKITS": " Linear , JIRA ,, ", + "COMPOSIO_USER_TOOLKITS": "Gmail", + }, + default_user_id="open-tag", + ) + assert config is not None + assert config.workspace_toolkits == ("linear", "jira") + assert config.user_toolkits == ("gmail",) + + +def test_approvals_defaults_to_destructive_when_blank(): + # `COMPOSIO_APPROVALS=` is routine in .env files and compose passthrough. + # Unset is not invalid, and must not take the agent down at boot. + for raw in ("", " "): + config = read_composio_config( + { + "COMPOSIO_API_KEY": "ak_test", + "COMPOSIO_TOOLKITS": "linear", + "COMPOSIO_APPROVALS": raw, + }, + default_user_id="open-tag", + ) + assert config is not None + assert config.approvals == "destructive" + + +def test_unknown_approval_mode_is_refused_by_name(): + with pytest.raises(ComposioConfigError) as error: + read_composio_config( + { + "COMPOSIO_API_KEY": "ak_test", + "COMPOSIO_TOOLKITS": "linear", + "COMPOSIO_APPROVALS": "sometimes", + }, + default_user_id="open-tag", + ) + assert "sometimes" in str(error.value) + + +def test_workspace_user_id_falls_back_to_the_channel_name(): + config = read_composio_config( + {"COMPOSIO_API_KEY": "ak_test", "COMPOSIO_TOOLKITS": "linear"}, + default_user_id="open-tag", + ) + assert config is not None + assert config.workspace_user_id == "open-tag" + + +def test_auth_configs_keep_id_case_and_split_on_the_first_colon_only(): + config = read_composio_config( + { + "COMPOSIO_API_KEY": "ak_test", + "COMPOSIO_TOOLKITS": "linear", + # Real ids are mixed case and can contain a colon; a lowercased or + # truncated id does not resolve against the project. + "COMPOSIO_AUTH_CONFIGS": "Linear:ac_ExAmPle1:aB, broken, :x, y:", + }, + default_user_id="open-tag", + ) + assert config is not None + assert config.auth_configs == {"linear": "ac_ExAmPle1:aB"} diff --git a/agent/tests/test_composio_scopes.py b/agent/tests/test_composio_scopes.py new file mode 100644 index 0000000..d440b42 --- /dev/null +++ b/agent/tests/test_composio_scopes.py @@ -0,0 +1,78 @@ +"""Which identities a turn acts as, and what the agent says at boot.""" + +from __future__ import annotations + +from composio_tools.config import ComposioConfig +from composio_tools.scopes import resolve_scopes, startup_warnings + + +def config(**overrides) -> ComposioConfig: + defaults = { + "api_key": "ak_test", + "workspace_toolkits": ("linear",), + "user_toolkits": (), + "approvals": "destructive", + "workspace_user_id": "open-tag", + } + return ComposioConfig(**{**defaults, **overrides}) + + +def test_shared_toolkits_run_as_the_workspace_identity(): + scopes = resolve_scopes(config(), actor_id="U1") + assert [(s.user_id, s.toolkits, s.personal) for s in scopes] == [ + ("open-tag", ("linear",), False) + ] + + +def test_a_personal_toolkit_runs_as_the_person_who_spoke(): + scopes = resolve_scopes( + config(workspace_toolkits=("linear",), user_toolkits=("gmail",)), + actor_id="U1", + ) + assert [(s.user_id, s.toolkits, s.personal) for s in scopes] == [ + ("open-tag", ("linear",), False), + ("U1", ("gmail",), True), + ] + + +def test_a_toolkit_in_both_lists_runs_only_as_the_person(): + # Routing by slug is ambiguous when a slug lives in two sessions, and + # picking whichever loaded first would attribute an action to a person or to + # the shared account depending on restart order. + scopes = resolve_scopes( + config(workspace_toolkits=("linear", "gmail"), user_toolkits=("gmail",)), + actor_id="U1", + ) + assert [(s.user_id, s.toolkits) for s in scopes] == [ + ("open-tag", ("linear",)), + ("U1", ("gmail",)), + ] + + +def test_an_unidentified_turn_gets_no_access_to_a_personal_toolkit(): + # The de-duplication above is unconditional. Naming a toolkit in + # COMPOSIO_USER_TOOLKITS is the operator saying it must run as the person, + # so an anonymous turn must not fall through to the shared account. + for actor in (None, "", " "): + scopes = resolve_scopes( + config(workspace_toolkits=("gmail",), user_toolkits=("gmail",)), + actor_id=actor, + ) + assert scopes == () + + +def test_a_shared_personal_app_warns_that_everyone_shares_one_account(): + warnings = startup_warnings(config(workspace_toolkits=("gmail",)), env={}) + assert any("Every Slack user will act through ONE account" in w for w in warnings) + + +def test_a_toolkit_configured_twice_warns_that_approvals_will_vary(): + warnings = startup_warnings( + config(workspace_toolkits=("linear",)), + env={"LINEAR_API_KEY": "lin_test"}, + ) + assert any("configured twice" in w for w in warnings) + + +def test_a_quiet_configuration_says_nothing(): + assert startup_warnings(config(workspace_toolkits=("jira",)), env={}) == () diff --git a/agent/tests/test_composio_sessions.py b/agent/tests/test_composio_sessions.py new file mode 100644 index 0000000..42a05e5 --- /dev/null +++ b/agent/tests/test_composio_sessions.py @@ -0,0 +1,111 @@ +"""Session creation, caching, and what happens when one identity is unreachable.""" + +from __future__ import annotations + +import logging + +from composio_tools.config import ComposioConfig +from composio_tools.scopes import ResolvedScope +from composio_tools.sessions import SessionCache + + +class FakeSession: + def __init__(self, user_id: str) -> None: + self.user_id = user_id + + +class FakeSessions: + def __init__(self, *, fail_for: set[str] | None = None) -> None: + self.calls: list[dict] = [] + self._fail_for = fail_for or set() + + def create(self, **kwargs): + self.calls.append(kwargs) + user_id = kwargs["user_id"] + if user_id in self._fail_for: + raise RuntimeError("no connected account") + return FakeSession(user_id) + + +class FakeComposio: + def __init__(self, **kwargs) -> None: + self.sessions = FakeSessions(**kwargs) + + +def config() -> ComposioConfig: + return ComposioConfig( + api_key="ak_test", + workspace_toolkits=("linear",), + user_toolkits=("gmail",), + approvals="destructive", + workspace_user_id="open-tag", + ) + + +def scope(user_id: str, *toolkits: str, personal: bool = False) -> ResolvedScope: + return ResolvedScope(user_id=user_id, toolkits=toolkits, personal=personal) + + +def test_a_session_disables_the_sandbox_explicitly(): + # A default session hands back a remote shell and a remote Python tool with + # no opt-in, and the SDK only defaults them off under one preset we do not + # use. + client = FakeComposio() + SessionCache(config(), client=client).for_scope(scope("open-tag", "linear")) + + assert client.sessions.calls[0]["sandbox"] == {"enable": False} + # `workbench` is a deprecated alias and passing both raises. + assert "workbench" not in client.sessions.calls[0] + + +def test_a_session_is_created_once_per_identity_and_toolkit_set(): + client = FakeComposio() + cache = SessionCache(config(), client=client) + + first = cache.for_scope(scope("U1", "gmail", personal=True)) + again = cache.for_scope(scope("U1", "gmail", personal=True)) + other = cache.for_scope(scope("U2", "gmail", personal=True)) + + assert first.session is again.session + assert other.session is not first.session + assert [call["user_id"] for call in client.sessions.calls] == ["U1", "U2"] + + +def test_a_different_toolkit_set_is_a_different_session(): + client = FakeComposio() + cache = SessionCache(config(), client=client) + + cache.for_scope(scope("U1", "gmail", personal=True)) + cache.for_scope(scope("U1", "gmail", "googlecalendar", personal=True)) + + assert len(client.sessions.calls) == 2 + + +def test_one_unreachable_identity_does_not_cost_the_others(caplog): + # A broken personal account must not take the team's shared toolkits down + # for the turn: fewer tools can still answer, an exception answers nothing. + client = FakeComposio(fail_for={"U1"}) + cache = SessionCache(config(), client=client) + + with caplog.at_level(logging.WARNING): + resolved = cache.resolve( + ( + scope("open-tag", "linear"), + scope("U1", "gmail", personal=True), + ) + ) + + assert [entry.scope.user_id for entry in resolved] == ["open-tag"] + assert "U1" in caplog.text + assert "gmail" in caplog.text + assert "no connected account" in caplog.text + + +def test_the_api_key_stays_out_of_the_failure_log(caplog): + client = FakeComposio(fail_for={"U1"}) + cache = SessionCache(config(), client=client) + + with caplog.at_level(logging.WARNING): + cache.resolve((scope("U1", "gmail", personal=True),)) + + assert "ak_test" not in caplog.text diff --git a/agent/tests/test_packaging.py b/agent/tests/test_packaging.py index 5640c33..a5dd7a9 100644 --- a/agent/tests/test_packaging.py +++ b/agent/tests/test_packaging.py @@ -13,15 +13,33 @@ def test_wheel_includes_every_runtime_module(): } assert packaged_modules == runtime_modules - assert project["tool"]["setuptools"]["packages"] == ["prompts", "coding"] + + # Derived, not listed. A hardcoded list passes for whoever wrote it and + # fails the next person to add a package, which is backwards: the point is + # to catch a package that exists on disk and never reaches the wheel. + runtime_packages = { + path.parent.name + for path in agent_root.glob("*/__init__.py") + if path.parent.name not in {"tests", ".venv"} + } + assert set(project["tool"]["setuptools"]["packages"]) == runtime_packages -def test_agent_image_copies_the_coding_package(): +def test_agent_image_copies_every_runtime_package(): + # The image copies packages one line at a time, so a new package imports + # fine locally and crashes the container on first import. Derived from disk + # for the same reason as the wheel assertion above. + agent_root = Path(__file__).resolve().parent.parent repo_root = Path(__file__).resolve().parents[2] dockerfile = ( repo_root / "deployment" / "docker" / "agent.Dockerfile" ).read_text(encoding="utf-8") - assert "COPY agent/coding ./coding" in dockerfile + + for path in agent_root.glob("*/__init__.py"): + package = path.parent.name + if package in {"tests", ".venv"}: + continue + assert f"COPY agent/{package} ./{package}" in dockerfile def test_coding_dependencies_are_declared(): diff --git a/agent/uv.lock b/agent/uv.lock index 8252a97..0db8c39 100644 --- a/agent/uv.lock +++ b/agent/uv.lock @@ -445,6 +445,43 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "composio" +version = "0.21.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "composio-client" }, + { name = "json-schema-to-pydantic" }, + { name = "jsonschema" }, + { name = "openai" }, + { name = "pydantic" }, + { name = "pysher" }, + { name = "requests" }, + { name = "typing-extensions" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/2c/169aa85a8d42edf7e18032285beff001f8b492d253fa428eaf62dc75ee85/composio-0.21.0.tar.gz", hash = "sha256:334fbcc2358467a2eed7e04133fdd9080cf63007e53caffa50555023724e956a", size = 309944, upload-time = "2026-08-27T18:27:00.589Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/e7/28a2d0f4e63cd98e5a6d491496b9c7939996eb1823b0021b0b71e3bc8ce6/composio-0.21.0-py3-none-any.whl", hash = "sha256:8c26d8248b6f01c0b9e2453035291756f6c17d4f4971aaf073230206f8ec39f5", size = 187352, upload-time = "2026-08-27T18:26:48.764Z" }, +] + +[[package]] +name = "composio-client" +version = "1.43.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "httpx" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/4b/3789f4c1347fd01349b66ecaeffb5ef623434c3b1fa4f5c5993fddb69c68/composio_client-1.43.0.tar.gz", hash = "sha256:bb96700da0c2aabc394cebc954be0ebf419557cc42b40f5d148aac52a5aff6f9", size = 246767, upload-time = "2026-07-08T09:06:02.42Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/a2/3ae1f5471a52189ac558de6b0e088dc3575d695a0f91278cddf464274694/composio_client-1.43.0-py3-none-any.whl", hash = "sha256:3274d965b9efb6be90a51977f4c068ed24e2cad9160de4d40d3176d8bb4ce2d9", size = 277716, upload-time = "2026-07-08T09:06:01.007Z" }, +] + [[package]] name = "copilotkit" version = "0.1.94" @@ -1037,6 +1074,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/78/f7/18a1afcd64f35314b68c1f23afcd9994d0bc13e65cc77517afff4e83986d/jiter-0.16.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64d613743df53199b1aa256a7d328340da6d7078aac7705a7db9d7a791e9cfd2", size = 343885, upload-time = "2026-06-29T13:05:12.087Z" }, ] +[[package]] +name = "json-schema-to-pydantic" +version = "0.4.11" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/60/d8/423895b918706c80db1cee679c13fbe810200b9a9d9a9442c7a58d35c3f2/json_schema_to_pydantic-0.4.11.tar.gz", hash = "sha256:35448ed711a28dd33396b095c8492939b4925aa30eb31942e9b8e08d04279465", size = 56597, upload-time = "2026-03-09T20:53:55.692Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/64/7cfeb8c6d2a5e73e0f8d732032aa62be9a7724c04beb461d677de0b4beb3/json_schema_to_pydantic-0.4.11-py3-none-any.whl", hash = "sha256:da2ccc39d070ee03dbcf0517d16720e3e33f7aa8d61257ace09af8c51bd46348", size = 17842, upload-time = "2026-03-09T20:53:54.576Z" }, +] + [[package]] name = "jsonpatch" version = "1.33" @@ -1448,7 +1497,7 @@ wheels = [ [[package]] name = "openai" -version = "2.45.0" +version = "2.54.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -1460,9 +1509,9 @@ dependencies = [ { name = "tqdm" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/78/60/d4219875289b11d2c2f7da93c36283da224a2e55865ed865ab64e0ce9217/openai-2.45.0.tar.gz", hash = "sha256:10d34ca9c5643bce775852fddbfc172505cb1d4de1ccd101696c3ecff358765d", size = 1109653, upload-time = "2026-07-09T18:02:44.091Z" } +sdist = { url = "https://files.pythonhosted.org/packages/50/9a/8c75e8c8a5b407a0586faeb2afac91674ff955c191ecc1d6d3b6669f6788/openai-2.54.0.tar.gz", hash = "sha256:e3e6f8bc1ba30ddf381ace1a14340eed381cb984a1a59bd0f34b5be3b5d49cfa", size = 1100285, upload-time = "2026-08-11T18:46:59.035Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f1/b0/2291689e3ec4723fbf5bbf3b54afcd7b160f9ddc98ca7aedfd0132af5677/openai-2.45.0-py3-none-any.whl", hash = "sha256:5df105f5f8c9b711fcb9d06d2d3888cebc82506db216484c14a4e53cdf651777", size = 1629470, upload-time = "2026-07-09T18:02:42.21Z" }, + { url = "https://files.pythonhosted.org/packages/64/a8/bb76c7356de8ad57f59d5ff993d434df0607f07f08bcc9c9a5c275e399c0/openai-2.54.0-py3-none-any.whl", hash = "sha256:89089789197ccdb87f173a03145ed1598d00795220c93e96cf712b1cbf5e5f2b", size = 1660351, upload-time = "2026-08-11T18:46:56.684Z" }, ] [[package]] @@ -1471,6 +1520,7 @@ version = "0.1.0" source = { virtual = "." } dependencies = [ { name = "ag-ui-langgraph" }, + { name = "composio" }, { name = "copilotkit" }, { name = "daytona" }, { name = "deepagents" }, @@ -1494,6 +1544,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "ag-ui-langgraph", specifier = ">=0.0.23" }, + { name = "composio", specifier = ">=0.9.0" }, { name = "copilotkit", specifier = ">=0.1.76" }, { name = "daytona" }, { name = "deepagents", specifier = ">=0.6.12" }, @@ -2018,6 +2069,16 @@ crypto = [ { name = "cryptography" }, ] +[[package]] +name = "pysher" +version = "1.0.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, + { name = "websocket-client" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/12/a0/d0638470df605ce266991fb04f74c69ab1bed3b90ac3838e9c3c8b69b66a/Pysher-1.0.8.tar.gz", hash = "sha256:7849c56032b208e49df67d7bd8d49029a69042ab0bb45b2ed59fa08f11ac5988", size = 9071, upload-time = "2022-10-10T13:41:09.936Z" } + [[package]] name = "pytest" version = "9.1.1" diff --git a/deployment/docker/agent.Dockerfile b/deployment/docker/agent.Dockerfile index 5c18433..280866b 100644 --- a/deployment/docker/agent.Dockerfile +++ b/deployment/docker/agent.Dockerfile @@ -17,6 +17,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \ COPY agent/*.py ./ COPY agent/prompts ./prompts COPY agent/coding ./coding +COPY agent/composio_tools ./composio_tools RUN --mount=type=cache,target=/root/.cache/uv \ uv sync --frozen --no-dev \ && useradd --uid 10001 --create-home --home-dir /home/opentag opentag From d991551f78087e07e536add2ef292006fef17a27 Mon Sep 17 00:00:00 2001 From: Maxim Date: Tue, 1 Sep 2026 19:17:41 +0200 Subject: [PATCH 02/23] feat(composio): give the agent the tools, and the actor to run them as MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Registers `search_my_tools` and `run_my_tool` on the graph. The agent now has the capability on its own — the point of the move — and a person's own apps resolve to their own account without the Channel deciding anything. Identity is read per call from the actor the Channel forwards, so one tool registration serves everybody. The model chooses what to do; the platform decides whose account it happens in. It is never a tool argument the model fills, which is what made this unsafe to put in the agent before. `channel_actor` is declared on the state schema whether or not Composio is configured. The AG-UI adapter drops a forwarded key the schema does not name, so leaving it conditional would make "who spoke" depend on an unrelated feature flag. Verified against the adapter: an ordinary run is mode "start" and carries forwarded properties every turn, so the value cannot go stale when a second person speaks in the same thread — the adapter only treats a run as a continuation when the caller supplies a node name, which a Channel never does. Ported decisions worth keeping visible: - Discovery round-robins across scopes instead of concatenating them. The cap is global and scopes arrive shared-first, so concatenating answers "what's on my calendar" with five Linear tools. - A slug is placed by its toolkit prefix. Without that, a slug discovery never returned falls to the first scope — the shared account, which does not carry the toolkit at all — and a personal slug would run as the team on an anonymous turn. - `execute` reports a failed tool in `error` and does not raise, so that field is checked. A try/except alone reads every failed write as a success. - Only an explicit false connection status asks somebody to connect. An absent status is silence. A malformed forwarded actor reads as an anonymous turn. The value crosses a process boundary, and refusing personal access is the safe failure. The health test asserting an unconfigured agent exposes no tools was passing for the wrong reason: the repo `.env` is loaded at import, so an optional feature configured on a developer's machine leaked into it. It now clears the variable, and a sibling test covers the configured case. --- agent/agent.py | 43 ++++- agent/composio_tools/state.py | 57 +++++++ agent/composio_tools/tools.py | 233 +++++++++++++++++++++++++ agent/tests/test_composio_tools.py | 262 +++++++++++++++++++++++++++++ agent/tests/test_health.py | 47 ++++++ 5 files changed, 640 insertions(+), 2 deletions(-) create mode 100644 agent/composio_tools/state.py create mode 100644 agent/composio_tools/tools.py create mode 100644 agent/tests/test_composio_tools.py diff --git a/agent/agent.py b/agent/agent.py index d8fd087..cb7296c 100644 --- a/agent/agent.py +++ b/agent/agent.py @@ -1,5 +1,6 @@ """OpenTag's general-purpose knowledge-work Deep Agent.""" +import logging import os from pathlib import Path @@ -26,6 +27,11 @@ from coding.subagent import build_coder_subagent from copilotkit.langgraph import copilotkit_emit_message from langchain_core.runnables.config import ensure_config +from composio_tools.config import read_composio_config +from composio_tools.scopes import startup_warnings +from composio_tools.sessions import SessionCache +from composio_tools.state import ComposioAgentState +from composio_tools.tools import build_composio_tools from internal_sources import internal_source_toolsets from prompts import ( BASE_SYSTEM_PROMPT, @@ -39,6 +45,8 @@ ) from tools import web_search +logger = logging.getLogger(__name__) + load_dotenv(Path(__file__).resolve().parent.parent / ".env") @@ -174,10 +182,24 @@ def build_agent(): internal_tools = [ tool for tools in source_toolsets.values() for tool in tools ] + # Built once, here, rather than per turn: an unconfigured deployment + # constructs no client at all, and a misconfigured one says so at boot + # instead of once per message. Only the identity inside a call is per-turn. + composio_config = read_composio_config( + default_user_id=os.environ.get("INTELLIGENCE_CHANNEL_NAME", "open-tag"), + ) + composio_tools: list = [] + if composio_config is not None: + for warning in startup_warnings(composio_config): + logger.warning("[composio] %s", warning) + composio_tools = build_composio_tools( + composio_config, SessionCache(composio_config) + ) + main_tools = ( - [web_search, *internal_tools] + [web_search, *internal_tools, *composio_tools] if has_web_search - else [*internal_tools] + else [*internal_tools, *composio_tools] ) agent_display_name = ( @@ -208,6 +230,11 @@ def build_agent(): # create_agent rejects duplicate middleware names. "backend": StateBackend(), "checkpointer": checkpointer, + # Declared whether or not Composio is configured. The Channel forwards + # the actor on every run and the AG-UI adapter drops a forwarded key the + # state schema does not name, so leaving it out would make "who spoke" + # depend on an unrelated feature flag. + "state_schema": ComposioAgentState, } if coding_on: assert providers.coding is not None @@ -229,6 +256,18 @@ def build_agent(): print(f"[AGENT] web search: {'enabled' if has_web_search else 'disabled'}") print(f"[AGENT] coding: {'enabled' if coding_on else 'disabled'}") print(f"[AGENT] internal-source tools: {len(internal_tools)}") + print( + "[AGENT] composio: " + + ( + "disabled" + if composio_config is None + else "shared=" + + (",".join(composio_config.workspace_toolkits) or "none") + + " personal=" + + (",".join(composio_config.user_toolkits) or "none") + + f" approvals={composio_config.approvals}" + ) + ) print(f"[AGENT] Main tools: {[t.name for t in main_tools]}") # A coding turn uses many GitHub MCP reads before task(). 25 steps is diff --git a/agent/composio_tools/state.py b/agent/composio_tools/state.py new file mode 100644 index 0000000..7a865d0 --- /dev/null +++ b/agent/composio_tools/state.py @@ -0,0 +1,57 @@ +"""Graph state carrying who is speaking. + +The Channel forwards the verified actor with every run, and the AG-UI adapter +merges forwarded properties into the graph's input. A key only survives that +merge if the state schema declares it, which is what this module is for. + +Refreshed every ordinary turn, so it cannot go stale when a second person speaks +in the same thread: the adapter treats a run as a continuation only when the +caller supplies a node name, and a Channel never does. + +One exception, and it is the reason `pending.py` carries an identity of its own +rather than reading this: a resume is delivered as a resume command, and +forwarded properties do not travel with it. +""" + +from __future__ import annotations + +from typing import Any, NotRequired + +from deepagents import DeepAgentState + + +def actor_of(state: dict[str, Any] | None) -> dict[str, Any] | None: + """ + The forwarded actor, or `None` when the turn named nobody. + + Defensive about shape because this value crosses a process boundary: a + malformed `channel_actor` reads as an anonymous turn, which costs access to + personal toolkits and never grants it. + """ + actor = (state or {}).get("channel_actor") + if not isinstance(actor, dict): + return None + identifier = actor.get("id") + if not isinstance(identifier, str) or not identifier.strip(): + return None + return actor + + +def actor_key(actor: dict[str, Any] | None) -> str | None: + """ + The stable per-person key, namespaced by platform. + + A provider id is unique only within its provider, so two platforms can hand + out the same string for different people. Everything keyed per person — + a connected account, a pending approval — keys on both parts. + """ + if actor is None: + return None + platform = str(actor.get("platform") or "").strip() or "unknown" + return f"{platform}:{str(actor['id']).strip()}" + + +class ComposioAgentState(DeepAgentState): + """`DeepAgentState` plus the forwarded actor.""" + + channel_actor: NotRequired[dict[str, Any] | None] diff --git a/agent/composio_tools/tools.py b/agent/composio_tools/tools.py new file mode 100644 index 0000000..1c0697a --- /dev/null +++ b/agent/composio_tools/tools.py @@ -0,0 +1,233 @@ +"""The two tools the model sees: find an action, then run it. + +Registered once when the graph is built. Identity is read per call from the +forwarded actor in state, never captured at build time and never taken from a +model-supplied argument — the model chooses *what* to do, and the platform +decides *whose* account it happens in. + +Binding every tool of every connected toolkit is not an option: gmail alone +exposes 63, linear 47, googlecalendar 49. Composio's own session is a router, so +the model searches and then executes, and search returns the schemas inline — +which collapses search, fetch-schema, execute into two hops rather than three. +""" + +from __future__ import annotations + +import logging +from typing import Annotated, Any + +from langchain_core.tools import tool +from langgraph.prebuilt import InjectedState + +from composio_tools.config import ComposioConfig +from composio_tools.scopes import resolve_scopes +from composio_tools.sessions import ScopedSession, SessionCache +from composio_tools.state import actor_of + +logger = logging.getLogger(__name__) + +#: How many candidates the model sees. Tunable; not a principle. +MAX_RESULTS = 5 + + +def _as_list(value: Any) -> list[Any]: + return value if isinstance(value, list) else [] + + +def _as_dict(value: Any) -> dict[str, Any]: + return value if isinstance(value, dict) else {} + + +def _as_strings(value: Any) -> list[str]: + return [item for item in _as_list(value) if isinstance(item, str)] + + +def _candidates_of(response: Any) -> list[dict[str, Any]]: + """ + Every candidate one scope offers, in the order that scope ranked them. + + Primary slugs before related ones, because that ordering is the scope's own + judgement and there is nothing better to replace it with. + """ + payload = _as_dict(response) + schemas = _as_dict(payload.get("toolSchemas") or payload.get("tool_schemas")) + candidates: list[dict[str, Any]] = [] + + for entry in _as_list(payload.get("results")): + result = _as_dict(entry) + slugs = [ + *_as_strings(result.get("primaryToolSlugs") or result.get("primary_tool_slugs")), + *_as_strings(result.get("relatedToolSlugs") or result.get("related_tool_slugs")), + ] + for slug in slugs: + schema = _as_dict(schemas.get(slug)) + description = schema.get("description") + candidates.append( + { + "slug": slug, + "description": description if isinstance(description, str) else "", + "inputSchema": schema.get("inputSchema") + or schema.get("input_schema"), + } + ) + return candidates + + +def _interleave(per_scope: list[list[dict[str, Any]]]) -> list[dict[str, Any]]: + """ + Round-robin across scopes rather than concatenating them. + + Scopes arrive shared-first and the cap is global, so concatenating would let + a chatty shared scope fill every slot and make the asking person's own apps + unreachable — "what's on my calendar" answering with only Linear tools. + Taking one candidate from each scope in turn keeps every scope represented. + + Deduplicated by slug, first occurrence wins. A linear scan on purpose: the + lists hold a handful of entries and a set would buy nothing. + """ + merged: list[dict[str, Any]] = [] + deepest = max((len(entries) for entries in per_scope), default=0) + + for rank in range(deepest): + for entries in per_scope: + if rank >= len(entries): + continue + candidate = entries[rank] + if any(existing["slug"] == candidate["slug"] for existing in merged): + continue + merged.append(candidate) + return merged + + +def owns_slug(scope_toolkits: tuple[str, ...], slug: str) -> bool: + """ + Whether a toolkit set contains the toolkit a slug belongs to. + + Composio slugs are `TOOLKIT_REST_OF_NAME` with the toolkit uppercased — + `GMAIL_SEND_EMAIL`, `GOOGLECALENDAR_EVENTS_LIST` — so the prefix is the only + thing needed to place a slug that discovery never returned. Which is the + case that matters: without this, an unplaced slug falls to the first scope, + the shared account, which does not carry the toolkit at all. + """ + upper = slug.upper() + return any(upper.startswith(f"{toolkit.upper()}_") for toolkit in scope_toolkits) + + +def build_composio_tools(config: ComposioConfig, cache: SessionCache) -> list[Any]: + """The Composio tools for this deployment, or none at all.""" + + def sessions_for(state: dict[str, Any] | None) -> tuple[ScopedSession, ...]: + actor = actor_of(state) + scopes = resolve_scopes(config, (actor or {}).get("id")) + return cache.resolve(scopes) + + @tool + def search_my_tools( + query: str, + state: Annotated[dict[str, Any], InjectedState], + ) -> dict[str, Any] | str: + """Find actions available in the connected apps. Call this before run_my_tool. + + Args: + query: What you want to do, in plain words, e.g. 'send an email'. + """ + scopes = sessions_for(state) + if not scopes: + return "Connected apps are not configured for you." + + per_scope: list[list[dict[str, Any]]] = [] + needs_connection: list[str] = [] + + for entry in scopes: + try: + response = entry.session.search(query=query) + except Exception as error: # noqa: BLE001 - provider errors vary + # One scope's failure costs its own candidates and nothing else. + logger.warning( + "[composio] search failed for user=%s: %s", + entry.scope.user_id, + error, + ) + continue + + per_scope.append(_candidates_of(response)) + + for status in _as_list( + _as_dict(response).get("toolkitConnectionStatuses") + or _as_dict(response).get("toolkit_connection_statuses") + ): + fields = _as_dict(status) + active = fields.get("hasActiveConnection") + if active is None: + active = fields.get("has_active_connection") + # Only an explicit False means "not connected". An absent status + # is silence, not something to prompt a person about. + if active is not False: + continue + toolkit = fields.get("toolkit") + if isinstance(toolkit, str) and toolkit not in needs_connection: + needs_connection.append(toolkit) + + merged = _interleave(per_scope) + # A candidate with no schema cannot be called, so it must never displace + # one that can — but it still ships, so the model can see it exists. + ordered = [item for item in merged if item["inputSchema"] is not None] + [ + item for item in merged if item["inputSchema"] is None + ] + return { + "tools": ordered[:MAX_RESULTS], + "needsConnection": needs_connection, + } + + @tool + def run_my_tool( + slug: str, + arguments: dict[str, Any], + state: Annotated[dict[str, Any], InjectedState], + ) -> Any: + """Run one action found by search_my_tools. + + Args: + slug: The tool slug from search_my_tools, e.g. 'GMAIL_SEND_EMAIL'. + arguments: Arguments matching that tool's input schema. + """ + scopes = sessions_for(state) + if not scopes: + return "Connected apps are not configured for you." + + owning = next( + (entry for entry in scopes if owns_slug(entry.scope.toolkits, slug)), + None, + ) + if owning is None: + return ( + f"No connected app here provides {slug}. " + "Call search_my_tools and use a slug it returned." + ) + + result = owning.session.execute(slug, arguments) + fields = _as_dict(result) if not hasattr(result, "error") else None + error = fields.get("error") if fields is not None else getattr(result, "error", None) + data = fields.get("data") if fields is not None else getattr(result, "data", None) + log_id = ( + fields.get("logId") or fields.get("log_id") + if fields is not None + else getattr(result, "log_id", None) + ) + + # Mandatory, not defensive: execute reports a failed tool in `error` and + # does not raise, so a try/except alone reads every failed write as a + # success. + if error: + logger.warning( + "[composio] %s failed for user=%s (log=%s): %s", + slug, + owning.scope.user_id, + log_id, + error, + ) + return f"{slug} failed: {error}" + + return data + + return [search_my_tools, run_my_tool] diff --git a/agent/tests/test_composio_tools.py b/agent/tests/test_composio_tools.py new file mode 100644 index 0000000..4eedc35 --- /dev/null +++ b/agent/tests/test_composio_tools.py @@ -0,0 +1,262 @@ +"""Discovery and execution, and whose account each one happens in.""" + +from __future__ import annotations + +import logging + +import pytest + +from composio_tools.config import ComposioConfig +from composio_tools.sessions import SessionCache +from composio_tools.tools import build_composio_tools, owns_slug + +SCHEMA = {"type": "object", "properties": {}} + + +def search_response(*slugs, schema=SCHEMA, statuses=None): + return { + "results": [{"primaryToolSlugs": list(slugs)}], + "toolSchemas": { + slug: {"description": f"{slug} does a thing", "inputSchema": schema} + for slug in slugs + }, + **({"toolkitConnectionStatuses": statuses} if statuses else {}), + } + + +class FakeSession: + def __init__(self, user_id, response=None, result=None, fail_search=False): + self.user_id = user_id + self._response = response or search_response() + self._result = result if result is not None else {"data": {"ok": True}} + self._fail_search = fail_search + self.executed: list[tuple[str, dict]] = [] + + def search(self, *, query): + if self._fail_search: + raise RuntimeError("scope unreachable") + return self._response + + def execute(self, slug, arguments): + self.executed.append((slug, arguments)) + return self._result + + def authorize(self, toolkit): + raise NotImplementedError + + def toolkits(self): + raise NotImplementedError + + +class FakeComposio: + def __init__(self, sessions_by_user): + self.sessions = self + self._by_user = sessions_by_user + self.created: list[str] = [] + + def create(self, *, user_id, **kwargs): + self.created.append(user_id) + return self._by_user[user_id] + + +def config(**overrides) -> ComposioConfig: + defaults = { + "api_key": "ak_test", + "workspace_toolkits": ("linear",), + "user_toolkits": ("gmail",), + "approvals": "destructive", + "workspace_user_id": "open-tag", + } + return ComposioConfig(**{**defaults, **overrides}) + + +def tools_for(sessions_by_user, cfg=None): + cfg = cfg or config() + client = FakeComposio(sessions_by_user) + search, run = build_composio_tools(cfg, SessionCache(cfg, client=client)) + return search, run, client + + +def state(actor_id=None, platform="slack"): + if actor_id is None: + return {} + return {"channel_actor": {"id": actor_id, "kind": "human", "platform": platform}} + + +def test_an_anonymous_turn_reaches_only_the_shared_account(): + shared = FakeSession("open-tag", search_response("LINEAR_CREATE_ISSUE")) + search, _run, client = tools_for({"open-tag": shared}) + + result = search.invoke({"query": "file a bug", "state": state()}) + + assert client.created == ["open-tag"] + assert [entry["slug"] for entry in result["tools"]] == ["LINEAR_CREATE_ISSUE"] + + +def test_an_identified_turn_also_reaches_that_person(): + shared = FakeSession("open-tag", search_response("LINEAR_CREATE_ISSUE")) + personal = FakeSession("U1", search_response("GMAIL_SEND_EMAIL")) + search, _run, client = tools_for({"open-tag": shared, "U1": personal}) + + result = search.invoke({"query": "email the team", "state": state("U1")}) + + assert client.created == ["open-tag", "U1"] + assert {entry["slug"] for entry in result["tools"]} == { + "LINEAR_CREATE_ISSUE", + "GMAIL_SEND_EMAIL", + } + + +def test_a_malformed_actor_is_treated_as_anonymous(): + # The value crosses a process boundary. Refusing personal access is the safe + # failure; granting it on a shape we do not recognise is not. + shared = FakeSession("open-tag", search_response("LINEAR_CREATE_ISSUE")) + search, _run, client = tools_for({"open-tag": shared}) + + for actor in ("U1", {"kind": "human"}, {"id": ""}, {"id": 7}, None): + client.created.clear() + search.invoke({"query": "x", "state": {"channel_actor": actor}}) + assert client.created in ([], ["open-tag"]) + assert "U1" not in client.created + + +def test_a_chatty_shared_scope_cannot_crowd_out_the_person_asking(): + # Scopes arrive shared-first and the cap is global, so concatenating would + # answer "what's on my calendar" with five Linear tools. + shared = FakeSession( + "open-tag", + search_response(*[f"LINEAR_TOOL_{index}" for index in range(8)]), + ) + personal = FakeSession("U1", search_response("GMAIL_SEND_EMAIL")) + search, _run, _client = tools_for({"open-tag": shared, "U1": personal}) + + result = search.invoke({"query": "email", "state": state("U1")}) + + assert "GMAIL_SEND_EMAIL" in [entry["slug"] for entry in result["tools"]] + + +def test_a_schemaless_candidate_never_displaces_a_callable_one(): + shared = FakeSession( + "open-tag", + { + "results": [{"primaryToolSlugs": ["LINEAR_NO_SCHEMA", "LINEAR_OK"]}], + "toolSchemas": { + "LINEAR_NO_SCHEMA": {"description": "unusable"}, + "LINEAR_OK": {"description": "usable", "inputSchema": SCHEMA}, + }, + }, + ) + search, _run, _client = tools_for({"open-tag": shared}) + + slugs = [entry["slug"] for entry in search.invoke({"query": "x", "state": state()})["tools"]] + + assert slugs == ["LINEAR_OK", "LINEAR_NO_SCHEMA"] + + +def test_only_an_explicit_false_asks_someone_to_connect(): + shared = FakeSession( + "open-tag", + search_response( + "LINEAR_OK", + statuses=[ + {"toolkit": "linear", "hasActiveConnection": False}, + {"toolkit": "jira"}, + ], + ), + ) + search, _run, _client = tools_for({"open-tag": shared}) + + result = search.invoke({"query": "x", "state": state()}) + + assert result["needsConnection"] == ["linear"] + + +def test_one_unreachable_scope_costs_only_its_own_candidates(caplog): + shared = FakeSession("open-tag", search_response("LINEAR_OK")) + personal = FakeSession("U1", fail_search=True) + search, _run, _client = tools_for({"open-tag": shared, "U1": personal}) + + with caplog.at_level(logging.WARNING): + result = search.invoke({"query": "x", "state": state("U1")}) + + assert [entry["slug"] for entry in result["tools"]] == ["LINEAR_OK"] + assert "scope unreachable" in caplog.text + + +def test_a_call_runs_in_the_account_that_owns_its_toolkit(): + shared = FakeSession("open-tag") + personal = FakeSession("U1") + _search, run, _client = tools_for({"open-tag": shared, "U1": personal}) + + run.invoke( + {"slug": "GMAIL_SEND_EMAIL", "arguments": {"to": "a@b.c"}, "state": state("U1")} + ) + + assert personal.executed == [("GMAIL_SEND_EMAIL", {"to": "a@b.c"})] + assert shared.executed == [] + + +def test_an_unplaceable_slug_is_refused_rather_than_run_as_the_shared_account(): + # Without prefix matching this falls to the first scope, which does not + # carry the toolkit at all. + shared = FakeSession("open-tag") + _search, run, _client = tools_for({"open-tag": shared}) + + result = run.invoke({"slug": "DROPBOX_DELETE", "arguments": {}, "state": state()}) + + assert "No connected app here provides DROPBOX_DELETE" in result + assert shared.executed == [] + + +def test_a_personal_slug_is_refused_on_an_anonymous_turn(): + shared = FakeSession("open-tag") + _search, run, _client = tools_for({"open-tag": shared}) + + result = run.invoke({"slug": "GMAIL_SEND_EMAIL", "arguments": {}, "state": state()}) + + assert "No connected app here provides" in result + assert shared.executed == [] + + +def test_a_reported_failure_is_a_failure(caplog): + # `execute` reports a failed tool in `error` and does not raise, so a + # try/except alone reads every failed write as a success. + shared = FakeSession( + "open-tag", + result={"data": None, "error": "Invalid request data provided", "logId": "log_1"}, + ) + _search, run, _client = tools_for({"open-tag": shared}) + + with caplog.at_level(logging.WARNING): + result = run.invoke( + {"slug": "LINEAR_CREATE_ISSUE", "arguments": {}, "state": state()} + ) + + assert "failed" in result + assert "Invalid request data provided" in result + assert "log_1" in caplog.text + + +def test_a_successful_call_returns_its_data(): + shared = FakeSession("open-tag", result={"data": {"id": "ISS-1"}, "error": None}) + _search, run, _client = tools_for({"open-tag": shared}) + + result = run.invoke( + {"slug": "LINEAR_CREATE_ISSUE", "arguments": {}, "state": state()} + ) + + assert result == {"id": "ISS-1"} + + +@pytest.mark.parametrize( + ("toolkits", "slug", "expected"), + [ + (("gmail",), "GMAIL_SEND_EMAIL", True), + (("googlecalendar",), "GOOGLECALENDAR_EVENTS_LIST", True), + (("gmail",), "GMAILX_SEND", False), + (("gmail",), "LINEAR_CREATE_ISSUE", False), + ((), "GMAIL_SEND_EMAIL", False), + ], +) +def test_owns_slug(toolkits, slug, expected): + assert owns_slug(toolkits, slug) is expected diff --git a/agent/tests/test_health.py b/agent/tests/test_health.py index ffb962c..1a2a824 100644 --- a/agent/tests/test_health.py +++ b/agent/tests/test_health.py @@ -101,6 +101,9 @@ def with_config(self, config): monkeypatch.delenv("DAYTONA_API_KEY", raising=False) monkeypatch.delenv("GITHUB_CODER_TOKEN", raising=False) monkeypatch.delenv("GITHUB_PERSONAL_ACCESS_TOKEN", raising=False) + # The repo `.env` is loaded at import, so an optional feature configured on + # the developer's machine otherwise leaks into this assertion. + monkeypatch.delenv("COMPOSIO_API_KEY", raising=False) monkeypatch.setattr(agent_mod, "ChatOpenAI", lambda **_kwargs: object()) monkeypatch.setattr(agent_mod, "internal_source_toolsets", lambda _provider: {}) @@ -115,6 +118,50 @@ def fake_create_deep_agent(**kwargs): assert captured["tools"] == [] +def test_build_agent_registers_composio_tools_only_when_configured(monkeypatch): + captured = {} + + class FakeGraph: + def with_config(self, config): + return self + + def build(env): + for name in ( + "TAVILY_API_KEY", + "DAYTONA_API_KEY", + "GITHUB_CODER_TOKEN", + "GITHUB_PERSONAL_ACCESS_TOKEN", + "COMPOSIO_API_KEY", + "COMPOSIO_TOOLKITS", + "COMPOSIO_USER_TOOLKITS", + ): + monkeypatch.delenv(name, raising=False) + for name, value in env.items(): + monkeypatch.setenv(name, value) + monkeypatch.setenv("OPENAI_API_KEY", "sk-test") + monkeypatch.setattr(agent_mod, "ChatOpenAI", lambda **_kwargs: object()) + monkeypatch.setattr( + agent_mod, "internal_source_toolsets", lambda _provider: {} + ) + monkeypatch.setattr( + agent_mod, + "create_deep_agent", + lambda **kwargs: (captured.update(kwargs), FakeGraph())[1], + ) + agent_mod.build_agent() + return [tool.name for tool in captured["tools"]] + + assert build({}) == [] + assert build( + {"COMPOSIO_API_KEY": "ak_test", "COMPOSIO_TOOLKITS": "linear"} + ) == ["search_my_tools", "run_my_tool"] + + # The actor key must be declared whichever way that went: the AG-UI adapter + # drops a forwarded key the state schema does not name, so "who spoke" must + # not depend on whether an unrelated feature is switched on. + assert "channel_actor" in captured["state_schema"].__annotations__ + + def test_system_prompt_requires_confirmation_only_for_writes(): prompt = agent_mod.BASE_SYSTEM_PROMPT From 6bdabd0ad6152a50c3928d4c9803e143acdc8b1b Mon Sep 17 00:00:00 2001 From: Maxim Date: Tue, 1 Sep 2026 19:33:24 +0200 Subject: [PATCH 03/23] feat(composio): gate Composio calls on the card that already gates writes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Composio call that needs signing off now pauses on `confirm_write` — the same card, the same pause, and the same resume that already gate a Linear or Notion mutation. That removes the second approval system this repo had and documented: one gate for every action a person has to answer for. Two things improve rather than merely move: - The graph resumes after the decision, so the model sees an approved call's result. The channel-side version could not, because it had no graph to resume, and said so in its own comments. - Effects are resolved one slug at a time and cached, instead of building the whole map up front under a fixed limit. A real slug past that limit used to be unclassified through no fault of the model; now the only unclassified slug is one that does not exist. An unclassifiable slug is destructive, not a write. `writes` mode gates both, but `destructive` — the default — gates only the first, so calling an unrecognised slug a write would run it unapproved in the mode most deployments ship with. That answer is deliberately not cached: a lookup that failed for a transient reason deserves another chance, and being wrong costs one prompt. A personal call names its approver, because approving it spends that person's access and nobody else's. The agent can only say whose call it is; the surface knows who clicked, so it enforces. That enforcement lands with the channel-side change. The test that matters here is the delayed approval. A resume is delivered as a resume command and carries no forwarded properties, so the actor that decided whose account the call runs in is not re-sent. It survives because the state schema declares it and the checkpoint keeps it — verified by removing that declaration and watching the personal call stop running. --- agent/composio_tools/effects.py | 61 +++++ agent/composio_tools/sessions.py | 9 +- agent/composio_tools/tools.py | 44 +++- agent/tests/test_composio_approval_resume.py | 231 +++++++++++++++++++ agent/tests/test_composio_tools.py | 158 ++++++++++++- 5 files changed, 496 insertions(+), 7 deletions(-) create mode 100644 agent/composio_tools/effects.py create mode 100644 agent/tests/test_composio_approval_resume.py diff --git a/agent/composio_tools/effects.py b/agent/composio_tools/effects.py new file mode 100644 index 0000000..81e9da5 --- /dev/null +++ b/agent/composio_tools/effects.py @@ -0,0 +1,61 @@ +"""What a slug does, resolved one slug at a time and remembered. + +The channel-side implementation this replaces built the whole map up front with +a fixed limit, which meant a real slug past that limit was unclassified through +no fault of the model. A per-slug lookup has no cap, so the only unclassified +slug left is one that does not exist. +""" + +from __future__ import annotations + +import logging +from typing import Any + +from composio_tools.classify import DESTRUCTIVE, effect_of + +logger = logging.getLogger(__name__) + + +class EffectMap: + """Per-slug effects, cached for the life of the process. + + A tool's tags do not change between calls, so one lookup per slug is enough + and a cache miss costs a single round trip on first use. + """ + + def __init__(self, client_factory) -> None: + self._client_factory = client_factory + self._effects: dict[str, str] = {} + + def effect_for(self, slug: str) -> str: + """ + The effect of one slug, erring towards the dangerous reading. + + A slug that cannot be looked up is destructive, not a write. `writes` + mode gates both, but `destructive` mode — the default — gates only the + first, so calling an unrecognised slug a write would run it unapproved + in the mode most deployments ship with. A hallucinated slug and a + prompt-injected one both arrive here looking exactly like a real one. + """ + cached = self._effects.get(slug) + if cached is not None: + return cached + + try: + tool: Any = self._client_factory().tools.get_raw_composio_tool_by_slug( + slug + ) + effect = effect_of(getattr(tool, "tags", None)) + except Exception as error: # noqa: BLE001 - provider errors vary + logger.warning( + "[composio] could not classify %s, treating it as destructive: %s", + slug, + error, + ) + # Deliberately not cached. A lookup that failed for a transient + # reason should get another chance, and the fail-safe answer costs + # only an approval prompt in the meantime. + return DESTRUCTIVE + + self._effects[slug] = effect + return effect diff --git a/agent/composio_tools/sessions.py b/agent/composio_tools/sessions.py index d8b01bb..e96a9ea 100644 --- a/agent/composio_tools/sessions.py +++ b/agent/composio_tools/sessions.py @@ -52,7 +52,12 @@ def __init__(self, config: ComposioConfig, *, client: Any | None = None) -> None self._client = client self._sessions: dict[tuple[str, tuple[str, ...]], Session] = {} - def _composio(self) -> Any: + def client(self) -> Any: + """The SDK client, constructed on first use. + + Shared with the effect map so one process holds one client, and so the + api key is read in exactly one place. + """ if self._client is None: self._client = Composio(api_key=self._config.api_key) return self._client @@ -62,7 +67,7 @@ def for_scope(self, scope: ResolvedScope) -> ScopedSession: key = (scope.user_id, scope.toolkits) session = self._sessions.get(key) if session is None: - session = self._composio().sessions.create( + session = self.client().sessions.create( user_id=scope.user_id, toolkits=list(scope.toolkits), # Explicit, and not optional. A default session hands back a diff --git a/agent/composio_tools/tools.py b/agent/composio_tools/tools.py index 1c0697a..db92c2b 100644 --- a/agent/composio_tools/tools.py +++ b/agent/composio_tools/tools.py @@ -19,10 +19,13 @@ from langchain_core.tools import tool from langgraph.prebuilt import InjectedState +from composio_tools.classify import needs_approval from composio_tools.config import ComposioConfig +from composio_tools.effects import EffectMap from composio_tools.scopes import resolve_scopes from composio_tools.sessions import ScopedSession, SessionCache -from composio_tools.state import actor_of +from composio_tools.state import actor_key, actor_of +from write_confirmation import require_write_confirmation, summarize_args logger = logging.getLogger(__name__) @@ -113,8 +116,20 @@ def owns_slug(scope_toolkits: tuple[str, ...], slug: str) -> bool: return any(upper.startswith(f"{toolkit.upper()}_") for toolkit in scope_toolkits) -def build_composio_tools(config: ComposioConfig, cache: SessionCache) -> list[Any]: +def humanize_slug(slug: str) -> str: + """`GMAIL_SEND_EMAIL` -> `Gmail send email`, for the approval card.""" + toolkit, _, rest = slug.partition("_") + words = (rest or toolkit).replace("_", " ").lower() + return f"{toolkit.capitalize()} {words}".strip() if rest else toolkit.capitalize() + + +def build_composio_tools( + config: ComposioConfig, + cache: SessionCache, + effects: EffectMap | None = None, +) -> list[Any]: """The Composio tools for this deployment, or none at all.""" + effects = effects or EffectMap(cache.client) def sessions_for(state: dict[str, Any] | None) -> tuple[ScopedSession, ...]: actor = actor_of(state) @@ -205,6 +220,31 @@ def run_my_tool( "Call search_my_tools and use a slug it returned." ) + effect = effects.effect_for(slug) + if needs_approval(effect, config.approvals): + # The same card, and the same pause, that already gate a Linear or + # Notion write. One gate for every action a person has to sign off + # on, rather than a second mechanism that behaves almost the same. + # + # The graph resumes after the decision, so unlike the channel-side + # version the model sees the result of an approved call. + approved = require_write_confirmation( + action=humanize_slug(slug), + fields=summarize_args(arguments), + extra_args={ + # Who may answer this card. A personal call runs in one + # person's account, so a colleague approving it would spend + # somebody else's access. The surface knows who clicked and + # enforces it; the agent can only say whose call it is. + "approver": actor_key(actor_of(state)) + if owning.scope.personal + else None, + "effect": effect, + }, + ) + if not approved: + return f"{humanize_slug(slug)} was declined, so nothing ran." + result = owning.session.execute(slug, arguments) fields = _as_dict(result) if not hasattr(result, "error") else None error = fields.get("error") if fields is not None else getattr(result, "error", None) diff --git a/agent/tests/test_composio_approval_resume.py b/agent/tests/test_composio_approval_resume.py new file mode 100644 index 0000000..6405705 --- /dev/null +++ b/agent/tests/test_composio_approval_resume.py @@ -0,0 +1,231 @@ +"""A gated Composio call, approved after the original run has finished. + +The interesting part is not the pause. It is that a resume is delivered as a +resume command and carries no forwarded properties, so the actor that decided +whose account the call runs in is not re-sent. If identity did not survive the +checkpoint, an approval clicked twenty minutes later would either fail or — much +worse — run in the wrong account. +""" + +from __future__ import annotations + +import asyncio +from typing import Any + +from ag_ui.core import RunAgentInput +from copilotkit import CopilotKitMiddleware +from deepagents import create_deep_agent +from langchain_core.language_models import BaseChatModel +from langchain_core.messages import AIMessage, BaseMessage, ToolMessage +from langchain_core.outputs import ChatGeneration, ChatResult +from langgraph.checkpoint.memory import MemorySaver + +from agui import build_agui_agent +from composio_tools.config import ComposioConfig +from composio_tools.sessions import SessionCache +from composio_tools.state import ComposioAgentState +from composio_tools.tools import build_composio_tools + +SLUG = "GMAIL_SEND_EMAIL" + + +class SendOnceModel(BaseChatModel): + """Calls the gated tool once, then stops.""" + + @property + def _llm_type(self): + return "composio-approval-resume" + + def bind_tools(self, tools, **_kwargs): + return self + + def _generate( + self, + messages: list[BaseMessage], + stop=None, + run_manager=None, + **_kwargs: Any, + ): + del stop, run_manager + already_ran = any(isinstance(message, ToolMessage) for message in messages) + message = ( + AIMessage(content="sent") + if already_ran + else AIMessage( + content="", + tool_calls=[ + { + "id": "send-1", + "name": "run_my_tool", + "args": {"slug": SLUG, "arguments": {"to": "a@b.c"}}, + } + ], + ) + ) + return ChatResult(generations=[ChatGeneration(message=message)]) + + +class RecordingSession: + def __init__(self, user_id: str) -> None: + self.user_id = user_id + self.executed: list[tuple[str, dict]] = [] + + def search(self, *, query): + raise AssertionError("this test does not search") + + def execute(self, slug, arguments): + self.executed.append((slug, arguments)) + return {"data": {"id": "msg-1"}, "error": None} + + def authorize(self, toolkit): + raise NotImplementedError + + def toolkits(self): + raise NotImplementedError + + +class RecordingComposio: + def __init__(self, sessions_by_user): + self.sessions = self + self._by_user = sessions_by_user + + def create(self, *, user_id, **_kwargs): + return self._by_user[user_id] + + +class AlwaysDestructive: + def effect_for(self, _slug): + return "destructive" + + +async def _collect(stream): + return [event async for event in stream] + + +def test_an_approval_after_the_run_ends_still_runs_in_the_asking_person_s_account(): + personal = RecordingSession("U1") + shared = RecordingSession("open-tag") + config = ComposioConfig( + api_key="ak_test", + workspace_toolkits=("linear",), + user_toolkits=("gmail",), + approvals="destructive", + workspace_user_id="open-tag", + ) + cache = SessionCache( + config, client=RecordingComposio({"U1": personal, "open-tag": shared}) + ) + checkpointer = MemorySaver() + graph = create_deep_agent( + model=SendOnceModel(), + tools=build_composio_tools(config, cache, AlwaysDestructive()), + middleware=[CopilotKitMiddleware()], + state_schema=ComposioAgentState, + checkpointer=checkpointer, + ) + agent = build_agui_agent(graph, recursion_limit=40) + + request = { + "threadId": "composio-approval-thread", + "state": {}, + "messages": [{"id": "user-1", "role": "user", "content": "email them"}], + "tools": [], + "context": [], + } + + first = asyncio.run( + _collect( + agent.run( + RunAgentInput( + runId="run-1", + forwardedProps={ + "channelActor": { + "id": "U1", + "kind": "human", + "platform": "slack", + } + }, + **request, + ) + ) + ) + ) + + assert any(getattr(event, "name", None) == "on_interrupt" for event in first) + assert personal.executed == [], "nothing may run before the person answers" + + # The resume carries the decision and nothing else — no actor, exactly as a + # real one does. + asyncio.run( + _collect( + agent.run( + RunAgentInput( + runId="run-2", + forwardedProps={"command": {"resume": {"confirmed": True}}}, + **request, + ) + ) + ) + ) + + assert personal.executed == [(SLUG, {"to": "a@b.c"})] + assert shared.executed == [], "a personal call must not fall to the shared account" + + +def test_a_declined_approval_runs_nothing(): + personal = RecordingSession("U1") + config = ComposioConfig( + api_key="ak_test", + workspace_toolkits=(), + user_toolkits=("gmail",), + approvals="destructive", + workspace_user_id="open-tag", + ) + cache = SessionCache(config, client=RecordingComposio({"U1": personal})) + checkpointer = MemorySaver() + graph = create_deep_agent( + model=SendOnceModel(), + tools=build_composio_tools(config, cache, AlwaysDestructive()), + middleware=[CopilotKitMiddleware()], + state_schema=ComposioAgentState, + checkpointer=checkpointer, + ) + agent = build_agui_agent(graph, recursion_limit=40) + request = { + "threadId": "composio-decline-thread", + "state": {}, + "messages": [{"id": "user-1", "role": "user", "content": "email them"}], + "tools": [], + "context": [], + } + + asyncio.run( + _collect( + agent.run( + RunAgentInput( + runId="run-1", + forwardedProps={ + "channelActor": { + "id": "U1", + "kind": "human", + "platform": "slack", + } + }, + **request, + ) + ) + ) + ) + asyncio.run( + _collect( + agent.run( + RunAgentInput( + runId="run-2", + forwardedProps={"command": {"resume": {"confirmed": False}}}, + **request, + ) + ) + ) + ) + + assert personal.executed == [] diff --git a/agent/tests/test_composio_tools.py b/agent/tests/test_composio_tools.py index 4eedc35..38cb826 100644 --- a/agent/tests/test_composio_tools.py +++ b/agent/tests/test_composio_tools.py @@ -6,9 +6,10 @@ import pytest +import composio_tools.tools as tools_mod from composio_tools.config import ComposioConfig from composio_tools.sessions import SessionCache -from composio_tools.tools import build_composio_tools, owns_slug +from composio_tools.tools import build_composio_tools, humanize_slug, owns_slug SCHEMA = {"type": "object", "properties": {}} @@ -70,10 +71,26 @@ def config(**overrides) -> ComposioConfig: return ComposioConfig(**{**defaults, **overrides}) -def tools_for(sessions_by_user, cfg=None): +class FakeEffects: + """Effects without a lookup. Read-only by default, so a test that is not + about approvals does not have to think about the gate.""" + + def __init__(self, effects=None, default="read"): + self._effects = effects or {} + self._default = default + self.asked: list[str] = [] + + def effect_for(self, slug): + self.asked.append(slug) + return self._effects.get(slug, self._default) + + +def tools_for(sessions_by_user, cfg=None, effects=None): cfg = cfg or config() client = FakeComposio(sessions_by_user) - search, run = build_composio_tools(cfg, SessionCache(cfg, client=client)) + search, run = build_composio_tools( + cfg, SessionCache(cfg, client=client), effects or FakeEffects() + ) return search, run, client @@ -260,3 +277,138 @@ def test_a_successful_call_returns_its_data(): ) def test_owns_slug(toolkits, slug, expected): assert owns_slug(toolkits, slug) is expected + + +class Recorder: + """Stands in for the approval pause, recording what the card was asked.""" + + def __init__(self, approve: bool) -> None: + self.approve = approve + self.calls: list[dict] = [] + + def __call__(self, *, action, fields, extra_args=None): + self.calls.append( + {"action": action, "fields": fields, "extra_args": extra_args or {}} + ) + return self.approve + + +def test_a_destructive_call_waits_for_approval_before_running(monkeypatch): + shared = FakeSession("open-tag") + _search, run, _client = tools_for( + {"open-tag": shared}, + effects=FakeEffects({"LINEAR_DELETE_ISSUE": "destructive"}), + ) + recorder = Recorder(approve=True) + monkeypatch.setattr(tools_mod, "require_write_confirmation", recorder) + + run.invoke({"slug": "LINEAR_DELETE_ISSUE", "arguments": {"id": "ISS-1"}, "state": state()}) + + assert len(recorder.calls) == 1 + assert recorder.calls[0]["action"] == "Linear delete issue" + assert shared.executed == [("LINEAR_DELETE_ISSUE", {"id": "ISS-1"})] + + +def test_a_declined_call_does_not_run(monkeypatch): + shared = FakeSession("open-tag") + _search, run, _client = tools_for( + {"open-tag": shared}, + effects=FakeEffects({"LINEAR_DELETE_ISSUE": "destructive"}), + ) + monkeypatch.setattr(tools_mod, "require_write_confirmation", Recorder(approve=False)) + + result = run.invoke( + {"slug": "LINEAR_DELETE_ISSUE", "arguments": {}, "state": state()} + ) + + assert "declined" in result + assert shared.executed == [] + + +def test_a_read_is_never_gated(monkeypatch): + shared = FakeSession("open-tag") + _search, run, _client = tools_for( + {"open-tag": shared}, effects=FakeEffects({"LINEAR_LIST_ISSUES": "read"}) + ) + recorder = Recorder(approve=True) + monkeypatch.setattr(tools_mod, "require_write_confirmation", recorder) + + run.invoke({"slug": "LINEAR_LIST_ISSUES", "arguments": {}, "state": state()}) + + assert recorder.calls == [] + assert shared.executed == [("LINEAR_LIST_ISSUES", {})] + + +def test_the_approval_mode_decides_whether_a_write_is_gated(monkeypatch): + for mode, gated in (("off", False), ("destructive", False), ("writes", True)): + shared = FakeSession("open-tag") + _search, run, _client = tools_for( + {"open-tag": shared}, + cfg=config(approvals=mode), + effects=FakeEffects({"LINEAR_CREATE_ISSUE": "write"}), + ) + recorder = Recorder(approve=True) + monkeypatch.setattr(tools_mod, "require_write_confirmation", recorder) + + run.invoke({"slug": "LINEAR_CREATE_ISSUE", "arguments": {}, "state": state()}) + + assert bool(recorder.calls) is gated, mode + + +def test_only_the_person_whose_account_it_is_may_approve(monkeypatch): + # A personal call spends one person's access, so a colleague clicking + # approve would spend somebody else's. The agent names the approver; the + # surface, which knows who clicked, enforces it. + personal = FakeSession("U1") + shared = FakeSession("open-tag") + _search, run, _client = tools_for( + {"open-tag": shared, "U1": personal}, + effects=FakeEffects({"GMAIL_SEND_EMAIL": "write"}), + cfg=config(approvals="writes"), + ) + recorder = Recorder(approve=True) + monkeypatch.setattr(tools_mod, "require_write_confirmation", recorder) + + run.invoke({"slug": "GMAIL_SEND_EMAIL", "arguments": {}, "state": state("U1")}) + + assert recorder.calls[0]["extra_args"]["approver"] == "slack:U1" + + +def test_a_shared_call_names_no_particular_approver(monkeypatch): + # A shared account is the team's, so anyone who can see the card may answer. + shared = FakeSession("open-tag") + _search, run, _client = tools_for( + {"open-tag": shared}, + effects=FakeEffects({"LINEAR_CREATE_ISSUE": "write"}), + cfg=config(approvals="writes"), + ) + recorder = Recorder(approve=True) + monkeypatch.setattr(tools_mod, "require_write_confirmation", recorder) + + run.invoke({"slug": "LINEAR_CREATE_ISSUE", "arguments": {}, "state": state("U1")}) + + assert recorder.calls[0]["extra_args"]["approver"] is None + + +def test_an_unplaceable_slug_is_refused_before_anything_is_classified(): + # Refusing first keeps a hallucinated slug from costing a lookup, and keeps + # the person from being asked to approve a call that could never run. + effects = FakeEffects() + shared = FakeSession("open-tag") + _search, run, _client = tools_for({"open-tag": shared}, effects=effects) + + run.invoke({"slug": "DROPBOX_DELETE", "arguments": {}, "state": state()}) + + assert effects.asked == [] + + +@pytest.mark.parametrize( + ("slug", "expected"), + [ + ("GMAIL_SEND_EMAIL", "Gmail send email"), + ("GOOGLECALENDAR_EVENTS_LIST", "Googlecalendar events list"), + ("LINEAR", "Linear"), + ], +) +def test_humanize_slug(slug, expected): + assert humanize_slug(slug) == expected From 262a91ceb3d9643dd0946bed05d38e00cbe1e3cd Mon Sep 17 00:00:00 2001 From: Maxim Date: Tue, 1 Sep 2026 19:54:11 +0200 Subject: [PATCH 04/23] feat(composio): mint connect links behind the shared secret the runtime sends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A connect link is a bearer capability: whoever opens it binds an account to the Composio user id the link was minted for. So the agent grows one route that mints a link for one person and one app, and the surface calls it — because the surface is what knows who clicked, and private delivery is the one thing an agent cannot do. The model never sees a URL. That route cannot be added safely as things stood. The runtime has always sent `AGENT_AUTH_HEADER` as its `Authorization` and this service has always ignored it. Behind Railway's private domain that was survivable; in front of a capability-minting endpoint it is not. So the secret is now checked, on two different rules: - Ordinary traffic is checked only when a secret is configured. A local run has none, and enforcing unconditionally would take every existing deployment down on upgrade. Health stays open either way; the platform probe cannot send one. - The connect route requires a secret of its own accord. With none configured it reports itself unavailable rather than serving. There is no configuration in which handing connect links to unauthenticated callers is intended. Compared with `compare_digest`, not `==`: an early-exit comparison leaks the length of the matching prefix, and this value is the only thing in front of the agent. Also fixes an identity collision carried over from the channel-side version: a Composio session was keyed on the raw provider id, so one deployment serving Slack and Teams would give `U1` on either platform the same Composio identity, and therefore each other's connected accounts. Sessions now key on platform and id together, which is what the approver field already did. Anyone who connected an account against the old key reconnects — only test workspaces, since that version never merged. A shared toolkit is refused a click-minted link rather than handled: it runs as one workspace identity, so a link minted for a clicker connects an account no shared call ever uses. The graph and the route share one runtime object. Two session caches would mean two sessions per identity, and one process holding one session is the reason this moved into the agent at all. --- agent/agent.py | 32 ++- agent/agent_auth.py | 78 +++++++ agent/composio_tools/connect.py | 91 ++++++++ agent/composio_tools/runtime.py | 72 +++++++ agent/composio_tools/tools.py | 7 +- agent/main.py | 65 +++++- agent/pyproject.toml | 1 + agent/tests/test_agent_auth.py | 53 +++++ agent/tests/test_composio_approval_resume.py | 8 +- agent/tests/test_composio_connect.py | 216 +++++++++++++++++++ agent/tests/test_composio_tools.py | 42 ++-- agent/tests/test_health.py | 7 +- 12 files changed, 634 insertions(+), 38 deletions(-) create mode 100644 agent/agent_auth.py create mode 100644 agent/composio_tools/connect.py create mode 100644 agent/composio_tools/runtime.py create mode 100644 agent/tests/test_agent_auth.py create mode 100644 agent/tests/test_composio_connect.py diff --git a/agent/agent.py b/agent/agent.py index cb7296c..c5199da 100644 --- a/agent/agent.py +++ b/agent/agent.py @@ -27,9 +27,7 @@ from coding.subagent import build_coder_subagent from copilotkit.langgraph import copilotkit_emit_message from langchain_core.runnables.config import ensure_config -from composio_tools.config import read_composio_config -from composio_tools.scopes import startup_warnings -from composio_tools.sessions import SessionCache +from composio_tools.runtime import composio_runtime from composio_tools.state import ComposioAgentState from composio_tools.tools import build_composio_tools from internal_sources import internal_source_toolsets @@ -182,19 +180,17 @@ def build_agent(): internal_tools = [ tool for tools in source_toolsets.values() for tool in tools ] - # Built once, here, rather than per turn: an unconfigured deployment - # constructs no client at all, and a misconfigured one says so at boot - # instead of once per message. Only the identity inside a call is per-turn. - composio_config = read_composio_config( + # The same runtime the connect route uses, built once per process. Two + # session caches would mean two sessions per identity, and one process + # holding one session is the reason this moved into the agent at all. + composio = composio_runtime( default_user_id=os.environ.get("INTELLIGENCE_CHANNEL_NAME", "open-tag"), ) - composio_tools: list = [] - if composio_config is not None: - for warning in startup_warnings(composio_config): - logger.warning("[composio] %s", warning) - composio_tools = build_composio_tools( - composio_config, SessionCache(composio_config) - ) + composio_tools: list = ( + [] + if composio is None + else build_composio_tools(composio.config, composio.cache, composio.effects) + ) main_tools = ( [web_search, *internal_tools, *composio_tools] @@ -260,12 +256,12 @@ def build_agent(): "[AGENT] composio: " + ( "disabled" - if composio_config is None + if composio is None else "shared=" - + (",".join(composio_config.workspace_toolkits) or "none") + + (",".join(composio.config.workspace_toolkits) or "none") + " personal=" - + (",".join(composio_config.user_toolkits) or "none") - + f" approvals={composio_config.approvals}" + + (",".join(composio.config.user_toolkits) or "none") + + f" approvals={composio.config.approvals}" ) ) print(f"[AGENT] Main tools: {[t.name for t in main_tools]}") diff --git a/agent/agent_auth.py b/agent/agent_auth.py new file mode 100644 index 0000000..61ec166 --- /dev/null +++ b/agent/agent_auth.py @@ -0,0 +1,78 @@ +"""The shared secret between the runtime and this agent. + +The runtime has always sent `AGENT_AUTH_HEADER` as its `Authorization` header and +this service has always ignored it. In the deployed topology that was survivable: +the runtime reaches the agent over Railway's private domain, so nothing off the +project could call it anyway. It is not survivable for an endpoint that mints +connect links, because such a link is a bearer capability — whoever opens it +binds an account to the user id it was minted for. + +Two different rules, on purpose: + +- Ordinary traffic is checked only when a secret is configured. A local `pnpm + dev` has no secret and must keep working, and switching enforcement on for + every existing deployment would take them down on upgrade. +- Anything that mints a capability requires a secret unconditionally. With none + configured the route reports itself unavailable rather than serving + unauthenticated. Fail closed where it counts, unchanged everywhere else. +""" + +from __future__ import annotations + +import hmac +import os +from collections.abc import Mapping + +#: Paths served without a secret even when one is configured. The platform's +#: health probe has no way to send one. +PUBLIC_PATHS = frozenset({"/health"}) + + +def configured_secret(env: Mapping[str, str] | None = None) -> str | None: + """The expected `Authorization` value, or `None` when none is configured.""" + source = os.environ if env is None else env + return (source.get("AGENT_AUTH_HEADER") or "").strip() or None + + +def header_matches(presented: str | None, expected: str) -> bool: + """ + Whether a presented header is the configured secret. + + Compared with `compare_digest` rather than `==`: an early-exit comparison + leaks the length of the matching prefix, and this value is the only thing + standing in front of the agent. + """ + if not presented: + return False + return hmac.compare_digest(presented.strip(), expected) + + +def is_authorized( + path: str, + presented: str | None, + env: Mapping[str, str] | None = None, +) -> bool: + """Whether ordinary traffic for `path` may proceed.""" + if path in PUBLIC_PATHS: + return True + expected = configured_secret(env) + if expected is None: + return True + return header_matches(presented, expected) + + +def authorizes_capability( + presented: str | None, + env: Mapping[str, str] | None = None, +) -> bool: + """ + Whether a capability-minting request may proceed. + + Unlike `is_authorized`, an absent secret is a refusal. There is no + configuration in which handing out connect links to unauthenticated callers + is the intended behaviour. + """ + expected = configured_secret(env) + if expected is None: + return False + return header_matches(presented, expected) diff --git a/agent/composio_tools/connect.py b/agent/composio_tools/connect.py new file mode 100644 index 0000000..454158c --- /dev/null +++ b/agent/composio_tools/connect.py @@ -0,0 +1,91 @@ +"""Minting a connect link for one person and one app. + +A connect link is a bearer capability: whoever opens it binds their account to +the Composio user id the link was minted for. So it is minted per clicker, on +demand, and handed back to the surface for private delivery — never posted where +somebody else can open it, and never shown to the model. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass + +from composio_tools.runtime import ComposioRuntime +from composio_tools.scopes import ResolvedScope + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class ConnectRefused: + """Why no link was minted, in words an operator can act on.""" + + reason: str + + +@dataclass(frozen=True) +class ConnectLink: + url: str + + +def connect_link( + runtime: ComposioRuntime, + *, + identity: str, + toolkit: str, +) -> ConnectLink | ConnectRefused: + """ + A link that connects `identity`'s own account for `toolkit`. + + `identity` is the platform-namespaced actor key, the same value a turn uses + to pick that person's session. A link minted against anything else connects + an account the agent will never look at again. + + A toolkit that is not personal is refused rather than handled. A shared + toolkit runs as one workspace identity, so a link minted for a clicker would + connect an account no shared call ever uses — the same broken end state the + operator connect script exists to prevent. + """ + slug = toolkit.strip().lower() + if not slug: + return ConnectRefused(reason="No app was named.") + if slug not in runtime.config.user_toolkits: + return ConnectRefused( + reason=( + f'"{slug}" is not one of the apps people connect for themselves. ' + "Shared apps are connected once by an operator, not from Slack." + ) + ) + + scope = ResolvedScope(user_id=identity, toolkits=(slug,), personal=True) + try: + session = runtime.cache.for_scope(scope).session + authorization = session.authorize(slug) + except Exception as error: # noqa: BLE001 - provider errors vary + # The identity, not the failure detail, is what an operator needs here, + # and the reason may quote provider text of unknown shape. + logger.warning( + "[composio] could not mint a %s connect link for %s: %s", + slug, + identity, + error, + ) + return ConnectRefused( + reason=f"Could not start the {slug} connection. Try again shortly." + ) + + url = getattr(authorization, "redirect_url", None) or getattr( + authorization, "redirectUrl", None + ) + if not isinstance(url, str) or not url: + logger.warning( + "[composio] %s authorization for %s returned no link", slug, identity + ) + return ConnectRefused( + reason=f"Could not start the {slug} connection. Try again shortly." + ) + + # Never logged. The whole point of the private delivery is that this string + # reaches exactly one person, and a log is not that. + return ConnectLink(url=url) diff --git a/agent/composio_tools/runtime.py b/agent/composio_tools/runtime.py new file mode 100644 index 0000000..74eb387 --- /dev/null +++ b/agent/composio_tools/runtime.py @@ -0,0 +1,72 @@ +"""One Composio setup per process, shared by the graph and the HTTP surface. + +The graph needs it to register tools. The connect route needs it to mint a link +for one person. Both must be the same object: two session caches would mean two +sessions per identity, and the point of moving this into the agent was that only +one process holds a Composio session. +""" + +from __future__ import annotations + +import logging +from collections.abc import Mapping +from dataclasses import dataclass + +from composio_tools.config import ComposioConfig, read_composio_config +from composio_tools.effects import EffectMap +from composio_tools.scopes import startup_warnings +from composio_tools.sessions import SessionCache + +logger = logging.getLogger(__name__) + +_runtime: ComposioRuntime | None = None +_built = False + + +@dataclass(frozen=True) +class ComposioRuntime: + config: ComposioConfig + cache: SessionCache + effects: EffectMap + + +def build_composio_runtime( + env: Mapping[str, str] | None = None, + *, + default_user_id: str, +) -> ComposioRuntime | None: + """Read the configuration and construct the shared pieces, or `None`.""" + config = read_composio_config(env, default_user_id=default_user_id) + if config is None: + return None + + # Said once, at boot, rather than once per message. + for warning in startup_warnings(config, env): + logger.warning("[composio] %s", warning) + + cache = SessionCache(config) + return ComposioRuntime(config=config, cache=cache, effects=EffectMap(cache.client)) + + +def composio_runtime( + env: Mapping[str, str] | None = None, + *, + default_user_id: str = "open-tag", +) -> ComposioRuntime | None: + """The process-wide runtime, built on first use. + + Cached including the `None` answer: an unconfigured deployment must not + re-read the environment and re-log on every request to the connect route. + """ + global _runtime, _built + if not _built: + _runtime = build_composio_runtime(env, default_user_id=default_user_id) + _built = True + return _runtime + + +def reset_composio_runtime() -> None: + """Drop the cached runtime. For tests, which vary the environment.""" + global _runtime, _built + _runtime = None + _built = False diff --git a/agent/composio_tools/tools.py b/agent/composio_tools/tools.py index db92c2b..bbd9852 100644 --- a/agent/composio_tools/tools.py +++ b/agent/composio_tools/tools.py @@ -132,8 +132,11 @@ def build_composio_tools( effects = effects or EffectMap(cache.client) def sessions_for(state: dict[str, Any] | None) -> tuple[ScopedSession, ...]: - actor = actor_of(state) - scopes = resolve_scopes(config, (actor or {}).get("id")) + # The platform-namespaced key, not the raw provider id. A provider id is + # unique only within its provider, so one deployment serving Slack and + # Teams would otherwise give `U1` on either platform the same Composio + # identity — and therefore each other's connected accounts. + scopes = resolve_scopes(config, actor_key(actor_of(state))) return cache.resolve(scopes) @tool diff --git a/agent/main.py b/agent/main.py index 3eeba37..92e0a59 100644 --- a/agent/main.py +++ b/agent/main.py @@ -5,11 +5,17 @@ import sys from ag_ui_langgraph import add_langgraph_fastapi_endpoint -from fastapi import FastAPI +from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse +from pydantic import BaseModel from agent import build_agent +from agent_auth import authorizes_capability, is_authorized from agui import AGENT_DESCRIPTION, AGENT_NAME, build_agui_agent +from composio_tools.connect import ConnectRefused, connect_link +from composio_tools.runtime import composio_runtime +from composio_tools.state import actor_key app = FastAPI( title="OpenTag Agent", @@ -32,12 +38,69 @@ ) +@app.middleware("http") +async def require_shared_secret(request: Request, call_next): + """Check the runtime's shared secret, when one is configured. + + Only when configured: a local run has no secret, and enforcing + unconditionally would take every existing deployment down on upgrade. The + connect route does not rely on this — it requires a secret of its own + accord, because handing out a bearer capability to an unauthenticated caller + has no correct configuration. + """ + if not is_authorized(request.url.path, request.headers.get("authorization")): + return JSONResponse({"error": "unauthorized"}, status_code=401) + return await call_next(request) + + @app.get("/health") def health(): """Return service health.""" return {"status": "ok", "service": "opentag-agent", "version": "0.1.0"} +class ConnectRequest(BaseModel): + """One person, one app. No link comes in; exactly one goes out.""" + + actor_id: str + platform: str + toolkit: str + + +@app.post("/composio/connect") +def composio_connect(body: ConnectRequest, request: Request): + """Mint a connect link for one person's own account. + + The response is a bearer capability, so this route is deliberately stricter + than the rest of the service: with no shared secret configured it reports + itself unavailable rather than serving. + + The surface calls it because the surface is what knows who clicked, and the + surface delivers the link privately because that is the one thing an agent + cannot do. The model never sees the URL. + """ + if not authorizes_capability(request.headers.get("authorization")): + return JSONResponse({"error": "unauthorized"}, status_code=401) + + runtime = composio_runtime( + default_user_id=os.environ.get("INTELLIGENCE_CHANNEL_NAME", "open-tag") + ) + if runtime is None: + return JSONResponse( + {"error": "Composio is not configured on this deployment."}, + status_code=503, + ) + + identity = actor_key({"id": body.actor_id, "platform": body.platform}) + if identity is None: + return JSONResponse({"error": "No person was named."}, status_code=400) + + result = connect_link(runtime, identity=identity, toolkit=body.toolkit) + if isinstance(result, ConnectRefused): + return JSONResponse({"error": result.reason}, status_code=400) + return {"redirectUrl": result.url} + + def local_server_port(env: Mapping[str, str] = os.environ) -> int: """Resolve the local agent port without consuming the Channel's `PORT`.""" raw_port = env.get("SERVER_PORT", "8123") diff --git a/agent/pyproject.toml b/agent/pyproject.toml index 695af9f..187c328 100644 --- a/agent/pyproject.toml +++ b/agent/pyproject.toml @@ -28,6 +28,7 @@ dev = ["pytest>=8.0.0"] packages = ["prompts", "coding", "composio_tools"] py-modules = [ "agent", + "agent_auth", "agui", "internal_sources", "main", diff --git a/agent/tests/test_agent_auth.py b/agent/tests/test_agent_auth.py new file mode 100644 index 0000000..cef18fc --- /dev/null +++ b/agent/tests/test_agent_auth.py @@ -0,0 +1,53 @@ +"""The shared secret between the runtime and this agent.""" + +from __future__ import annotations + +from agent_auth import authorizes_capability, header_matches, is_authorized + + +def test_ordinary_traffic_is_open_when_no_secret_is_configured(): + # A local run has no secret, and enforcing unconditionally would take every + # existing deployment down on upgrade. + assert is_authorized("/", None, env={}) is True + assert is_authorized("/", "anything", env={}) is True + + +def test_ordinary_traffic_needs_the_secret_once_one_is_configured(): + env = {"AGENT_AUTH_HEADER": "Bearer s3cret"} + assert is_authorized("/", "Bearer s3cret", env=env) is True + assert is_authorized("/", "Bearer wrong", env=env) is False + assert is_authorized("/", None, env=env) is False + + +def test_health_stays_open_so_the_platform_probe_keeps_working(): + env = {"AGENT_AUTH_HEADER": "Bearer s3cret"} + assert is_authorized("/health", None, env=env) is True + + +def test_a_capability_is_refused_when_no_secret_is_configured(): + # Unlike ordinary traffic, an absent secret is a refusal here: there is no + # configuration in which handing connect links to unauthenticated callers is + # the intended behaviour. + assert authorizes_capability("anything", env={}) is False + assert authorizes_capability(None, env={}) is False + + +def test_a_capability_needs_the_exact_secret(): + env = {"AGENT_AUTH_HEADER": "Bearer s3cret"} + assert authorizes_capability("Bearer s3cret", env=env) is True + assert authorizes_capability("Bearer s3cre", env=env) is False + assert authorizes_capability("bearer s3cret", env=env) is False + + +def test_a_blank_or_whitespace_secret_counts_as_unconfigured(): + # `AGENT_AUTH_HEADER=` is routine in .env files and compose passthrough, and + # must not become a secret that equals the empty string. + for raw in ("", " "): + assert is_authorized("/", None, env={"AGENT_AUTH_HEADER": raw}) is True + assert authorizes_capability(None, env={"AGENT_AUTH_HEADER": raw}) is False + + +def test_surrounding_whitespace_does_not_change_a_match(): + assert header_matches(" Bearer s3cret ", "Bearer s3cret") is True + assert header_matches("", "Bearer s3cret") is False + assert header_matches(None, "Bearer s3cret") is False diff --git a/agent/tests/test_composio_approval_resume.py b/agent/tests/test_composio_approval_resume.py index 6405705..e446708 100644 --- a/agent/tests/test_composio_approval_resume.py +++ b/agent/tests/test_composio_approval_resume.py @@ -103,7 +103,7 @@ async def _collect(stream): def test_an_approval_after_the_run_ends_still_runs_in_the_asking_person_s_account(): - personal = RecordingSession("U1") + personal = RecordingSession("slack:U1") shared = RecordingSession("open-tag") config = ComposioConfig( api_key="ak_test", @@ -113,7 +113,7 @@ def test_an_approval_after_the_run_ends_still_runs_in_the_asking_person_s_accoun workspace_user_id="open-tag", ) cache = SessionCache( - config, client=RecordingComposio({"U1": personal, "open-tag": shared}) + config, client=RecordingComposio({"slack:U1": personal, "open-tag": shared}) ) checkpointer = MemorySaver() graph = create_deep_agent( @@ -173,7 +173,7 @@ def test_an_approval_after_the_run_ends_still_runs_in_the_asking_person_s_accoun def test_a_declined_approval_runs_nothing(): - personal = RecordingSession("U1") + personal = RecordingSession("slack:U1") config = ComposioConfig( api_key="ak_test", workspace_toolkits=(), @@ -181,7 +181,7 @@ def test_a_declined_approval_runs_nothing(): approvals="destructive", workspace_user_id="open-tag", ) - cache = SessionCache(config, client=RecordingComposio({"U1": personal})) + cache = SessionCache(config, client=RecordingComposio({"slack:U1": personal})) checkpointer = MemorySaver() graph = create_deep_agent( model=SendOnceModel(), diff --git a/agent/tests/test_composio_connect.py b/agent/tests/test_composio_connect.py new file mode 100644 index 0000000..d2c3919 --- /dev/null +++ b/agent/tests/test_composio_connect.py @@ -0,0 +1,216 @@ +"""Minting a connect link, and the route that serves one.""" + +from __future__ import annotations + +import logging + +import pytest +from fastapi.testclient import TestClient + +import composio_tools.runtime as runtime_mod +from composio_tools.config import ComposioConfig +from composio_tools.connect import ConnectRefused, connect_link +from composio_tools.runtime import ComposioRuntime, reset_composio_runtime +from composio_tools.sessions import SessionCache + +LINK = "https://backend.composio.dev/connect/abc123" + + +class FakeAuthorization: + def __init__(self, url=LINK): + self.redirect_url = url + + +class FakeSession: + def __init__(self, user_id, *, fail=False, url=LINK): + self.user_id = user_id + self._fail = fail + self._url = url + self.authorized: list[str] = [] + + def search(self, *, query): + raise NotImplementedError + + def execute(self, slug, arguments): + raise NotImplementedError + + def authorize(self, toolkit): + self.authorized.append(toolkit) + if self._fail: + raise RuntimeError("provider said no") + return FakeAuthorization(self._url) + + def toolkits(self): + raise NotImplementedError + + +class FakeComposio: + def __init__(self, sessions_by_user): + self.sessions = self + self._by_user = sessions_by_user + self.created: list[str] = [] + + def create(self, *, user_id, **_kwargs): + self.created.append(user_id) + return self._by_user.setdefault(user_id, FakeSession(user_id)) + + +class FakeEffects: + def effect_for(self, _slug): + return "read" + + +def runtime_for(sessions_by_user, **config_overrides): + defaults = { + "api_key": "ak_test", + "workspace_toolkits": ("linear",), + "user_toolkits": ("gmail",), + "approvals": "destructive", + "workspace_user_id": "open-tag", + } + config = ComposioConfig(**{**defaults, **config_overrides}) + client = FakeComposio(sessions_by_user) + return ( + ComposioRuntime( + config=config, + cache=SessionCache(config, client=client), + effects=FakeEffects(), + ), + client, + ) + + +def test_a_personal_app_gets_a_link_minted_for_that_person(): + sessions = {} + runtime, client = runtime_for(sessions) + + result = connect_link(runtime, identity="slack:U1", toolkit="gmail") + + assert result.url == LINK + assert client.created == ["slack:U1"] + assert sessions["slack:U1"].authorized == ["gmail"] + + +def test_a_shared_app_is_refused_rather_than_connected_by_a_clicker(): + # A shared toolkit runs as one workspace identity, so a link minted for a + # clicker connects an account no shared call ever uses. + runtime, client = runtime_for({}) + + result = connect_link(runtime, identity="slack:U1", toolkit="linear") + + assert isinstance(result, ConnectRefused) + assert "not one of the apps people connect for themselves" in result.reason + assert client.created == [] + + +def test_an_unknown_app_is_refused(): + runtime, _client = runtime_for({}) + assert isinstance(connect_link(runtime, identity="slack:U1", toolkit="dropbox"), ConnectRefused) + assert isinstance(connect_link(runtime, identity="slack:U1", toolkit=" "), ConnectRefused) + + +def test_a_provider_failure_becomes_a_reason_not_an_exception(caplog): + sessions = {"slack:U1": FakeSession("slack:U1", fail=True)} + runtime, _client = runtime_for(sessions) + + with caplog.at_level(logging.WARNING): + result = connect_link(runtime, identity="slack:U1", toolkit="gmail") + + assert isinstance(result, ConnectRefused) + assert "provider said no" in caplog.text + + +def test_an_authorization_with_no_link_is_a_refusal(): + sessions = {"slack:U1": FakeSession("slack:U1", url="")} + runtime, _client = runtime_for(sessions) + + assert isinstance(connect_link(runtime, identity="slack:U1", toolkit="gmail"), ConnectRefused) + + +def test_the_link_is_never_logged(caplog): + sessions = {} + runtime, _client = runtime_for(sessions) + + with caplog.at_level(logging.DEBUG): + connect_link(runtime, identity="slack:U1", toolkit="gmail") + + assert LINK not in caplog.text + + +@pytest.fixture +def client(monkeypatch): + reset_composio_runtime() + monkeypatch.setenv("OPENAI_API_KEY", "sk-test") + import main + + yield TestClient(main.app) + reset_composio_runtime() + + +def install_runtime(monkeypatch, sessions_by_user, **overrides): + runtime, client = runtime_for(sessions_by_user, **overrides) + monkeypatch.setattr(runtime_mod, "build_composio_runtime", lambda *a, **k: runtime) + reset_composio_runtime() + return runtime, client + + +def test_the_route_refuses_without_a_configured_secret(client, monkeypatch): + # The response is a bearer capability. With no secret there is no + # configuration in which serving it is right, so it fails closed. + monkeypatch.delenv("AGENT_AUTH_HEADER", raising=False) + install_runtime(monkeypatch, {}) + + response = client.post( + "/composio/connect", + json={"actor_id": "U1", "platform": "slack", "toolkit": "gmail"}, + ) + + assert response.status_code == 401 + + +def test_the_route_refuses_a_wrong_secret(client, monkeypatch): + monkeypatch.setenv("AGENT_AUTH_HEADER", "Bearer s3cret") + install_runtime(monkeypatch, {}) + + response = client.post( + "/composio/connect", + json={"actor_id": "U1", "platform": "slack", "toolkit": "gmail"}, + headers={"Authorization": "Bearer wrong"}, + ) + + assert response.status_code == 401 + + +def test_the_route_returns_a_link_for_the_named_person(client, monkeypatch): + monkeypatch.setenv("AGENT_AUTH_HEADER", "Bearer s3cret") + sessions = {} + _runtime, composio = install_runtime(monkeypatch, sessions) + + response = client.post( + "/composio/connect", + json={"actor_id": "U1", "platform": "slack", "toolkit": "gmail"}, + headers={"Authorization": "Bearer s3cret"}, + ) + + assert response.status_code == 200 + assert response.json() == {"redirectUrl": LINK} + assert composio.created == ["slack:U1"] + + +def test_the_route_reports_an_unconfigured_deployment(client, monkeypatch): + monkeypatch.setenv("AGENT_AUTH_HEADER", "Bearer s3cret") + monkeypatch.setattr(runtime_mod, "build_composio_runtime", lambda *a, **k: None) + reset_composio_runtime() + + response = client.post( + "/composio/connect", + json={"actor_id": "U1", "platform": "slack", "toolkit": "gmail"}, + headers={"Authorization": "Bearer s3cret"}, + ) + + assert response.status_code == 503 + + +def test_health_stays_reachable_without_the_secret(client, monkeypatch): + monkeypatch.setenv("AGENT_AUTH_HEADER", "Bearer s3cret") + assert client.get("/health").status_code == 200 diff --git a/agent/tests/test_composio_tools.py b/agent/tests/test_composio_tools.py index 38cb826..5de1c47 100644 --- a/agent/tests/test_composio_tools.py +++ b/agent/tests/test_composio_tools.py @@ -112,12 +112,12 @@ def test_an_anonymous_turn_reaches_only_the_shared_account(): def test_an_identified_turn_also_reaches_that_person(): shared = FakeSession("open-tag", search_response("LINEAR_CREATE_ISSUE")) - personal = FakeSession("U1", search_response("GMAIL_SEND_EMAIL")) - search, _run, client = tools_for({"open-tag": shared, "U1": personal}) + personal = FakeSession("slack:U1", search_response("GMAIL_SEND_EMAIL")) + search, _run, client = tools_for({"open-tag": shared, "slack:U1": personal}) result = search.invoke({"query": "email the team", "state": state("U1")}) - assert client.created == ["open-tag", "U1"] + assert client.created == ["open-tag", "slack:U1"] assert {entry["slug"] for entry in result["tools"]} == { "LINEAR_CREATE_ISSUE", "GMAIL_SEND_EMAIL", @@ -134,7 +134,7 @@ def test_a_malformed_actor_is_treated_as_anonymous(): client.created.clear() search.invoke({"query": "x", "state": {"channel_actor": actor}}) assert client.created in ([], ["open-tag"]) - assert "U1" not in client.created + assert "slack:U1" not in client.created def test_a_chatty_shared_scope_cannot_crowd_out_the_person_asking(): @@ -144,8 +144,8 @@ def test_a_chatty_shared_scope_cannot_crowd_out_the_person_asking(): "open-tag", search_response(*[f"LINEAR_TOOL_{index}" for index in range(8)]), ) - personal = FakeSession("U1", search_response("GMAIL_SEND_EMAIL")) - search, _run, _client = tools_for({"open-tag": shared, "U1": personal}) + personal = FakeSession("slack:U1", search_response("GMAIL_SEND_EMAIL")) + search, _run, _client = tools_for({"open-tag": shared, "slack:U1": personal}) result = search.invoke({"query": "email", "state": state("U1")}) @@ -190,8 +190,8 @@ def test_only_an_explicit_false_asks_someone_to_connect(): def test_one_unreachable_scope_costs_only_its_own_candidates(caplog): shared = FakeSession("open-tag", search_response("LINEAR_OK")) - personal = FakeSession("U1", fail_search=True) - search, _run, _client = tools_for({"open-tag": shared, "U1": personal}) + personal = FakeSession("slack:U1", fail_search=True) + search, _run, _client = tools_for({"open-tag": shared, "slack:U1": personal}) with caplog.at_level(logging.WARNING): result = search.invoke({"query": "x", "state": state("U1")}) @@ -202,8 +202,8 @@ def test_one_unreachable_scope_costs_only_its_own_candidates(caplog): def test_a_call_runs_in_the_account_that_owns_its_toolkit(): shared = FakeSession("open-tag") - personal = FakeSession("U1") - _search, run, _client = tools_for({"open-tag": shared, "U1": personal}) + personal = FakeSession("slack:U1") + _search, run, _client = tools_for({"open-tag": shared, "slack:U1": personal}) run.invoke( {"slug": "GMAIL_SEND_EMAIL", "arguments": {"to": "a@b.c"}, "state": state("U1")} @@ -359,10 +359,10 @@ def test_only_the_person_whose_account_it_is_may_approve(monkeypatch): # A personal call spends one person's access, so a colleague clicking # approve would spend somebody else's. The agent names the approver; the # surface, which knows who clicked, enforces it. - personal = FakeSession("U1") + personal = FakeSession("slack:U1") shared = FakeSession("open-tag") _search, run, _client = tools_for( - {"open-tag": shared, "U1": personal}, + {"open-tag": shared, "slack:U1": personal}, effects=FakeEffects({"GMAIL_SEND_EMAIL": "write"}), cfg=config(approvals="writes"), ) @@ -412,3 +412,21 @@ def test_an_unplaceable_slug_is_refused_before_anything_is_classified(): ) def test_humanize_slug(slug, expected): assert humanize_slug(slug) == expected + + +def test_the_composio_identity_is_namespaced_by_platform(): + # A provider id is unique only within its provider. Without the namespace, + # `U1` on Slack and `U1` on Teams share one Composio identity, and therefore + # each other's connected accounts. + slack_person = FakeSession("slack:U1", search_response("GMAIL_SEND_EMAIL")) + teams_person = FakeSession("teams:U1", search_response("GMAIL_SEND_EMAIL")) + shared = FakeSession("open-tag", search_response()) + search, _run, client = tools_for( + {"open-tag": shared, "slack:U1": slack_person, "teams:U1": teams_person} + ) + + search.invoke({"query": "x", "state": state("U1", platform="slack")}) + search.invoke({"query": "x", "state": state("U1", platform="teams")}) + + assert "slack:U1" in client.created + assert "teams:U1" in client.created diff --git a/agent/tests/test_health.py b/agent/tests/test_health.py index 1a2a824..65b723e 100644 --- a/agent/tests/test_health.py +++ b/agent/tests/test_health.py @@ -1,7 +1,8 @@ from fastapi.testclient import TestClient # Import before tests mutate environment variables. -import agent as agent_mod # noqa: E402 +import agent as agent_mod +from composio_tools.runtime import reset_composio_runtime # noqa: E402 def test_health_ok(monkeypatch): @@ -104,6 +105,9 @@ def with_config(self, config): # The repo `.env` is loaded at import, so an optional feature configured on # the developer's machine otherwise leaks into this assertion. monkeypatch.delenv("COMPOSIO_API_KEY", raising=False) + # The Composio runtime is cached per process, so a test that varies the + # environment has to drop it first or it reads the previous test's answer. + reset_composio_runtime() monkeypatch.setattr(agent_mod, "ChatOpenAI", lambda **_kwargs: object()) monkeypatch.setattr(agent_mod, "internal_source_toolsets", lambda _provider: {}) @@ -139,6 +143,7 @@ def build(env): for name, value in env.items(): monkeypatch.setenv(name, value) monkeypatch.setenv("OPENAI_API_KEY", "sk-test") + reset_composio_runtime() monkeypatch.setattr(agent_mod, "ChatOpenAI", lambda **_kwargs: object()) monkeypatch.setattr( agent_mod, "internal_source_toolsets", lambda _provider: {} From a63f71c7faacbaddc15145d78644a19aacbfadb6 Mon Sep 17 00:00:00 2001 From: Maxim Date: Tue, 1 Sep 2026 21:37:07 +0200 Subject: [PATCH 05/23] feat(composio): connect a personal account, and document the whole feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the move. The agent asks the surface to post a Connect card; the card carries no link; whoever presses it gets one minted for them and delivered where only they can see it. That split is the whole architecture in one flow — the capability is the agent's, knowing who clicked and reaching one person is the surface's. The connect request travels as an interrupt that is resumed immediately, because it is a request to draw something rather than a decision to wait on. Connecting takes minutes and several people in one thread may each connect their own account, which is not a shape one paused graph can hold. Approver enforcement lands here too, since only the surface knows who pressed a button. A card for a call in someone's own account refuses anybody else, tells them privately, and leaves the graph paused so the right person can still answer — including on the decline button, which would otherwise let a colleague cancel somebody else's action. Matching is on platform and id together, so a person who shares an id on another platform is not the same person. A shared toolkit still cannot be connected from Slack, and now cannot be connected from the dashboard either without noticing: the operator path is `uv run python -m composio_tools.connect_cli `, which lives where the session does. It refuses a personal toolkit for the same reason the route does. The connect button needs direct Slack delivery, so `SLACK_BOT_TOKEN` and `SLACK_APP_TOKEN` return — both or neither. The managed adapter reports `supportsEphemeral: false`, and delivery deliberately does not fall back to a DM: a bearer capability must not follow somebody somewhere it was not scoped to. Documentation covers the feature end to end for the first time — the variables on the service that actually reads them, the two-step-per-app setup, why a slug typo is silent, the difference between a shared account and a personal one, and why `AGENT_AUTH_HEADER` is required before a link can be minted. Both deployments carry the variables: Railway per service, and on AWS the toolkit lists as CDK context with the api key and the shared secret as secret fields. Gates run: `pnpm check-types` clean, 261 TypeScript tests, 271 Python tests, 11 CDK tests. `railway` IaC validation needs credentials and was not run. --- .env.example | 36 +++++ .railway/railway.ts | 16 ++ README.md | 4 +- agent/composio_tools/connect_cli.py | 100 ++++++++++++ agent/composio_tools/tools.py | 46 +++++- agent/tests/test_composio_connect_cli.py | 53 +++++++ agent/tests/test_composio_tools.py | 86 ++++++++++- app/channel.tsx | 45 +++++- app/env.test.ts | 36 +++++ app/env.ts | 38 +++++ .../__tests__/confirm-write-approver.test.tsx | 122 +++++++++++++++ app/human-in-the-loop/confirm-write.tsx | 45 +++++- app/human-in-the-loop/connect-account.tsx | 94 ++++++++++++ app/human-in-the-loop/index.ts | 6 + app/index.ts | 1 + app/interrupt.test.ts | 76 +++++++++- app/interrupt.ts | 41 ++++- app/railway.test.ts | 10 ++ app/tools/__tests__/composio-connect.test.ts | 143 ++++++++++++++++++ app/tools/__tests__/connect-click.test.tsx | 86 +++++++++++ app/tools/composio-connect.ts | 110 ++++++++++++++ app/tools/connect-click.tsx | 65 ++++++++ deployment/aws/lib/opentag-stack.ts | 24 +++ deployment/aws/test/opentag-stack.test.ts | 11 ++ setup.md | 102 ++++++++++++- 25 files changed, 1380 insertions(+), 16 deletions(-) create mode 100644 agent/composio_tools/connect_cli.py create mode 100644 agent/tests/test_composio_connect_cli.py create mode 100644 app/human-in-the-loop/__tests__/confirm-write-approver.test.tsx create mode 100644 app/human-in-the-loop/connect-account.tsx create mode 100644 app/tools/__tests__/composio-connect.test.ts create mode 100644 app/tools/__tests__/connect-click.test.tsx create mode 100644 app/tools/composio-connect.ts create mode 100644 app/tools/connect-click.tsx diff --git a/.env.example b/.env.example index a93ba82..f43fec7 100644 --- a/.env.example +++ b/.env.example @@ -61,3 +61,39 @@ export OPENAI_API_KEY=sk-... # Create a key at https://platform.ope # -- Notion (Optional) -- # export NOTION_MCP_URL=https://your-notion-mcp.example.com/mcp # export NOTION_MCP_AUTH_TOKEN=your-remote-mcp-bearer-token + +# -- Composio (Optional) -- +# Connect any Composio toolkit without writing an MCP block. Two steps per app: +# add the toolkit at https://app.composio.dev, then name its slug below. +# Slugs are Composio's own — lowercase, unspaced: `googlecalendar`, not `gcal`. +# COMPOSIO_API_KEY is the master switch; without it nothing is constructed. +# export COMPOSIO_API_KEY=ak_... +# +# One shared identity everyone in Slack reaches. Connect each of these once with +# cd agent && uv run python -m composio_tools.connect_cli +# export COMPOSIO_TOOLKITS=linear,jira +# +# Each person's own account. They connect it themselves from a thread. +# export COMPOSIO_USER_TOOLKITS=gmail,googlecalendar +# +# off | destructive (default) | writes — which calls wait for a person. +# export COMPOSIO_APPROVALS=destructive +# +# The Composio user_id shared toolkits act as. Defaults to the Channel name. +# export COMPOSIO_WORKSPACE_USER_ID=open-tag +# +# Read only by the connect script: pins which auth config a shared toolkit +# connects against when it has several. Ids are case-sensitive. +# export COMPOSIO_AUTH_CONFIGS=linear:ac_ExAmPle1 + +# -- Slack direct delivery (Optional; needed for COMPOSIO_USER_TOOLKITS) -- +# Intelligence owns the Slack edge and no Slack token is otherwise needed here. +# The one thing it cannot do is post a message only one person can see, and a +# connect link must reach exactly one person. Set both or neither. +# export SLACK_BOT_TOKEN=xoxb-... +# export SLACK_APP_TOKEN=xapp-... + +# -- Agent authentication (Optional; required to connect personal accounts) -- +# Sent by the runtime and now checked by the agent. Without it the connect +# endpoint refuses to mint a link, since that link is a bearer capability. +# export AGENT_AUTH_HEADER=Bearer generate-a-long-random-string diff --git a/.railway/railway.ts b/.railway/railway.ts index a71abfa..60e8ab0 100644 --- a/.railway/railway.ts +++ b/.railway/railway.ts @@ -41,6 +41,17 @@ export default defineRailway(() => { LINEAR_API_KEY: preserve(), NOTION_MCP_URL: preserve(), NOTION_MCP_AUTH_TOKEN: preserve(), + // Composio is read by the agent, which is where the toolkits live. The + // runtime carries only the shared secret it presents when asking for a + // connect link. + COMPOSIO_API_KEY: preserve(), + COMPOSIO_TOOLKITS: preserve(), + COMPOSIO_USER_TOOLKITS: preserve(), + COMPOSIO_APPROVALS: preserve(), + COMPOSIO_WORKSPACE_USER_ID: preserve(), + COMPOSIO_AUTH_CONFIGS: preserve(), + AGENT_AUTH_HEADER: preserve(), + INTELLIGENCE_CHANNEL_NAME: "open-tag", }, }); @@ -71,6 +82,11 @@ export default defineRailway(() => { "wss://realtime.intelligence.copilotkit.ai", INTELLIGENCE_LEARNING_CONTAINER_ID: preserve(), INTELLIGENCE_CHANNEL_NAME: "open-tag", + AGENT_AUTH_HEADER: preserve(), + // Only so a Composio connect link can reach one person privately; the + // managed adapter cannot post a message only one person sees. + SLACK_BOT_TOKEN: preserve(), + SLACK_APP_TOKEN: preserve(), PLAYWRIGHT_BROWSERS_PATH: "0", RAILPACK_DEPLOY_APT_PACKAGES: "fonts-liberation fonts-noto-color-emoji fonts-unifont libasound2 libatk-bridge2.0-0 libatk1.0-0 libatspi2.0-0 libcairo2 libcups2 libdbus-1-3 libdrm2 libexpat1 libfontconfig1 libfreetype6 libgbm1 libglib2.0-0 libnspr4 libnss3 libpango-1.0-0 libx11-6 libx11-xcb1 libxcb1 libxcomposite1 libxdamage1 libxext6 libxfixes3 libxkbcommon0 libxrandr2 libxrender1 libxshmfence1", diff --git a/README.md b/README.md index b9485b4..14395e4 100644 --- a/README.md +++ b/README.md @@ -320,7 +320,8 @@ agent (Python + LangGraph deepagents) ├── GitHub MCP (optional, read-only) ├── PostHog MCP (optional, read-only) ├── Linear MCP (optional) - └── Notion MCP (optional remote server) + ├── Notion MCP (optional remote server) + └── Composio toolkits (optional; shared or per-person) ``` | You run | CopilotKit Intelligence manages | @@ -361,6 +362,7 @@ knowledge work, and renders UI from model knowledge. | `GITHUB_PERSONAL_ACCESS_TOKEN` | Read-only repository, code, PR, and CI search | | `POSTHOG_PERSONAL_API_KEY` | PostHog analytics, read-only (use the **MCP Server** key preset) | | `LINEAR_API_KEY` | Hosted Linear MCP | +| `COMPOSIO_API_KEY` | Composio toolkits, shared or per-person (see setup.md) | | `NOTION_MCP_URL` + `NOTION_MCP_AUTH_TOKEN` | Remote Notion MCP; setting only one disables it | | `DAYTONA_API_KEY` + a PAT or GitHub App | Coding subagent: edit in Daytona, then push and publish a draft PR after `confirm_write` | diff --git a/agent/composio_tools/connect_cli.py b/agent/composio_tools/connect_cli.py new file mode 100644 index 0000000..15749ca --- /dev/null +++ b/agent/composio_tools/connect_cli.py @@ -0,0 +1,100 @@ +"""Connect a shared toolkit, once, as the workspace identity. + +A shared toolkit runs as one Composio identity that everyone in Slack reaches, +so nobody in Slack can connect it: a link clicked by a person binds to that +person's id, and no shared call would ever look there. The dashboard cannot do it +either — a connection made there binds to the dashboard's own user id, which this +deployment never passes. It is a test button. + +So this is the only correct path, and it needs no running agent: + + cd agent && uv run python -m composio_tools.connect_cli +""" + +from __future__ import annotations + +import os +import sys +from collections.abc import Mapping + +from composio import Composio + +from composio_tools.config import ComposioConfig, read_composio_config + +DASHBOARD_URL = "https://app.composio.dev" + + +def resolve_shared_toolkit( + config: ComposioConfig, requested: str | None +) -> tuple[str | None, str | None]: + """The slug to connect, or the sentence the operator should read.""" + slug = (requested or "").strip().lower() + if not slug: + listed = ", ".join(config.workspace_toolkits) or "none configured" + return None, ( + "Usage: uv run python -m composio_tools.connect_cli \n" + f"Shared toolkits on this deployment: {listed}" + ) + if slug in config.user_toolkits: + return None, ( + f'"{slug}" is in COMPOSIO_USER_TOOLKITS, so it runs as each person ' + "and they connect it themselves from a thread. Minting a shared link " + "for it would connect one account every personal call then ignores." + ) + if slug not in config.workspace_toolkits: + listed = ", ".join(config.workspace_toolkits) or "none configured" + return None, ( + f'"{slug}" is not in COMPOSIO_TOOLKITS. Shared toolkits: {listed}' + ) + return slug, None + + +def main(argv: list[str] | None = None, env: Mapping[str, str] | None = None) -> int: + arguments = sys.argv[1:] if argv is None else argv + source = os.environ if env is None else env + + config = read_composio_config( + source, + default_user_id=source.get("INTELLIGENCE_CHANNEL_NAME", "open-tag"), + ) + if config is None: + print( + "Composio is not configured. Set COMPOSIO_API_KEY and at least one " + "of COMPOSIO_TOOLKITS or COMPOSIO_USER_TOOLKITS.", + file=sys.stderr, + ) + return 1 + + slug, message = resolve_shared_toolkit( + config, arguments[0] if arguments else None + ) + if slug is None: + print(message, file=sys.stderr) + return 1 + + composio = Composio(api_key=config.api_key) + session = composio.sessions.create( + user_id=config.workspace_user_id, + toolkits=[slug], + sandbox={"enable": False}, + ) + request = session.authorize(slug) + url = getattr(request, "redirect_url", None) + if not url: + print( + f"Composio returned no link for {slug}. Check that its auth config " + f"exists at {DASHBOARD_URL}.", + file=sys.stderr, + ) + return 1 + + print( + f"Open this once, signed in as the account the team should share:\n\n{url}\n\n" + f"It connects {slug} for the shared identity " + f'"{config.workspace_user_id}". Anyone in Slack then reaches it.' + ) + return 0 + + +if __name__ == "__main__": # pragma: no cover - entry point + raise SystemExit(main()) diff --git a/agent/composio_tools/tools.py b/agent/composio_tools/tools.py index bbd9852..654bc79 100644 --- a/agent/composio_tools/tools.py +++ b/agent/composio_tools/tools.py @@ -25,6 +25,7 @@ from composio_tools.scopes import resolve_scopes from composio_tools.sessions import ScopedSession, SessionCache from composio_tools.state import actor_key, actor_of +from copilotkit.langgraph import copilotkit_interrupt from write_confirmation import require_write_confirmation, summarize_args logger = logging.getLogger(__name__) @@ -273,4 +274,47 @@ def run_my_tool( return data - return [search_my_tools, run_my_tool] + @tool + def ask_to_connect( + toolkit: str, + state: Annotated[dict[str, Any], InjectedState], + ) -> str: + """Ask the person to connect one of their own accounts. + + Call this when search_my_tools reports an app needs connecting. + + Args: + toolkit: The app to connect, e.g. 'gmail'. + """ + slug = toolkit.strip().lower() + if slug not in config.user_toolkits: + # A shared app is connected once by an operator, so prompting a + # person would produce a connection no shared call ever uses. + return ( + f"{slug or 'that app'} is not one people connect for themselves. " + "A shared app is connected once by whoever runs this deployment." + ) + if actor_of(state) is None: + return "I could not tell who is asking, so I cannot start a connection." + + # A render request, not a decision: the surface posts the card and + # resumes at once. Connecting takes minutes and several people in one + # thread may each connect their own account, which is not a shape a + # single paused graph can hold. + # + # The card carries no link. Minting happens on click, for the clicker, + # because whoever completes a connect flow binds their account to the id + # the link was minted for. + copilotkit_interrupt(action="connect_account", args={"toolkit": slug}) + return ( + f"I posted a Connect {slug} button in this thread. " + "Press it and the link will be private to you." + ) + + tools = [search_my_tools, run_my_tool] + # Connecting is a personal act. With no personal toolkits there is nothing a + # person could connect, and offering the tool would only invite the agent to + # tell somebody to connect a shared account they do not own. + if config.user_toolkits: + tools.append(ask_to_connect) + return tools diff --git a/agent/tests/test_composio_connect_cli.py b/agent/tests/test_composio_connect_cli.py new file mode 100644 index 0000000..aa7b042 --- /dev/null +++ b/agent/tests/test_composio_connect_cli.py @@ -0,0 +1,53 @@ +"""The operator path for connecting a shared toolkit.""" + +from __future__ import annotations + +from composio_tools.config import ComposioConfig +from composio_tools.connect_cli import resolve_shared_toolkit + + +def config(**overrides) -> ComposioConfig: + defaults = { + "api_key": "ak_test", + "workspace_toolkits": ("linear", "jira"), + "user_toolkits": ("gmail",), + "approvals": "destructive", + "workspace_user_id": "open-tag", + } + return ComposioConfig(**{**defaults, **overrides}) + + +def test_a_shared_toolkit_resolves(): + slug, message = resolve_shared_toolkit(config(), "Linear") + assert (slug, message) == ("linear", None) + + +def test_no_argument_lists_what_could_be_connected(): + slug, message = resolve_shared_toolkit(config(), None) + assert slug is None + assert "linear, jira" in message + + +def test_a_personal_toolkit_is_refused_with_the_reason(): + # Minting a shared link for a personal toolkit connects one account that + # every personal call then ignores — the exact broken end state this script + # exists to prevent. + slug, message = resolve_shared_toolkit(config(), "gmail") + assert slug is None + assert "COMPOSIO_USER_TOOLKITS" in message + + +def test_an_unconfigured_toolkit_is_refused(): + slug, message = resolve_shared_toolkit(config(), "salesforce") + assert slug is None + assert "not in COMPOSIO_TOOLKITS" in message + + +def test_a_toolkit_in_both_lists_is_treated_as_personal(): + # Matching `resolve_scopes`, which resolves a doubly-listed toolkit to the + # personal scope only. The two must not disagree about which it is. + slug, message = resolve_shared_toolkit( + config(workspace_toolkits=("gmail",), user_toolkits=("gmail",)), "gmail" + ) + assert slug is None + assert "COMPOSIO_USER_TOOLKITS" in message diff --git a/agent/tests/test_composio_tools.py b/agent/tests/test_composio_tools.py index 5de1c47..535813a 100644 --- a/agent/tests/test_composio_tools.py +++ b/agent/tests/test_composio_tools.py @@ -88,10 +88,35 @@ def effect_for(self, slug): def tools_for(sessions_by_user, cfg=None, effects=None): cfg = cfg or config() client = FakeComposio(sessions_by_user) - search, run = build_composio_tools( - cfg, SessionCache(cfg, client=client), effects or FakeEffects() - ) - return search, run, client + built = { + tool.name: tool + for tool in build_composio_tools( + cfg, SessionCache(cfg, client=client), effects or FakeEffects() + ) + } + return built["search_my_tools"], built["run_my_tool"], client + + +def all_tools(cfg, sessions_by_user=None, effects=None): + client = FakeComposio(sessions_by_user or {}) + return [ + tool.name + for tool in build_composio_tools( + cfg, SessionCache(cfg, client=client), effects or FakeEffects() + ) + ] + + +def connect_tool(sessions_by_user, cfg=None, effects=None): + cfg = cfg or config() + client = FakeComposio(sessions_by_user) + built = { + tool.name: tool + for tool in build_composio_tools( + cfg, SessionCache(cfg, client=client), effects or FakeEffects() + ) + } + return built["ask_to_connect"] def state(actor_id=None, platform="slack"): @@ -430,3 +455,56 @@ def test_the_composio_identity_is_namespaced_by_platform(): assert "slack:U1" in client.created assert "teams:U1" in client.created + + +def test_the_connect_tool_is_absent_when_nobody_has_their_own_apps(): + # With no personal toolkits there is nothing a person could connect, and + # offering the tool only invites the agent to tell somebody to connect a + # shared account they do not own. + assert "ask_to_connect" not in all_tools(config(user_toolkits=())) + assert "ask_to_connect" in all_tools(config(user_toolkits=("gmail",))) + + +def test_asking_to_connect_posts_a_card_and_says_so(monkeypatch): + recorded = [] + monkeypatch.setattr( + tools_mod, + "copilotkit_interrupt", + lambda **kwargs: recorded.append(kwargs) or (None, None), + ) + ask = connect_tool({}) + + result = ask.invoke({"toolkit": "Gmail", "state": state("U1")}) + + assert recorded == [{"action": "connect_account", "args": {"toolkit": "gmail"}}] + assert "Connect gmail" in result + + +def test_asking_to_connect_a_shared_app_is_refused(monkeypatch): + recorded = [] + monkeypatch.setattr( + tools_mod, + "copilotkit_interrupt", + lambda **kwargs: recorded.append(kwargs) or (None, None), + ) + ask = connect_tool({}) + + result = ask.invoke({"toolkit": "linear", "state": state("U1")}) + + assert recorded == [] + assert "not one people connect for themselves" in result + + +def test_asking_to_connect_needs_to_know_who_is_asking(monkeypatch): + recorded = [] + monkeypatch.setattr( + tools_mod, + "copilotkit_interrupt", + lambda **kwargs: recorded.append(kwargs) or (None, None), + ) + ask = connect_tool({}) + + result = ask.invoke({"toolkit": "gmail", "state": state()}) + + assert recorded == [] + assert "could not tell who is asking" in result diff --git a/app/channel.tsx b/app/channel.tsx index 311a2c3..ea0b719 100644 --- a/app/channel.tsx +++ b/app/channel.tsx @@ -1,3 +1,4 @@ +import { slack } from "@copilotkit/channels/slack"; import { createChannel, type Channel, @@ -12,9 +13,15 @@ import { import { appCommands } from "./commands/index.js"; import { IssueCard, IssueList, PageList } from "./components/index.js"; import { createAppContext } from "./context/app-context.js"; -import { DEFAULT_AGENT_DISPLAY_NAME } from "./env.js"; -import { ConfirmWrite } from "./human-in-the-loop/index.js"; -import { parseConfirmWriteInterrupt } from "./interrupt.js"; +import { + DEFAULT_AGENT_DISPLAY_NAME, + type SlackDirectConfig, +} from "./env.js"; +import { ConfirmWrite, ConnectAccount } from "./human-in-the-loop/index.js"; +import { + parseConfirmWriteInterrupt, + parseConnectAccountInterrupt, +} from "./interrupt.js"; import { FILE_ISSUE_CALLBACK, fileIssueSubmit } from "./modals/file-issue.js"; import { IncidentCard } from "./tools/showcase-tools.js"; import { RenderChart } from "./tools/render-chart.js"; @@ -31,11 +38,23 @@ export function createOpenTagChannel( name: string, agent: ChannelAgent, agentDisplayName = DEFAULT_AGENT_DISPLAY_NAME, + slackDirect?: SlackDirectConfig, ): Channel { + // Intelligence owns the Slack edge by default and no Slack token belongs in + // this repository. The one exception is a message only one person can see: + // the managed adapter reports `supportsEphemeral: false`, and the connect flow + // has to hand one person a link nobody else in the thread can open. Setting + // both Slack tokens swaps delivery for that reason alone. Unset — the normal + // case — nothing changes. + const adapters = slackDirect + ? [slack({ botToken: slackDirect.botToken, appToken: slackDirect.appToken })] + : undefined; + const channel = createChannel({ name, agent, identifyUser: "platform", + ...(adapters ? { adapters } : {}), tools: createAppTools(agentDisplayName), context: [...createAppContext(agentDisplayName)], commands: appCommands, @@ -45,6 +64,12 @@ export function createOpenTagChannel( PageList, IncidentCard, ConfirmWrite, + // Load-bearing, not bookkeeping: once the in-process cache is gone, a + // click is served by re-rendering the named component from here. An + // unregistered card's buttons raise an error the Channel swallows, so the + // person clicks and nothing happens at all. `ConnectAccount` exists to be + // pressed minutes later, by several different people. + ConnectAccount, RenderChart, ], }); @@ -119,10 +144,24 @@ export function createOpenTagChannel( channel.onModalSubmit(FILE_ISSUE_CALLBACK, fileIssueSubmit); channel.onInterrupt("on_interrupt", async ({ payload, thread }) => { + // One event carries every interrupt the agent raises, so the connect + // request is checked first and only then does this fall through to the + // approval card, whose parse throws on anything it does not recognise. + const connect = parseConnectAccountInterrupt(payload); + if (connect) { + await thread.post(); + // Resumed at once. This interrupt is a request to draw something, not a + // decision to wait on: connecting takes minutes, and several people in + // one thread may each connect their own account. + await thread.resume({ posted: true }); + return; + } + const { args } = parseConfirmWriteInterrupt(payload); await thread.post( { }); it("does not expose platform credentials owned by Intelligence", () => { + // Both Slack tokens or neither: one alone is now a configuration error, and + // the pair is read into `slackDirect` rather than into flat fields. const environment = readEnvironment({ ...requiredEnvironment, SLACK_BOT_TOKEN: "xoxb-unused", + SLACK_APP_TOKEN: "xapp-unused", TEAMS_CLIENT_ID: "teams-unused", }); @@ -111,3 +115,35 @@ describe("parsePort", () => { }, ); }); + +describe("readSlackDirect", () => { + it("is absent when neither token is set, which is the normal case", () => { + // Intelligence owns the Slack edge by default and no Slack token belongs in + // this repository. + expect(readSlackDirect({})).toBeUndefined(); + }); + + it("is present when both are set", () => { + expect( + readSlackDirect({ SLACK_BOT_TOKEN: "xoxb-1", SLACK_APP_TOKEN: "xapp-1" }), + ).toEqual({ botToken: "xoxb-1", appToken: "xapp-1" }); + }); + + it("refuses one token alone, and names the one that is missing", () => { + // One alone cannot start a Socket Mode adapter, and silently ignoring it + // would leave the connect button unable to deliver with nothing to explain + // why. + expect(() => readSlackDirect({ SLACK_BOT_TOKEN: "xoxb-1" })).toThrow( + /SLACK_APP_TOKEN/, + ); + expect(() => readSlackDirect({ SLACK_APP_TOKEN: "xapp-1" })).toThrow( + /SLACK_BOT_TOKEN/, + ); + }); + + it("treats a whitespace-only token as unset", () => { + expect( + readSlackDirect({ SLACK_BOT_TOKEN: " ", SLACK_APP_TOKEN: " " }), + ).toBeUndefined(); + }); +}); diff --git a/app/env.ts b/app/env.ts index 4faa03e..d4ef9ae 100644 --- a/app/env.ts +++ b/app/env.ts @@ -5,10 +5,31 @@ export const DEFAULT_INTELLIGENCE_GATEWAY_WS_URL = export const DEFAULT_INTELLIGENCE_CHANNEL_NAME = "open-tag"; export const DEFAULT_AGENT_DISPLAY_NAME = "OpenTag"; +/** + * Credentials for talking to Slack directly instead of through Intelligence. + * + * Intelligence normally owns the Slack edge and no Slack token belongs in this + * repository. The one thing it cannot do is post a message only one person can + * see — its adapter declares `supportsEphemeral: false` — and the Composio + * connect flow needs exactly that, because a connect link binds whoever opens + * it to the identity it was minted for. + * + * Setting these attaches a direct Slack adapter that does support it. Leaving + * them unset keeps the managed path, which stays the default, and leaves the + * connect button unable to deliver — see `handleConnectClick`, which refuses to + * fall back to a DM rather than send a capability somewhere it was not scoped. + */ +export interface SlackDirectConfig { + botToken: string; + appToken: string; +} + export interface AppEnvironment { agentDisplayName: string; agentUrl: string; agentAuthHeader?: string; + /** Present only when both Slack tokens are set; otherwise Intelligence delivers. */ + slackDirect?: SlackDirectConfig; intelligenceApiKey: string; intelligenceApiUrl: string; intelligenceGatewayWsUrl: string; @@ -39,6 +60,22 @@ export function parsePort( return port; } +/** Both tokens or neither — one alone cannot start a Socket Mode adapter. */ +export function readSlackDirect( + env: NodeJS.ProcessEnv, +): SlackDirectConfig | undefined { + const botToken = env.SLACK_BOT_TOKEN?.trim(); + const appToken = env.SLACK_APP_TOKEN?.trim(); + if (!botToken && !appToken) return undefined; + if (!botToken || !appToken) { + throw new Error( + "Slack direct delivery needs both SLACK_BOT_TOKEN and SLACK_APP_TOKEN; " + + `only ${botToken ? "SLACK_BOT_TOKEN" : "SLACK_APP_TOKEN"} is set`, + ); + } + return { botToken, appToken }; +} + export function readEnvironment( env: NodeJS.ProcessEnv = process.env, ): AppEnvironment { @@ -47,6 +84,7 @@ export function readEnvironment( env.AGENT_DISPLAY_NAME?.trim() || DEFAULT_AGENT_DISPLAY_NAME, agentUrl: required(env, "AGENT_URL"), agentAuthHeader: env.AGENT_AUTH_HEADER, + slackDirect: readSlackDirect(env), intelligenceApiKey: required(env, "INTELLIGENCE_API_KEY"), intelligenceApiUrl: env.INTELLIGENCE_API_URL ?? DEFAULT_INTELLIGENCE_API_URL, diff --git a/app/human-in-the-loop/__tests__/confirm-write-approver.test.tsx b/app/human-in-the-loop/__tests__/confirm-write-approver.test.tsx new file mode 100644 index 0000000..a4a6458 --- /dev/null +++ b/app/human-in-the-loop/__tests__/confirm-write-approver.test.tsx @@ -0,0 +1,122 @@ +/** + * Who may answer an approval card. + * + * A call that runs in one person's own connected account spends that person's + * access, so a colleague pressing approve would spend somebody else's. The agent + * can only say whose call it is; the surface knows who clicked, so the rule is + * enforced here. + */ +import { describe, expect, it, vi } from "vitest"; +import { ConfirmWrite } from "../confirm-write.js"; + +/** Walk the rendered tree and collect every button's onClick. */ +function buttonHandlers(node: unknown): Array<(ctx: unknown) => unknown> { + const found: Array<(ctx: unknown) => unknown> = []; + const visit = (value: unknown): void => { + if (Array.isArray(value)) { + value.forEach(visit); + return; + } + if (!value || typeof value !== "object") return; + const element = value as { + props?: Record; + children?: unknown; + }; + const onClick = element.props?.onClick; + if (typeof onClick === "function") { + found.push(onClick as (ctx: unknown) => unknown); + } + if (element.props?.children) visit(element.props.children); + if (element.children) visit(element.children); + }; + visit(node); + return found; +} + +function interaction(actorId: string) { + const update = vi.fn(async () => undefined); + const resume = vi.fn(async () => undefined); + const postEphemeral = vi.fn( + async (_user: unknown, _ui: unknown, _options: { fallbackToDM: boolean }) => + null, + ); + return { + ctx: { + actor: { id: actorId, kind: "human" }, + platform: "slack", + thread: { update, resume, postEphemeral }, + message: { ref: "m1" }, + action: { id: "a1" }, + values: {}, + user: null, + } as never, + update, + resume, + postEphemeral, + }; +} + +describe("ConfirmWrite approver", () => { + it("lets the named person answer", async () => { + const handlers = buttonHandlers( + ConfirmWrite({ action: "Gmail send email", approver: "slack:U1" }), + ); + expect(handlers.length).toBeGreaterThan(0); + const { ctx, update, postEphemeral } = interaction("U1"); + + await handlers[0]!(ctx); + + expect(update).toHaveBeenCalled(); + expect(postEphemeral).not.toHaveBeenCalled(); + }); + + it("refuses anybody else, and leaves the card for the right person", async () => { + const handlers = buttonHandlers( + ConfirmWrite({ action: "Gmail send email", approver: "slack:U1" }), + ); + const { ctx, update, resume, postEphemeral } = interaction("U2"); + + await handlers[0]!(ctx); + + expect(update).not.toHaveBeenCalled(); + expect(resume).not.toHaveBeenCalled(); + expect(postEphemeral).toHaveBeenCalledTimes(1); + // Told privately: a public refusal would name somebody's private account in + // front of the whole thread. + expect(postEphemeral.mock.calls[0]![2]).toEqual({ fallbackToDM: false }); + }); + + it("refuses the decline button too, not only approve", async () => { + const handlers = buttonHandlers( + ConfirmWrite({ action: "Gmail send email", approver: "slack:U1" }), + ); + const { ctx, update, postEphemeral } = interaction("U2"); + + await handlers[handlers.length - 1]!(ctx); + + expect(update).not.toHaveBeenCalled(); + expect(postEphemeral).toHaveBeenCalledTimes(1); + }); + + it("lets anyone answer a workspace action, which names no approver", async () => { + const handlers = buttonHandlers(ConfirmWrite({ action: "Create issue" })); + const { ctx, update, postEphemeral } = interaction("U2"); + + await handlers[0]!(ctx); + + expect(update).toHaveBeenCalled(); + expect(postEphemeral).not.toHaveBeenCalled(); + }); + + it("does not match a person on another platform who shares an id", async () => { + const handlers = buttonHandlers( + ConfirmWrite({ action: "Gmail send email", approver: "teams:U1" }), + ); + const { ctx, update, postEphemeral } = interaction("U1"); + + await handlers[0]!(ctx); + + expect(update).not.toHaveBeenCalled(); + expect(postEphemeral).toHaveBeenCalledTimes(1); + }); +}); diff --git a/app/human-in-the-loop/confirm-write.tsx b/app/human-in-the-loop/confirm-write.tsx index 7a14f63..350e1e9 100644 --- a/app/human-in-the-loop/confirm-write.tsx +++ b/app/human-in-the-loop/confirm-write.tsx @@ -35,6 +35,14 @@ export interface ConfirmWriteField { interface ConfirmWriteProps { /** Short imperative title of the write, e.g. 'Create Linear issue'. */ action: string; + /** + * Who may answer, as `platform:id`. Set only when the pending action runs in + * one person's own connected account: approving it spends that person's + * access, so a colleague pressing the button would spend somebody else's. + * Absent means the action belongs to the workspace and anyone who can see the + * card may answer. + */ + approver?: string; /** * The write's arguments as approver-readable rows, rendered as a table. The * agent decides which fields are worth showing (see `summarize_args`); this @@ -149,8 +157,37 @@ function retryNotice(attempt: number, previousError?: string) { ); } +/** + * Whether this click came from somebody other than the named approver. + * + * Enforced here rather than in the agent because only the surface knows who + * pressed the button; the agent can say whose action it is and nothing more. + * The wrong person is told privately and the graph is left paused, so the right + * person can still answer. + */ +async function refuseWrongApprover( + interaction: InteractionContext, + approver: string | undefined, +): Promise { + if (!approver) return false; + if (`${interaction.platform}:${interaction.actor?.id ?? ""}` === approver) { + return false; + } + await interaction.thread.postEphemeral( + interaction.actor, + +
+ {"This one runs in someone else's connected account, so only they can approve it."} +
+
, + { fallbackToDM: false }, + ); + return true; +} + export function ConfirmWrite({ action, + approver, fields, detail, attempt, @@ -184,7 +221,9 @@ export function ConfirmWrite({ + + + {`Anyone else in this thread can click to connect their own account.`} + + + ); +} + +/** What one person sees after clicking, and nobody else does. */ +export function ConnectLink({ + toolkit, + url, +}: { + toolkit: string; + url: string; +}) { + const label = toolkit.charAt(0).toUpperCase() + toolkit.slice(1); + return ( + +
{`<${url}|Connect your ${label} account> — this link is yours alone; it connects the account you sign in with.`}
+
+ ); +} + +/** What one person sees when no link could be minted. */ +export function ConnectFailed({ message }: { message: string }) { + return ( + +
{`⚠️ ${message}`}
+
+ ); +} diff --git a/app/human-in-the-loop/index.ts b/app/human-in-the-loop/index.ts index d20645f..c25d99a 100644 --- a/app/human-in-the-loop/index.ts +++ b/app/human-in-the-loop/index.ts @@ -7,3 +7,9 @@ * event posts `ConfirmWrite`; the card's buttons call `thread.resume(...)`. */ export { ConfirmWrite } from "./confirm-write.js"; +export { + ConnectAccount, + ConnectFailed, + ConnectLink, +} from "./connect-account.js"; +export type { ConnectRequest } from "./connect-account.js"; diff --git a/app/index.ts b/app/index.ts index df2a735..05f3d8e 100644 --- a/app/index.ts +++ b/app/index.ts @@ -24,6 +24,7 @@ export function createOpenTagApplication( environment.channelName, agent, environment.agentDisplayName, + environment.slackDirect, ), ]; const runtimeHost = createOpenTagRuntime({ environment, channels }); diff --git a/app/interrupt.test.ts b/app/interrupt.test.ts index b14ed77..1ec11b7 100644 --- a/app/interrupt.test.ts +++ b/app/interrupt.test.ts @@ -1,7 +1,10 @@ import { EventType } from "@ag-ui/client"; import { createRunRenderer } from "@copilotkit/channels/slack/render"; import { describe, expect, it, vi } from "vitest"; -import { parseConfirmWriteInterrupt } from "./interrupt.js"; +import { + parseConfirmWriteInterrupt, + parseConnectAccountInterrupt, +} from "./interrupt.js"; const realEnvelope = { __copilotkit_interrupt_value__: { @@ -154,3 +157,74 @@ describe("parseConfirmWriteInterrupt", () => { expect(() => parseConfirmWriteInterrupt("{broken")).toThrow(); }); }); + +function interruptPayload(action: string, args: unknown) { + return { + __copilotkit_interrupt_value__: { action, args }, + __copilotkit_messages__: [], + }; +} + +describe("parseConnectAccountInterrupt", () => { + it("reads a connect request", () => { + const parsed = parseConnectAccountInterrupt( + interruptPayload("connect_account", { toolkit: "gmail" }), + ); + expect(parsed?.args.toolkit).toBe("gmail"); + }); + + it("accepts the payload as a JSON string, as the transport may deliver it", () => { + const parsed = parseConnectAccountInterrupt( + JSON.stringify(interruptPayload("connect_account", { toolkit: "gmail" })), + ); + expect(parsed?.args.toolkit).toBe("gmail"); + }); + + it("returns null for the approval interrupt rather than throwing", () => { + // One Channel event carries every interrupt the agent raises, so "not this + // one" is the normal case and must not read as an error. + expect( + parseConnectAccountInterrupt( + interruptPayload("confirm_write", { action: "Create issue" }), + ), + ).toBeNull(); + }); + + it("returns null for a connect request naming no app", () => { + expect( + parseConnectAccountInterrupt( + interruptPayload("connect_account", { toolkit: "" }), + ), + ).toBeNull(); + }); +}); + +describe("parseConfirmWriteInterrupt approver", () => { + it("carries the approver through when one is named", () => { + const { args } = parseConfirmWriteInterrupt( + interruptPayload("confirm_write", { + action: "Gmail send email", + approver: "slack:U1", + effect: "write", + }), + ); + expect(args.approver).toBe("slack:U1"); + }); + + it("accepts a null approver, which is how a workspace action arrives", () => { + const { args } = parseConfirmWriteInterrupt( + interruptPayload("confirm_write", { + action: "Create issue", + approver: null, + }), + ); + expect(args.approver ?? undefined).toBeUndefined(); + }); + + it("still accepts a payload from an agent revision predating the approver", () => { + const { args } = parseConfirmWriteInterrupt( + interruptPayload("confirm_write", { action: "Create issue" }), + ); + expect(args.action).toBe("Create issue"); + }); +}); diff --git a/app/interrupt.ts b/app/interrupt.ts index 420edef..d6ec9d9 100644 --- a/app/interrupt.ts +++ b/app/interrupt.ts @@ -1,5 +1,17 @@ import { z } from "zod"; +/** + * Both interrupts the agent raises arrive on the same Channel event, so the + * handler has to tell them apart before it can act on either. + */ +const connectAccountInterruptSchema = z.object({ + __copilotkit_interrupt_value__: z.object({ + action: z.literal("connect_account"), + args: z.object({ toolkit: z.string().min(1) }), + }), + __copilotkit_messages__: z.array(z.unknown()).optional(), +}); + const confirmWriteInterruptSchema = z.object({ __copilotkit_interrupt_value__: z.object({ action: z.literal("confirm_write"), @@ -18,14 +30,37 @@ const confirmWriteInterruptSchema = z.object({ attempt: z.number().int().min(1).optional(), /** Why the previous attempt at this same write failed. */ previous_error: z.string().nullish(), + /** + * Who may answer this card, as `platform:id`. Present only when the call + * runs in one person's own account: approving it spends that person's + * access, so a colleague pressing the button would spend somebody else's. + * Absent means the action belongs to the workspace and anyone who can see + * the card may answer. + */ + approver: z.string().min(1).nullish(), + /** `read`, `write`, or `destructive` — what the agent classified it as. */ + effect: z.string().nullish(), }), }), __copilotkit_messages__: z.array(z.unknown()), }); +function normalize(payload: unknown): unknown { + return typeof payload === "string" ? JSON.parse(payload) : payload; +} + export function parseConfirmWriteInterrupt(payload: unknown) { - const normalized = - typeof payload === "string" ? JSON.parse(payload) : payload; - return confirmWriteInterruptSchema.parse(normalized) + return confirmWriteInterruptSchema.parse(normalize(payload)) .__copilotkit_interrupt_value__; } + +/** + * The connect request, or `null` when this interrupt is something else. + * + * Null rather than throwing: one event carries every interrupt the agent + * raises, so "not this one" is the normal case and not an error. + */ +export function parseConnectAccountInterrupt(payload: unknown) { + const parsed = connectAccountInterruptSchema.safeParse(normalize(payload)); + return parsed.success ? parsed.data.__copilotkit_interrupt_value__ : null; +} diff --git a/app/railway.test.ts b/app/railway.test.ts index 02fb5f4..bf9bf96 100644 --- a/app/railway.test.ts +++ b/app/railway.test.ts @@ -80,6 +80,13 @@ describe("Railway deployment graph", () => { LINEAR_API_KEY: { type: "preserve" }, NOTION_MCP_URL: { type: "preserve" }, NOTION_MCP_AUTH_TOKEN: { type: "preserve" }, + COMPOSIO_API_KEY: { type: "preserve" }, + COMPOSIO_TOOLKITS: { type: "preserve" }, + COMPOSIO_USER_TOOLKITS: { type: "preserve" }, + COMPOSIO_APPROVALS: { type: "preserve" }, + COMPOSIO_WORKSPACE_USER_ID: { type: "preserve" }, + COMPOSIO_AUTH_CONFIGS: { type: "preserve" }, + AGENT_AUTH_HEADER: { type: "preserve" }, }); const runtime = resources.find(({ name }) => name === "runtime"); @@ -118,6 +125,9 @@ describe("Railway deployment graph", () => { type: "literal", value: "open-tag", }, + AGENT_AUTH_HEADER: { type: "preserve" }, + SLACK_BOT_TOKEN: { type: "preserve" }, + SLACK_APP_TOKEN: { type: "preserve" }, PLAYWRIGHT_BROWSERS_PATH: { type: "literal", value: "0", diff --git a/app/tools/__tests__/composio-connect.test.ts b/app/tools/__tests__/composio-connect.test.ts new file mode 100644 index 0000000..ac9fe75 --- /dev/null +++ b/app/tools/__tests__/composio-connect.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, it, vi } from "vitest"; +import { + connectEndpoint, + requestConnectLink, +} from "../composio-connect.js"; + +const LINK = "https://backend.composio.dev/connect/abc123"; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +const base = { + agentUrl: "http://agent.internal:8123/", + agentAuthHeader: "Bearer s3cret", + actorId: "U1", + platform: "slack", + toolkit: "gmail", +}; + +describe("connectEndpoint", () => { + it("derives the route from the agent url, with or without a trailing slash", () => { + expect(connectEndpoint("http://agent:8123/")).toBe( + "http://agent:8123/composio/connect", + ); + expect(connectEndpoint("http://agent:8123")).toBe( + "http://agent:8123/composio/connect", + ); + }); + + it("keeps a base path rather than replacing it", () => { + expect(connectEndpoint("http://agent:8123/opentag/")).toBe( + "http://agent:8123/opentag/composio/connect", + ); + }); +}); + +describe("requestConnectLink", () => { + it("returns the link and never puts one in the request", async () => { + const fetchImpl = vi.fn(async () => + jsonResponse({ redirectUrl: LINK }), + ) as unknown as typeof fetch; + + const result = await requestConnectLink({ ...base, fetchImpl }); + + expect(result).toEqual({ ok: true, url: LINK }); + const [, init] = (fetchImpl as unknown as ReturnType).mock + .calls[0]!; + expect(JSON.parse((init as RequestInit).body as string)).toEqual({ + actor_id: "U1", + platform: "slack", + toolkit: "gmail", + }); + }); + + it("sends the shared secret, because the route mints nothing without it", async () => { + const fetchImpl = vi.fn(async () => + jsonResponse({ redirectUrl: LINK }), + ) as unknown as typeof fetch; + + await requestConnectLink({ ...base, fetchImpl }); + + const [, init] = (fetchImpl as unknown as ReturnType).mock + .calls[0]!; + expect((init as RequestInit).headers).toMatchObject({ + authorization: "Bearer s3cret", + }); + }); + + it("explains a missing secret instead of provoking a 401 nobody can act on", async () => { + const fetchImpl = vi.fn() as unknown as typeof fetch; + + const result = await requestConnectLink({ + ...base, + agentAuthHeader: undefined, + fetchImpl, + }); + + expect(result.ok).toBe(false); + expect(fetchImpl).not.toHaveBeenCalled(); + if (!result.ok) expect(result.message).toContain("AGENT_AUTH_HEADER"); + }); + + it("passes the agent's own refusal through, because it is written for a person", async () => { + const fetchImpl = vi.fn(async () => + jsonResponse( + { error: '"linear" is not one of the apps people connect for themselves.' }, + 400, + ), + ) as unknown as typeof fetch; + + const result = await requestConnectLink({ + ...base, + toolkit: "linear", + fetchImpl, + }); + + expect(result).toEqual({ + ok: false, + message: '"linear" is not one of the apps people connect for themselves.', + }); + }); + + it("does not surface a server error body, which is a stack trace or proxy html", async () => { + const fetchImpl = vi.fn(async () => + jsonResponse({ error: "Traceback (most recent call last)" }, 500), + ) as unknown as typeof fetch; + + const result = await requestConnectLink({ ...base, fetchImpl }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.message).not.toContain("Traceback"); + expect(result.message).toContain("gmail"); + } + }); + + it("treats an unreachable agent as something to retry", async () => { + const fetchImpl = vi.fn(async () => { + throw new Error("ECONNREFUSED"); + }) as unknown as typeof fetch; + + const result = await requestConnectLink({ ...base, fetchImpl }); + + expect(result.ok).toBe(false); + if (!result.ok) expect(result.message).toContain("Try again shortly"); + }); + + it("treats a response with no link as a failure rather than passing undefined on", async () => { + for (const body of [{}, { redirectUrl: "" }, { redirectUrl: 7 }]) { + const fetchImpl = vi.fn(async () => + jsonResponse(body), + ) as unknown as typeof fetch; + + const result = await requestConnectLink({ ...base, fetchImpl }); + + expect(result.ok).toBe(false); + } + }); +}); diff --git a/app/tools/__tests__/connect-click.test.tsx b/app/tools/__tests__/connect-click.test.tsx new file mode 100644 index 0000000..15d10ee --- /dev/null +++ b/app/tools/__tests__/connect-click.test.tsx @@ -0,0 +1,86 @@ +import { describe, expect, it, vi } from "vitest"; +import { handleConnectClick } from "../connect-click.js"; + +const LINK = "https://backend.composio.dev/connect/abc123"; + +function interaction(actor: { id: string } | undefined) { + // Typed parameters, not a cast: the assertions below read the recorded + // arguments, and an untyped mock records an empty tuple. + const postEphemeral = vi.fn( + async (_user: unknown, _ui: unknown, _options: { fallbackToDM: boolean }) => + null, + ); + return { + ctx: { + actor, + platform: "slack", + thread: { postEphemeral }, + message: { ref: "m1" }, + action: { id: "a1" }, + values: {}, + user: null, + } as never, + postEphemeral, + }; +} + +const environment = { + agentUrl: "http://agent:8123/", + agentAuthHeader: "Bearer s3cret", +} as never; + +describe("handleConnectClick", () => { + it("mints for whoever clicked, not for whoever the card was posted to", async () => { + const request = vi.fn(async () => ({ ok: true as const, url: LINK })); + const { ctx } = interaction({ id: "U2" }); + + await handleConnectClick("gmail", ctx, { environment, request }); + + expect(request).toHaveBeenCalledWith( + expect.objectContaining({ actorId: "U2", platform: "slack", toolkit: "gmail" }), + ); + }); + + it("delivers the link to that person alone", async () => { + const request = vi.fn(async () => ({ ok: true as const, url: LINK })); + const { ctx, postEphemeral } = interaction({ id: "U2" }); + + await handleConnectClick("gmail", ctx, { environment, request }); + + expect(postEphemeral).toHaveBeenCalledTimes(1); + expect(postEphemeral.mock.calls[0]![0]).toEqual({ id: "U2" }); + }); + + it("never falls back to a DM, because a link must not follow someone elsewhere", async () => { + const request = vi.fn(async () => ({ ok: true as const, url: LINK })); + const { ctx, postEphemeral } = interaction({ id: "U2" }); + + await handleConnectClick("gmail", ctx, { environment, request }); + + expect(postEphemeral.mock.calls[0]![2]).toEqual({ fallbackToDM: false }); + }); + + it("mints nothing when it cannot tell who clicked", async () => { + // Minting anyway would bind an account to whatever id we guessed. + const request = vi.fn(); + const { ctx, postEphemeral } = interaction(undefined); + + await handleConnectClick("gmail", ctx, { environment, request }); + + expect(request).not.toHaveBeenCalled(); + expect(postEphemeral).toHaveBeenCalledTimes(1); + }); + + it("shows the reason privately when no link could be minted", async () => { + const request = vi.fn(async () => ({ + ok: false as const, + message: "Shared apps are connected by an operator.", + })); + const { ctx, postEphemeral } = interaction({ id: "U2" }); + + await handleConnectClick("linear", ctx, { environment, request }); + + expect(postEphemeral).toHaveBeenCalledTimes(1); + expect(postEphemeral.mock.calls[0]![2]).toEqual({ fallbackToDM: false }); + }); +}); diff --git a/app/tools/composio-connect.ts b/app/tools/composio-connect.ts new file mode 100644 index 0000000..f35dfe8 --- /dev/null +++ b/app/tools/composio-connect.ts @@ -0,0 +1,110 @@ +/** + * Asking the agent for one person's connect link. + * + * The Channel holds no Composio session and no api key. It knows two things the + * agent cannot: who pressed the button, and how to put something in front of + * that person alone. So it asks for a link and delivers it. The URL never + * reaches the model and is never posted where a second person could open it — + * whoever completes a connect flow binds their account to the id the link was + * minted for, which makes a shared link an account-takeover hazard. + */ + +export interface ConnectRequestInput { + agentUrl: string; + agentAuthHeader?: string; + actorId: string; + platform: string; + toolkit: string; + fetchImpl?: typeof fetch; +} + +/** A link for exactly one person, or the sentence to show them instead. */ +export type ConnectResult = + | { ok: true; url: string } + | { ok: false; message: string }; + +/** + * The agent's connect endpoint, derived from the URL the Channel already uses + * to run it. Derived rather than configured separately: two variables pointing + * at one service drift, and the second one is always the stale one. + */ +export function connectEndpoint(agentUrl: string): string { + return new URL("composio/connect", agentUrl.endsWith("/") ? agentUrl : `${agentUrl}/`).toString(); +} + +export async function requestConnectLink({ + agentUrl, + agentAuthHeader, + actorId, + platform, + toolkit, + fetchImpl = fetch, +}: ConnectRequestInput): Promise { + // The endpoint refuses to mint anything without this header, so a deployment + // that never set it gets a clear sentence rather than a 401 the person cannot + // act on. + if (!agentAuthHeader) { + return { + ok: false, + message: + "Connecting your own account needs `AGENT_AUTH_HEADER` set on both services. Ask whoever runs this deployment.", + }; + } + + let response: Response; + try { + response = await fetchImpl(connectEndpoint(agentUrl), { + method: "POST", + headers: { + "content-type": "application/json", + authorization: agentAuthHeader, + }, + body: JSON.stringify({ actor_id: actorId, platform, toolkit }), + }); + } catch { + // The reason is a network detail; the person can only retry either way. + return { + ok: false, + message: `Could not reach the agent to start the ${toolkit} connection. Try again shortly.`, + }; + } + + if (!response.ok) { + const detail = await readErrorMessage(response); + return { + ok: false, + message: detail ?? `Could not start the ${toolkit} connection.`, + }; + } + + const payload = (await response.json().catch(() => null)) as { + redirectUrl?: unknown; + } | null; + const url = payload?.redirectUrl; + if (typeof url !== "string" || url.length === 0) { + return { + ok: false, + message: `Could not start the ${toolkit} connection. Try again shortly.`, + }; + } + return { ok: true, url }; +} + +/** + * The agent's own sentence when it has one. + * + * Only from a 4xx: those are its considered refusals ("that app is connected by + * an operator, not from Slack"), and they are written for a person. A 5xx body + * is a stack trace or a proxy's HTML. + */ +async function readErrorMessage(response: Response): Promise { + if (response.status >= 500) return null; + try { + const payload = (await response.json()) as { error?: unknown }; + return typeof payload.error === "string" && payload.error.length > 0 + ? payload.error + : null; + } catch { + return null; + } +} diff --git a/app/tools/connect-click.tsx b/app/tools/connect-click.tsx new file mode 100644 index 0000000..1007c01 --- /dev/null +++ b/app/tools/connect-click.tsx @@ -0,0 +1,65 @@ +/** + * What happens when somebody presses "Connect". + * + * Kept out of the card so it can be tested without rendering one, and out of + * `composio-connect.ts` so that module stays a pure client with no knowledge of + * threads or delivery. + */ +import type { InteractionContext } from "@copilotkit/channels"; +import { readEnvironment } from "../env.js"; +import { + ConnectFailed, + ConnectLink, + type ConnectRequest, +} from "../human-in-the-loop/connect-account.js"; +import { requestConnectLink } from "./composio-connect.js"; + +/** + * Deliver privately, or say why not — to the clicker, either way. + * + * `fallbackToDM: false`: a connect link must not follow someone into a DM when + * the surface cannot show an ephemeral message. On a surface that cannot, the + * right outcome is that nothing is delivered rather than a bearer capability + * arriving somewhere it was not scoped to. + */ +export async function handleConnectClick( + toolkit: string, + interaction: InteractionContext, + deps: { + environment?: ReturnType; + request?: typeof requestConnectLink; + } = {}, +): Promise { + const environment = deps.environment ?? readEnvironment(); + const request = deps.request ?? requestConnectLink; + const actor = interaction.actor; + + if (!actor?.id) { + // Without a verified clicker there is nobody to mint for. Minting anyway + // would bind an account to whatever id we guessed. + await interaction.thread.postEphemeral( + actor ?? "unknown", + , + { fallbackToDM: false }, + ); + return; + } + + const result = await request({ + agentUrl: environment.agentUrl, + agentAuthHeader: environment.agentAuthHeader, + actorId: actor.id, + platform: interaction.platform, + toolkit, + }); + + await interaction.thread.postEphemeral( + actor, + result.ok ? ( + + ) : ( + + ), + { fallbackToDM: false }, + ); +} diff --git a/deployment/aws/lib/opentag-stack.ts b/deployment/aws/lib/opentag-stack.ts index 56fc391..ff9a3cf 100644 --- a/deployment/aws/lib/opentag-stack.ts +++ b/deployment/aws/lib/opentag-stack.ts @@ -21,6 +21,10 @@ const DATADOG_FORWARDER_TEMPLATE_URL = const AGENT_SECRET_KEYS = [ "OPENAI_API_KEY", + // The agent owns the Composio session, so the key and the shared secret it + // checks both live on this service. + "COMPOSIO_API_KEY", + "AGENT_AUTH_HEADER", "TAVILY_API_KEY", "DAYTONA_API_KEY", "GITHUB_PERSONAL_ACCESS_TOKEN", @@ -33,6 +37,10 @@ const AGENT_SECRET_KEYS = [ const RUNTIME_SECRET_KEYS = [ "INTELLIGENCE_API_KEY", "AGENT_AUTH_HEADER", + // Only so a Composio connect link can reach one person privately; the managed + // adapter cannot post a message only one person sees. + "SLACK_BOT_TOKEN", + "SLACK_APP_TOKEN", ] as const; function contextString( @@ -261,6 +269,22 @@ export class OpenTagStack extends cdk.Stack { "NOTION_MCP_URL", contextString(this, "notionMcpUrl", ""), ), + ...optionalEnvironment( + "COMPOSIO_TOOLKITS", + contextString(this, "composioToolkits", ""), + ), + ...optionalEnvironment( + "COMPOSIO_USER_TOOLKITS", + contextString(this, "composioUserToolkits", ""), + ), + ...optionalEnvironment( + "COMPOSIO_APPROVALS", + contextString(this, "composioApprovals", ""), + ), + ...optionalEnvironment( + "COMPOSIO_WORKSPACE_USER_ID", + contextString(this, "composioWorkspaceUserId", ""), + ), OPENAI_MODEL: openAiModel, OPENAI_REASONING_EFFORT: openAiReasoningEffort, OPENAI_VERBOSITY: openAiVerbosity, diff --git a/deployment/aws/test/opentag-stack.test.ts b/deployment/aws/test/opentag-stack.test.ts index 62f38ae..84bf0b3 100644 --- a/deployment/aws/test/opentag-stack.test.ts +++ b/deployment/aws/test/opentag-stack.test.ts @@ -117,6 +117,10 @@ test("allows supported non-secret environment overrides through context", () => openAiModel: "gpt-test", openAiReasoningEffort: "high", openAiVerbosity: "medium", + composioToolkits: "linear,jira", + composioUserToolkits: "gmail", + composioApprovals: "writes", + composioWorkspaceUserId: "acme", }), ); @@ -129,6 +133,13 @@ test("allows supported non-secret environment overrides through context", () => { Name: "DAYTONA_TTL_MINUTES", Value: "45" }, { Name: "GITHUB_APP_ID", Value: "12345" }, { Name: "GITHUB_APP_INSTALLATION_ID", Value: "67890" }, + // Composio is read by the agent container, which is where the + // toolkits live. Listed in the order the stack builds them, because + // `arrayWith` matches in sequence and CDK preserves insertion order. + { Name: "COMPOSIO_TOOLKITS", Value: "linear,jira" }, + { Name: "COMPOSIO_USER_TOOLKITS", Value: "gmail" }, + { Name: "COMPOSIO_APPROVALS", Value: "writes" }, + { Name: "COMPOSIO_WORKSPACE_USER_ID", Value: "acme" }, { Name: "OPENAI_MODEL", Value: "gpt-test" }, { Name: "OPENAI_REASONING_EFFORT", Value: "high" }, { Name: "OPENAI_VERBOSITY", Value: "medium" }, diff --git a/setup.md b/setup.md index 7826442..3601d80 100644 --- a/setup.md +++ b/setup.md @@ -82,6 +82,13 @@ or Channel slug. | `OPENAI_REASONING_EFFORT` | No | Defaults to `low` | | `OPENAI_VERBOSITY` | No | Defaults to `low` | | `TAVILY_API_KEY` | No | Enables live web research | +| `COMPOSIO_API_KEY` | No | Master switch for Composio toolkits. Absent means the feature is never constructed | +| `COMPOSIO_TOOLKITS` | No | Toolkit slugs everyone shares one connection for | +| `COMPOSIO_USER_TOOLKITS` | No | Toolkit slugs scoped to whoever sent the message | +| `COMPOSIO_APPROVALS` | No | `off`, `destructive` (default), or `writes`. An unrecognized value fails startup | +| `COMPOSIO_WORKSPACE_USER_ID` | No | Composio `user_id` the shared toolkits run as; defaults to `INTELLIGENCE_CHANNEL_NAME` | +| `COMPOSIO_AUTH_CONFIGS` | No | **Read only by the connect script, never by a turn.** `toolkit:auth_config_id` pairs, ids case-sensitive; pins which auth config a *shared* toolkit connects against when it has several | +| `AGENT_AUTH_HEADER` | No | The runtime's shared secret. Checked when set, and **required** before a Composio connect link is minted | | `GITHUB_PERSONAL_ACCESS_TOKEN` | No | Enables read-only GitHub repository, code, PR, Actions-run, and job-log search. It remains the legacy coding fallback | | `GITHUB_MCP_URL` | No | Overrides the hosted GitHub MCP URL; OpenTag still sends read-only headers | | `DAYTONA_API_KEY` | No | Enables the coding subagent (Daytona sandbox) | @@ -142,7 +149,9 @@ The AG-UI endpoint is `http://localhost:8123/`; `/health` reports the | `INTELLIGENCE_LEARNING_CONTAINER_ID` | No | Assigns OpenTag Threads to this existing Learning Container | | `INTELLIGENCE_API_URL` | No | Defaults to `https://api.intelligence.copilotkit.ai` | | `INTELLIGENCE_GATEWAY_WS_URL` | No | Defaults to `wss://realtime.intelligence.copilotkit.ai` | -| `AGENT_AUTH_HEADER` | No | Authorization header forwarded to the agent | +| `AGENT_AUTH_HEADER` | No | Shared secret between runtime and agent. Sent as `Authorization`; the agent checks it when set, and **requires** it before minting a Composio connect link | +| `SLACK_BOT_TOKEN` | No | With `SLACK_APP_TOKEN`, delivers Slack directly instead of through Intelligence. Needed only so a Composio connect link can reach one person privately | +| `SLACK_APP_TOKEN` | No | Socket Mode token; required with `SLACK_BOT_TOKEN` and refused alone | | `PORT` | No | Channel HTTP port; defaults to `3000` | | `LOG_LEVEL` | No | Defaults to `error`; use `debug` to see Channel lifecycle breadcrumbs | | `MERMAID_URL` | No | Overrides the Mermaid browser bundle URL used by diagram rendering | @@ -333,6 +342,97 @@ Notion is optional and remote-only, not a separate Railway service. Set both discovers the tools. If either value is absent OpenTag skips Notion without blocking startup. +### Composio + +Composio adds a toolkit — Gmail, Linear, Jira, Google Calendar, Salesforce — +without a new MCP block, a `preserve()` line, or a matching test assertion. It +lives in the Python agent, alongside every other capability, and is gated by the +same `confirm_write` card that already guards a Linear or Notion write. There is +one approval mechanism in this product, not two. + +Setup is **two steps per app**, not one: + +1. Add the toolkit at . That creates its auth config. +2. Add its slug to `COMPOSIO_TOOLKITS` or `COMPOSIO_USER_TOOLKITS`. A **shared** + toolkit also needs connecting once: + + ```bash + cd agent && uv run python -m composio_tools.connect_cli + ``` + + Open the link it prints, signed in as the account the team should share. That + needs no running agent, so do it before you restart. Personal toolkits skip + this — each person connects their own from a thread. +3. Restart the agent, once. + +**The slug is the tricky part.** It is Composio's own, lowercase and unspaced: +Google Calendar is `googlecalendar`, not `google-calendar` or `gcal`. Take it +from the toolkit's page URL at (`/toolkit/gmail`), or +from the Toolkits list in their docs. A typo is **silent** — OpenTag does not +validate slugs against Composio at startup, so a misspelled toolkit is simply one +that never appears: the agent has no tools for it and `search_my_tools` never +mentions it. If an app you configured seems absent, check the spelling first. + +`COMPOSIO_API_KEY` is the master switch. Without it nothing is constructed — no +client, no session, no tool the model can see but must not call. A key with both +toolkit lists empty is equally inert. + +#### Shared team accounts versus personal ones + +`COMPOSIO_TOOLKITS` runs every Slack user through **one** connection, under the +Composio `user_id` in `COMPOSIO_WORKSPACE_USER_ID` (defaulting to +`INTELLIGENCE_CHANNEL_NAME`). That is right for the team's Linear or Jira. + +`COMPOSIO_USER_TOOLKITS` scopes to whoever spoke, keyed by their verified +platform actor **and** the platform it came from — a provider id is unique only +within its provider, so `U1` on Slack and `U1` on Teams are different people. You +ask about "my calendar" and get yours; your colleague gets theirs. A turn with no +resolvable actor gets no personal tools at all and never falls back to the shared +identity. + +Both lists may be set at once, and one turn can use both. A toolkit named in both +resolves to the personal scope only. + +How an account gets connected differs by list, and this is where the surprises +are: + +- **Personal.** The agent posts a public **Connect** card carrying no link. + Whoever clicks receives a one-time link privately, minted for them; somebody + else clicking the same card connects their own account. A pre-minted link + posted in a channel would be an account-takeover hazard, because whoever + completes the flow binds their account to the id the link was minted for. +- **Shared.** Nobody in Slack can connect it, and neither can the dashboard — a + connection made there binds to the dashboard's own user id, which this + deployment never passes. It is a test button. The connect script above is the + only correct path. + +Personal toolkits need two more things: + +- **`AGENT_AUTH_HEADER`, on both services.** The runtime asks the agent to mint + each link, and the agent refuses to mint one without this secret. A link is a + bearer capability; there is no configuration in which handing one to an + unauthenticated caller is right. Ordinary agent traffic is checked only when + the variable is set, so an existing deployment is unaffected until it opts in. +- **Direct Slack delivery.** `SLACK_BOT_TOKEN` and `SLACK_APP_TOKEN`, both or + neither. The managed adapter reports `supportsEphemeral: false`, and a connect + link has to reach one person alone. Without these the Connect button has + nowhere private to deliver, and deliberately does not fall back to a DM. + +#### Approvals + +`COMPOSIO_APPROVALS` is `off`, `destructive` (the default), or `writes`. A gated +call posts the same card as a Linear or Notion write and pauses the graph, so the +answer can arrive twenty minutes later and the model still sees the result. + +A tool's effect comes from Composio's own behaviour tags, looked up per slug. A +slug that cannot be classified is treated as **destructive**, not as a write: +`writes` gates both, but `destructive` gates only the first, so the safe reading +of "unrecognised" is the stricter one. + +A call that runs in one person's own account names that person as its approver, +and only they can answer the card — approving it spends their access and nobody +else's. + ## Railway The IaC file declares exactly: From 6f7566c4a309a41bcdb1d2fce3b6777188a14f0a Mon Sep 17 00:00:00 2001 From: Maxim Date: Tue, 1 Sep 2026 21:37:27 +0200 Subject: [PATCH 06/23] docs(composio): name the channels release personal toolkits depend on --- setup.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/setup.md b/setup.md index 3601d80..fa28916 100644 --- a/setup.md +++ b/setup.md @@ -406,6 +406,14 @@ are: deployment never passes. It is a test button. The connect script above is the only correct path. +**Personal toolkits need a `@copilotkit/channels` that forwards the actor.** The +agent learns who spoke from `forwardedProps.channelActor`, which the Channel +started sending in the release carrying +[CopilotKit#6826](https://github.com/CopilotKit/CopilotKit/pull/6826). On an +older pin the actor never arrives, so every turn reads as anonymous: shared +toolkits work, personal ones silently offer nothing. Check the pin in +`package.json` before debugging anything else. + Personal toolkits need two more things: - **`AGENT_AUTH_HEADER`, on both services.** The runtime asks the agent to mint From 8d58f410a66e78f2f214906ee24d5e3eeff75936 Mon Sep 17 00:00:00 2001 From: Maxim Date: Tue, 1 Sep 2026 22:20:37 +0200 Subject: [PATCH 07/23] fix(composio): read the SDK's models, and say when a turn carried no actor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Discovery returned nothing against a live project. The Python SDK answers with Pydantic models — `SessionSearchResponse`, `Result` — where the TypeScript one answered with plain objects, and reading a model as a dictionary returns nothing and raises nothing. So `search_my_tools` reported no tools and nothing to connect, and the agent told the user Gmail was unavailable. Every dict-shaped unit test passed throughout. Responses are now flattened at the boundary, and the tests build models rather than dicts — a plain dict cannot catch this class of bug, which is the whole reason it survived to a live run. Two smaller fixes from the same session: - A blank `actor_id` on the connect route minted a real link bound to an identity no turn would ever look up again. The state reader filtered blanks; the route builds its actor from a request body and never passed through it. - A turn that carries no actor while personal toolkits are configured now says so. That is this feature's most likely silent failure — an older `@copilotkit/channels` does not forward the actor, so every turn looks anonymous and personal toolkits quietly offer nothing while shared ones keep working. The symptom otherwise reads as "the app is not connected", which sends you looking in the wrong place. Verified against the live project: discovery now returns Gmail tools and reports `gmail` as needing a connection. --- agent/composio_tools/state.py | 11 +++++- agent/composio_tools/tools.py | 44 ++++++++++++++++++++-- agent/tests/test_composio_connect.py | 19 ++++++++++ agent/tests/test_composio_tools.py | 55 ++++++++++++++++++++-------- 4 files changed, 109 insertions(+), 20 deletions(-) diff --git a/agent/composio_tools/state.py b/agent/composio_tools/state.py index 7a865d0..76fc9cf 100644 --- a/agent/composio_tools/state.py +++ b/agent/composio_tools/state.py @@ -44,11 +44,20 @@ def actor_key(actor: dict[str, Any] | None) -> str | None: A provider id is unique only within its provider, so two platforms can hand out the same string for different people. Everything keyed per person — a connected account, a pending approval — keys on both parts. + + A blank id is nobody, and returns `None` rather than a key ending in a colon. + Callers reaching this through `actor_of` already had that filtered, but the + connect route does not: it builds an actor from a request body, and a live + run showed an empty `actor_id` minting a real link bound to an identity no + turn would ever look up again. """ if actor is None: return None + identifier = str(actor.get("id") or "").strip() + if not identifier: + return None platform = str(actor.get("platform") or "").strip() or "unknown" - return f"{platform}:{str(actor['id']).strip()}" + return f"{platform}:{identifier}" class ComposioAgentState(DeepAgentState): diff --git a/agent/composio_tools/tools.py b/agent/composio_tools/tools.py index 654bc79..13c8994 100644 --- a/agent/composio_tools/tools.py +++ b/agent/composio_tools/tools.py @@ -34,12 +34,37 @@ MAX_RESULTS = 5 +def _plain(value: Any) -> Any: + """ + One SDK response, as plain data. + + The Python SDK answers with Pydantic models — `SessionSearchResponse`, + `Result` — where the TypeScript one answered with plain objects. Reading them + as dictionaries returns nothing and raises nothing, so discovery came back + empty against a live project while every dict-shaped unit test passed. Tests + now build models too; this is the boundary that makes either work. + """ + dump = getattr(value, "model_dump", None) + if callable(dump): + try: + return dump() + except Exception: # noqa: BLE001 - a model that cannot dump is not fatal + pass + if isinstance(value, dict): + return {key: _plain(item) for key, item in value.items()} + if isinstance(value, list): + return [_plain(item) for item in value] + return value + + def _as_list(value: Any) -> list[Any]: - return value if isinstance(value, list) else [] + plain = _plain(value) + return plain if isinstance(plain, list) else [] def _as_dict(value: Any) -> dict[str, Any]: - return value if isinstance(value, dict) else {} + plain = _plain(value) + return plain if isinstance(plain, dict) else {} def _as_strings(value: Any) -> list[str]: @@ -137,7 +162,20 @@ def sessions_for(state: dict[str, Any] | None) -> tuple[ScopedSession, ...]: # unique only within its provider, so one deployment serving Slack and # Teams would otherwise give `U1` on either platform the same Composio # identity — and therefore each other's connected accounts. - scopes = resolve_scopes(config, actor_key(actor_of(state))) + identity = actor_key(actor_of(state)) + if identity is None and config.user_toolkits: + # The silent failure this feature is most likely to hit: an older + # `@copilotkit/channels` does not forward the actor, so every turn + # looks anonymous and personal toolkits quietly offer nothing while + # shared ones keep working. Said out loud, because the symptom + # otherwise reads as "the app is not connected". + logger.warning( + "[composio] this turn carried no actor, so personal toolkits " + "(%s) are unavailable. A Channel forwards it as `channelActor`; " + "check the @copilotkit/channels version.", + ",".join(config.user_toolkits), + ) + scopes = resolve_scopes(config, identity) return cache.resolve(scopes) @tool diff --git a/agent/tests/test_composio_connect.py b/agent/tests/test_composio_connect.py index d2c3919..bb8f35d 100644 --- a/agent/tests/test_composio_connect.py +++ b/agent/tests/test_composio_connect.py @@ -214,3 +214,22 @@ def test_the_route_reports_an_unconfigured_deployment(client, monkeypatch): def test_health_stays_reachable_without_the_secret(client, monkeypatch): monkeypatch.setenv("AGENT_AUTH_HEADER", "Bearer s3cret") assert client.get("/health").status_code == 200 + + +def test_the_route_refuses_a_request_naming_nobody(client, monkeypatch): + # Found by a live run, not by a unit test: the route builds an actor from a + # request body, so a blank id reached `actor_key` without passing through the + # state reader that would have filtered it — and minted a real link bound to + # an identity no turn would ever look up again. + monkeypatch.setenv("AGENT_AUTH_HEADER", "Bearer s3cret") + _runtime, composio = install_runtime(monkeypatch, {}) + + for actor_id in ("", " "): + response = client.post( + "/composio/connect", + json={"actor_id": actor_id, "platform": "slack", "toolkit": "gmail"}, + headers={"Authorization": "Bearer s3cret"}, + ) + assert response.status_code == 400 + + assert composio.created == [] diff --git a/agent/tests/test_composio_tools.py b/agent/tests/test_composio_tools.py index 535813a..676848b 100644 --- a/agent/tests/test_composio_tools.py +++ b/agent/tests/test_composio_tools.py @@ -14,15 +14,34 @@ SCHEMA = {"type": "object", "properties": {}} +class Model: + """Stands in for an SDK response. + + A plain dict would not have caught the bug this exists for: the Python SDK + answers with Pydantic models, reading one as a dict returns nothing and + raises nothing, and discovery came back empty against a live project while + every dict-shaped test passed. + """ + + def __init__(self, payload): + self._payload = payload + + def model_dump(self): + return self._payload + + def search_response(*slugs, schema=SCHEMA, statuses=None): - return { - "results": [{"primaryToolSlugs": list(slugs)}], - "toolSchemas": { - slug: {"description": f"{slug} does a thing", "inputSchema": schema} - for slug in slugs - }, - **({"toolkitConnectionStatuses": statuses} if statuses else {}), - } + return Model( + { + # snake_case, as the Python SDK emits. + "results": [{"primary_tool_slugs": list(slugs)}], + "tool_schemas": { + slug: {"description": f"{slug} does a thing", "input_schema": schema} + for slug in slugs + }, + **({"toolkit_connection_statuses": statuses} if statuses else {}), + } + ) class FakeSession: @@ -180,13 +199,17 @@ def test_a_chatty_shared_scope_cannot_crowd_out_the_person_asking(): def test_a_schemaless_candidate_never_displaces_a_callable_one(): shared = FakeSession( "open-tag", - { - "results": [{"primaryToolSlugs": ["LINEAR_NO_SCHEMA", "LINEAR_OK"]}], - "toolSchemas": { - "LINEAR_NO_SCHEMA": {"description": "unusable"}, - "LINEAR_OK": {"description": "usable", "inputSchema": SCHEMA}, - }, - }, + Model( + { + "results": [ + {"primary_tool_slugs": ["LINEAR_NO_SCHEMA", "LINEAR_OK"]} + ], + "tool_schemas": { + "LINEAR_NO_SCHEMA": {"description": "unusable"}, + "LINEAR_OK": {"description": "usable", "input_schema": SCHEMA}, + }, + } + ), ) search, _run, _client = tools_for({"open-tag": shared}) @@ -201,7 +224,7 @@ def test_only_an_explicit_false_asks_someone_to_connect(): search_response( "LINEAR_OK", statuses=[ - {"toolkit": "linear", "hasActiveConnection": False}, + {"toolkit": "linear", "has_active_connection": False}, {"toolkit": "jira"}, ], ), From 82762217f8e9cb6a7e6b37a4e9df7c6ee42185a4 Mon Sep 17 00:00:00 2001 From: Maxim Date: Tue, 1 Sep 2026 22:47:11 +0200 Subject: [PATCH 08/23] fix(composio): post the Connect card from a channel tool, not an interrupt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The connect prompt raised an interrupt and resumed it immediately, on the reasoning that posting a card is a render request rather than a decision to wait on. That call could only ever fail: `Thread.resume` requires a live interaction continuation, which only a button click carries, and an interrupt handler has none. A live run put `ChannelContinuationRequiredError` in the thread instead of a Connect button. A channel tool is the mechanism that fits. `connect_app` runs in the surface, posts the card, and returns a sentence to the model. The agent still decides *when* to ask, because discovery is what reports an app as unconnected — so the split holds: the capability stays in the agent, the rendering stays in the surface. It is registered unconditionally. Which apps a person can connect is the agent's configuration, and on a two-service deployment those variables are set on the agent alone, so the runtime cannot know. The tool's own description is what keeps it from being called out of nowhere, and its result tells the agent not to claim the account is connected — the button still has to be pressed and the link still has to be completed in a browser. The agent-side tool, the second interrupt schema, and the handler branch that tried to resume are all gone rather than left dormant. --- agent/composio_tools/tools.py | 51 +++---------------- agent/tests/test_composio_tools.py | 63 ------------------------ app/channel.test.ts | 1 + app/channel.tsx | 18 +------ app/interrupt.test.ts | 39 +-------------- app/interrupt.ts | 23 --------- app/tools/__tests__/connect-app.test.tsx | 55 +++++++++++++++++++++ app/tools/connect-app.tsx | 42 ++++++++++++++++ app/tools/index.ts | 6 +++ setup.md | 3 +- 10 files changed, 114 insertions(+), 187 deletions(-) create mode 100644 app/tools/__tests__/connect-app.test.tsx create mode 100644 app/tools/connect-app.tsx diff --git a/agent/composio_tools/tools.py b/agent/composio_tools/tools.py index 13c8994..bc0be26 100644 --- a/agent/composio_tools/tools.py +++ b/agent/composio_tools/tools.py @@ -25,7 +25,6 @@ from composio_tools.scopes import resolve_scopes from composio_tools.sessions import ScopedSession, SessionCache from composio_tools.state import actor_key, actor_of -from copilotkit.langgraph import copilotkit_interrupt from write_confirmation import require_write_confirmation, summarize_args logger = logging.getLogger(__name__) @@ -312,47 +311,9 @@ def run_my_tool( return data - @tool - def ask_to_connect( - toolkit: str, - state: Annotated[dict[str, Any], InjectedState], - ) -> str: - """Ask the person to connect one of their own accounts. - - Call this when search_my_tools reports an app needs connecting. - - Args: - toolkit: The app to connect, e.g. 'gmail'. - """ - slug = toolkit.strip().lower() - if slug not in config.user_toolkits: - # A shared app is connected once by an operator, so prompting a - # person would produce a connection no shared call ever uses. - return ( - f"{slug or 'that app'} is not one people connect for themselves. " - "A shared app is connected once by whoever runs this deployment." - ) - if actor_of(state) is None: - return "I could not tell who is asking, so I cannot start a connection." - - # A render request, not a decision: the surface posts the card and - # resumes at once. Connecting takes minutes and several people in one - # thread may each connect their own account, which is not a shape a - # single paused graph can hold. - # - # The card carries no link. Minting happens on click, for the clicker, - # because whoever completes a connect flow binds their account to the id - # the link was minted for. - copilotkit_interrupt(action="connect_account", args={"toolkit": slug}) - return ( - f"I posted a Connect {slug} button in this thread. " - "Press it and the link will be private to you." - ) - - tools = [search_my_tools, run_my_tool] - # Connecting is a personal act. With no personal toolkits there is nothing a - # person could connect, and offering the tool would only invite the agent to - # tell somebody to connect a shared account they do not own. - if config.user_toolkits: - tools.append(ask_to_connect) - return tools + # Asking somebody to connect an account is not here. Posting a card is the + # surface's work, and it is a channel tool (`connect_app`) for a concrete + # reason: an interrupt cannot be resumed from an interrupt handler, only from + # a button click, so the agent-side version could only ever fail. The agent + # still decides *when* to ask — discovery tells it which app is unconnected. + return [search_my_tools, run_my_tool] diff --git a/agent/tests/test_composio_tools.py b/agent/tests/test_composio_tools.py index 676848b..8403eb1 100644 --- a/agent/tests/test_composio_tools.py +++ b/agent/tests/test_composio_tools.py @@ -126,18 +126,6 @@ def all_tools(cfg, sessions_by_user=None, effects=None): ] -def connect_tool(sessions_by_user, cfg=None, effects=None): - cfg = cfg or config() - client = FakeComposio(sessions_by_user) - built = { - tool.name: tool - for tool in build_composio_tools( - cfg, SessionCache(cfg, client=client), effects or FakeEffects() - ) - } - return built["ask_to_connect"] - - def state(actor_id=None, platform="slack"): if actor_id is None: return {} @@ -480,54 +468,3 @@ def test_the_composio_identity_is_namespaced_by_platform(): assert "teams:U1" in client.created -def test_the_connect_tool_is_absent_when_nobody_has_their_own_apps(): - # With no personal toolkits there is nothing a person could connect, and - # offering the tool only invites the agent to tell somebody to connect a - # shared account they do not own. - assert "ask_to_connect" not in all_tools(config(user_toolkits=())) - assert "ask_to_connect" in all_tools(config(user_toolkits=("gmail",))) - - -def test_asking_to_connect_posts_a_card_and_says_so(monkeypatch): - recorded = [] - monkeypatch.setattr( - tools_mod, - "copilotkit_interrupt", - lambda **kwargs: recorded.append(kwargs) or (None, None), - ) - ask = connect_tool({}) - - result = ask.invoke({"toolkit": "Gmail", "state": state("U1")}) - - assert recorded == [{"action": "connect_account", "args": {"toolkit": "gmail"}}] - assert "Connect gmail" in result - - -def test_asking_to_connect_a_shared_app_is_refused(monkeypatch): - recorded = [] - monkeypatch.setattr( - tools_mod, - "copilotkit_interrupt", - lambda **kwargs: recorded.append(kwargs) or (None, None), - ) - ask = connect_tool({}) - - result = ask.invoke({"toolkit": "linear", "state": state("U1")}) - - assert recorded == [] - assert "not one people connect for themselves" in result - - -def test_asking_to_connect_needs_to_know_who_is_asking(monkeypatch): - recorded = [] - monkeypatch.setattr( - tools_mod, - "copilotkit_interrupt", - lambda **kwargs: recorded.append(kwargs) or (None, None), - ) - ask = connect_tool({}) - - result = ask.invoke({"toolkit": "gmail", "state": state()}) - - assert recorded == [] - assert "could not tell who is asking" in result diff --git a/app/channel.test.ts b/app/channel.test.ts index 5c1ca2e..a07d865 100644 --- a/app/channel.test.ts +++ b/app/channel.test.ts @@ -540,6 +540,7 @@ describe("createOpenTagChannel", () => { "triage", ]); expect(appTools.map(({ name }) => name).sort()).toEqual([ + "connect_app", "issue_card", "issue_list", "page_list", diff --git a/app/channel.tsx b/app/channel.tsx index ea0b719..2171dfc 100644 --- a/app/channel.tsx +++ b/app/channel.tsx @@ -18,10 +18,7 @@ import { type SlackDirectConfig, } from "./env.js"; import { ConfirmWrite, ConnectAccount } from "./human-in-the-loop/index.js"; -import { - parseConfirmWriteInterrupt, - parseConnectAccountInterrupt, -} from "./interrupt.js"; +import { parseConfirmWriteInterrupt } from "./interrupt.js"; import { FILE_ISSUE_CALLBACK, fileIssueSubmit } from "./modals/file-issue.js"; import { IncidentCard } from "./tools/showcase-tools.js"; import { RenderChart } from "./tools/render-chart.js"; @@ -144,19 +141,6 @@ export function createOpenTagChannel( channel.onModalSubmit(FILE_ISSUE_CALLBACK, fileIssueSubmit); channel.onInterrupt("on_interrupt", async ({ payload, thread }) => { - // One event carries every interrupt the agent raises, so the connect - // request is checked first and only then does this fall through to the - // approval card, whose parse throws on anything it does not recognise. - const connect = parseConnectAccountInterrupt(payload); - if (connect) { - await thread.post(); - // Resumed at once. This interrupt is a request to draw something, not a - // decision to wait on: connecting takes minutes, and several people in - // one thread may each connect their own account. - await thread.resume({ posted: true }); - return; - } - const { args } = parseConfirmWriteInterrupt(payload); await thread.post( { - it("reads a connect request", () => { - const parsed = parseConnectAccountInterrupt( - interruptPayload("connect_account", { toolkit: "gmail" }), - ); - expect(parsed?.args.toolkit).toBe("gmail"); - }); - - it("accepts the payload as a JSON string, as the transport may deliver it", () => { - const parsed = parseConnectAccountInterrupt( - JSON.stringify(interruptPayload("connect_account", { toolkit: "gmail" })), - ); - expect(parsed?.args.toolkit).toBe("gmail"); - }); - - it("returns null for the approval interrupt rather than throwing", () => { - // One Channel event carries every interrupt the agent raises, so "not this - // one" is the normal case and must not read as an error. - expect( - parseConnectAccountInterrupt( - interruptPayload("confirm_write", { action: "Create issue" }), - ), - ).toBeNull(); - }); - - it("returns null for a connect request naming no app", () => { - expect( - parseConnectAccountInterrupt( - interruptPayload("connect_account", { toolkit: "" }), - ), - ).toBeNull(); - }); -}); - describe("parseConfirmWriteInterrupt approver", () => { it("carries the approver through when one is named", () => { const { args } = parseConfirmWriteInterrupt( diff --git a/app/interrupt.ts b/app/interrupt.ts index d6ec9d9..f910379 100644 --- a/app/interrupt.ts +++ b/app/interrupt.ts @@ -1,17 +1,5 @@ import { z } from "zod"; -/** - * Both interrupts the agent raises arrive on the same Channel event, so the - * handler has to tell them apart before it can act on either. - */ -const connectAccountInterruptSchema = z.object({ - __copilotkit_interrupt_value__: z.object({ - action: z.literal("connect_account"), - args: z.object({ toolkit: z.string().min(1) }), - }), - __copilotkit_messages__: z.array(z.unknown()).optional(), -}); - const confirmWriteInterruptSchema = z.object({ __copilotkit_interrupt_value__: z.object({ action: z.literal("confirm_write"), @@ -53,14 +41,3 @@ export function parseConfirmWriteInterrupt(payload: unknown) { return confirmWriteInterruptSchema.parse(normalize(payload)) .__copilotkit_interrupt_value__; } - -/** - * The connect request, or `null` when this interrupt is something else. - * - * Null rather than throwing: one event carries every interrupt the agent - * raises, so "not this one" is the normal case and not an error. - */ -export function parseConnectAccountInterrupt(payload: unknown) { - const parsed = connectAccountInterruptSchema.safeParse(normalize(payload)); - return parsed.success ? parsed.data.__copilotkit_interrupt_value__ : null; -} diff --git a/app/tools/__tests__/connect-app.test.tsx b/app/tools/__tests__/connect-app.test.tsx new file mode 100644 index 0000000..63049a5 --- /dev/null +++ b/app/tools/__tests__/connect-app.test.tsx @@ -0,0 +1,55 @@ +/** + * Posting the Connect button. + * + * This is a channel tool rather than an interrupt because `Thread.resume` + * requires a live interaction continuation, which only a button click has — the + * agent-side version raised an interrupt and tried to resume it from the + * interrupt handler, which could only ever fail. + */ +import { describe, expect, it, vi } from "vitest"; +import { connectAppTool } from "../connect-app.js"; + +function context() { + const post = vi.fn(async (_ui: unknown) => ({ id: "m1" })); + return { ctx: { thread: { post }, platform: "slack" } as never, post }; +} + +describe("connect_app", () => { + it("posts a card for the named app", async () => { + const { ctx, post } = context(); + + const result = await connectAppTool.handler({ toolkit: "gmail" }, ctx); + + expect(post).toHaveBeenCalledTimes(1); + expect(String(result)).toContain("gmail"); + }); + + it("lowercases and trims what the model passed", async () => { + const { ctx, post } = context(); + + await connectAppTool.handler({ toolkit: " Gmail " }, ctx); + + const posted = JSON.stringify(post.mock.calls[0]![0]); + expect(posted).toContain("gmail"); + expect(posted).not.toContain(" Gmail "); + }); + + it("posts nothing when no app was named", async () => { + const { ctx, post } = context(); + + const result = await connectAppTool.handler({ toolkit: " " }, ctx); + + expect(post).not.toHaveBeenCalled(); + expect(String(result)).toContain("No app was named"); + }); + + it("tells the agent not to claim the account is connected yet", async () => { + // The button still has to be pressed, and the link still has to be + // completed in a browser. An agent that reports success here is lying. + const { ctx } = context(); + + const result = await connectAppTool.handler({ toolkit: "gmail" }, ctx); + + expect(String(result)).toContain("Do not claim the account is connected"); + }); +}); diff --git a/app/tools/connect-app.tsx b/app/tools/connect-app.tsx new file mode 100644 index 0000000..76003c4 --- /dev/null +++ b/app/tools/connect-app.tsx @@ -0,0 +1,42 @@ +/** + * `connect_app` — post the Connect button for one app. + * + * A channel tool rather than an interrupt. The agent's first attempt at this + * raised an interrupt and resumed it immediately, on the reasoning that posting + * a card is a render request and not a decision to wait on. The framework + * disagrees for a good reason: `Thread.resume` requires a live interaction + * continuation, which only a button click has. An interrupt handler has none, so + * that call could only ever fail. + * + * A channel tool is the mechanism that actually fits. The agent decides *when* + * to ask — it knows which apps are configured and which the search reported as + * unconnected — and the surface does the posting, which is its job anyway. + */ +import { defineChannelTool } from "@copilotkit/channels"; +import { z } from "zod"; +import { ConnectAccount } from "../human-in-the-loop/connect-account.js"; + +export const connectAppTool = defineChannelTool({ + name: "connect_app", + description: + "Post a Connect button so the person can connect their own account for one " + + "app. Call this when a connected-app search reports that an app needs " + + "connecting, naming that app. The button is public but the link it produces " + + "is private to whoever presses it.", + parameters: z.object({ + toolkit: z + .string() + .describe("The app to connect, as the search reported it, e.g. 'gmail'"), + }), + async handler({ toolkit }, { thread }) { + const slug = toolkit.trim().toLowerCase(); + if (!slug) return "No app was named, so no button was posted."; + + await thread.post(); + return ( + `Posted a Connect ${slug} button in this thread. Tell the person to press ` + + `it; the link will be private to them. Do not claim the account is ` + + `connected until a later message says so.` + ); + }, +}); diff --git a/app/tools/index.ts b/app/tools/index.ts index 1878635..d3e33df 100644 --- a/app/tools/index.ts +++ b/app/tools/index.ts @@ -9,6 +9,7 @@ import { blockCatalogTool, isBlockCatalogEnabled, } from "./block-catalog.js"; +import { connectAppTool } from "./connect-app.js"; import { readThreadTool } from "./read-thread.js"; import { createShowCapabilitiesTool } from "./capabilities.js"; import { renderDiagramTool } from "./render-diagram.js"; @@ -51,6 +52,11 @@ export function createAppTools( showWorkPlanTool, showDecisionBriefTool, showKnowledgeSummaryTool, + // Registered unconditionally. Which apps a person can connect is the + // agent's configuration, not the runtime's — on a two-service deployment the + // toolkit lists are set on the agent alone — so the surface offers the + // button and the agent decides when asking for one makes sense. + connectAppTool, // Off by default, and *absent* rather than refusing when off: a tool the // agent can see but must not call leaks into its reasoning and turns into // "I can't do that here" instead of the topic not existing. diff --git a/setup.md b/setup.md index fa28916..19bdcd6 100644 --- a/setup.md +++ b/setup.md @@ -396,7 +396,8 @@ resolves to the personal scope only. How an account gets connected differs by list, and this is where the surprises are: -- **Personal.** The agent posts a public **Connect** card carrying no link. +- **Personal.** The agent calls `connect_app`, which posts a public **Connect** + card carrying no link. Whoever clicks receives a one-time link privately, minted for them; somebody else clicking the same card connects their own account. A pre-minted link posted in a channel would be an account-takeover hazard, because whoever From 55a9127ad9663d3dbe521b2fed71fa3dc6309169 Mon Sep 17 00:00:00 2001 From: Maxim Date: Tue, 1 Sep 2026 23:21:07 +0200 Subject: [PATCH 09/23] fix(composio): call execute the way the Python SDK declares it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `execute(slug, arguments)` raised "takes 2 positional arguments but 3 were given" the first time a real tool ran — the TypeScript SDK took the arguments positionally, the Python one wants them by keyword, and a hand-written fake accepted either. This is the third bug in this feature from the same root: the port carried the TypeScript call shape, and the fakes agreed with the port rather than with the SDK. So the fakes now match the real signature, and there is a contract test that reads the installed classes directly: `execute` takes its arguments by keyword, `search` takes its query by keyword, `authorize` takes the toolkit positionally, session creation still accepts `sandbox`, the response is still a Pydantic model carrying the three fields discovery reads, and our own structural `Session` type agrees with `ToolRouterSession` member by member. A fake can only assert what its author believed. An SDK upgrade that moves a parameter now fails in the suite instead of in a Slack thread. Verified against the live project end to end: discovery returns Gmail tools and a read-only execute comes back with real profile data. --- agent/composio_tools/sessions.py | 2 +- agent/composio_tools/tools.py | 4 +- agent/tests/test_composio_approval_resume.py | 2 +- agent/tests/test_composio_sdk_contract.py | 100 +++++++++++++++++++ agent/tests/test_composio_tools.py | 2 +- 5 files changed, 106 insertions(+), 4 deletions(-) create mode 100644 agent/tests/test_composio_sdk_contract.py diff --git a/agent/composio_tools/sessions.py b/agent/composio_tools/sessions.py index e96a9ea..1bb9453 100644 --- a/agent/composio_tools/sessions.py +++ b/agent/composio_tools/sessions.py @@ -24,7 +24,7 @@ class Session(Protocol): def search(self, *, query: str) -> Any: ... - def execute(self, slug: str, arguments: dict[str, Any]) -> Any: ... + def execute(self, slug: str, *, arguments: dict[str, Any]) -> Any: ... def authorize(self, toolkit: str) -> Any: ... diff --git a/agent/composio_tools/tools.py b/agent/composio_tools/tools.py index bc0be26..61d51e7 100644 --- a/agent/composio_tools/tools.py +++ b/agent/composio_tools/tools.py @@ -286,7 +286,9 @@ def run_my_tool( if not approved: return f"{humanize_slug(slug)} was declined, so nothing ran." - result = owning.session.execute(slug, arguments) + # `arguments` is keyword-only in the Python SDK. The TypeScript one took + # it positionally, and a hand-written fake happily accepted either. + result = owning.session.execute(slug, arguments=arguments) fields = _as_dict(result) if not hasattr(result, "error") else None error = fields.get("error") if fields is not None else getattr(result, "error", None) data = fields.get("data") if fields is not None else getattr(result, "data", None) diff --git a/agent/tests/test_composio_approval_resume.py b/agent/tests/test_composio_approval_resume.py index e446708..1dc1a89 100644 --- a/agent/tests/test_composio_approval_resume.py +++ b/agent/tests/test_composio_approval_resume.py @@ -73,7 +73,7 @@ def __init__(self, user_id: str) -> None: def search(self, *, query): raise AssertionError("this test does not search") - def execute(self, slug, arguments): + def execute(self, slug, *, arguments): self.executed.append((slug, arguments)) return {"data": {"id": "msg-1"}, "error": None} diff --git a/agent/tests/test_composio_sdk_contract.py b/agent/tests/test_composio_sdk_contract.py new file mode 100644 index 0000000..1fa7080 --- /dev/null +++ b/agent/tests/test_composio_sdk_contract.py @@ -0,0 +1,100 @@ +"""Do we call the installed SDK the way it is actually shaped? + +Three bugs in this feature came from the same place: the port carried the +TypeScript SDK's call shape, and hand-written fakes agreed with the port instead +of with Python. Every unit test passed while nothing worked against a live +project — a session response read as a dict returned nothing silently, and +`execute` took its arguments positionally where Python wants a keyword. + +A fake can only ever assert what its author believed. These tests read the real +installed classes, so an SDK upgrade that moves a parameter fails here rather +than in a thread. +""" + +from __future__ import annotations + +import inspect + +import pytest + +from composio.core.models.tool_router import ToolRouter +from composio.core.models.tool_router_session import ( + SessionSearchResponse, + ToolRouterSession, +) + +from composio_tools.sessions import Session + + +def parameters(method) -> dict[str, inspect.Parameter]: + return dict(inspect.signature(method).parameters) + + +def test_execute_takes_its_arguments_by_keyword(): + # The bug: `execute(slug, arguments)` raised "takes 2 positional arguments + # but 3 were given" only once a real call happened. + argument = parameters(ToolRouterSession.execute)["arguments"] + assert argument.kind is inspect.Parameter.KEYWORD_ONLY + + +def test_execute_names_the_slug_positionally(): + names = list(parameters(ToolRouterSession.execute)) + assert names[1] == "tool_slug" + assert ( + parameters(ToolRouterSession.execute)["tool_slug"].kind + is inspect.Parameter.POSITIONAL_OR_KEYWORD + ) + + +def test_search_takes_its_query_by_keyword(): + assert ( + parameters(ToolRouterSession.search)["query"].kind + is inspect.Parameter.KEYWORD_ONLY + ) + + +def test_authorize_names_the_toolkit_positionally(): + assert ( + parameters(ToolRouterSession.authorize)["toolkit"].kind + is inspect.Parameter.POSITIONAL_OR_KEYWORD + ) + + +def test_session_creation_accepts_what_we_pass_it(): + # `sandbox` disables the remote shell and remote Python tools, and `workbench` + # is its deprecated alias — passing both raises, so this must not silently + # become the wrong one. + names = parameters(ToolRouter.create) + assert names["user_id"].kind is inspect.Parameter.KEYWORD_ONLY + assert "sandbox" in names + assert "toolkits" in names + + +def test_our_protocol_matches_the_real_session(): + # The structural type our code is written against, checked member by member + # rather than trusted. + for name in ("search", "execute", "authorize", "toolkits"): + ours = parameters(getattr(Session, name)) + theirs = parameters(getattr(ToolRouterSession, name)) + for argument, declared in ours.items(): + if argument == "self": + continue + assert argument in theirs or argument == "slug", ( + f"Session.{name} declares {argument!r}, which " + f"ToolRouterSession.{name} does not accept" + ) + if argument in theirs: + assert declared.kind is theirs[argument].kind, ( + f"Session.{name}({argument}) is {declared.kind}, but the SDK " + f"wants {theirs[argument].kind}" + ) + + +@pytest.mark.parametrize( + "field", + ["results", "tool_schemas", "toolkit_connection_statuses"], +) +def test_the_search_response_still_carries_the_fields_we_read(field): + fields = getattr(SessionSearchResponse, "model_fields", None) + assert fields is not None, "the response stopped being a Pydantic model" + assert field in fields diff --git a/agent/tests/test_composio_tools.py b/agent/tests/test_composio_tools.py index 8403eb1..a7511ad 100644 --- a/agent/tests/test_composio_tools.py +++ b/agent/tests/test_composio_tools.py @@ -57,7 +57,7 @@ def search(self, *, query): raise RuntimeError("scope unreachable") return self._response - def execute(self, slug, arguments): + def execute(self, slug, *, arguments): self.executed.append((slug, arguments)) return self._result From 94381ae5c0739aa66df3fc4664a21b963651b9a8 Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 2 Sep 2026 18:34:02 +0200 Subject: [PATCH 10/23] fix(composio): take identity from the forwarded actor, and from nothing else MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An anonymous turn inherited the last person who spoke. `channel_actor` lives in a graph checkpointed per thread, and a run that forwarded nobody simply left it alone — so a second person in a Slack thread, or the same person on a build whose Channel does not forward, ran Gmail in the first person's connected account. Reproduced end to end against the real AG-UI adapter, not argued from the code. Alongside it, four smaller ways the same value could be made to mean the wrong person. None of them is reachable without the shared secret, so they are hardening rather than the defect above: * The adapter merges a request's `state` *over* its forwarded properties (`{**forwarded_props, **payload_input}`), so a caller-supplied `channel_actor` won the slot the trusted one arrives in. * `platform` was coerced: blank became the `unknown:` namespace, a dict became `{'x': 1}:U1`, an int became `7:U1`, and `Slack` and `slack` were two namespaces for one person. Coercion is also what made the `platform:id` join non-injective; a closed, colon-free platform set restores it, because the first colon in a key is then always the separator. * `kind` was unread, so a `bot`, `app` or `system` actor minted a personal identity and spent a real person's access. * `actor_of` rejected a non-string id while `actor_key` coerced one: the same actor was nobody to the turn and a live Composio user id to everything keyed per person. Both now sit on one type gate. `channel_actor` is also reduced to `{id, platform, kind}`. `name`, `handle` and `email` decide nothing here, and the whole value is echoed in every `StateSnapshotEvent` and kept in the thread's checkpoint. The agent already receives the display name through `senderContext`, TypeScript-side; nothing in `app/` reads `channel_actor` at all. The connect route now needs to know what clicked, because minting a link for a bot binds a real account to an identity no turn will ever act as. The runtime sends `kind`; a body without one is refused with a readable 400 rather than being treated as a person. Call sites of every symbol touched: added, composio_tools/state.py KNOWN_PLATFORMS -> _named_identity; tests/test_composio_identity.py PERSONAL_KINDS -> is_personal_kind _CALLER_ACTOR_KEYS -> forwarded_actor, with_forwarded_actor _named_identity -> actor_key, personal_actor is_personal_kind -> personal_actor; main.composio_connect; tests personal_actor -> actor_of, forwarded_actor forwarded_actor -> with_forwarded_actor; tests with_forwarded_actor -> agui.with_trusted_actor; tests added, agui.py with_trusted_actor -> OpenTagAGUIAgent.run; exercised through agent.run in tests/test_composio_identity.py behaviour changed, same signature actor_of -> composio_tools/tools.py:164, :280 actor_key -> composio_tools/tools.py:164, :280; main.py:109 wire contract ConnectRequest.kind <- app/tools/composio-connect.ts requestConnectLink ConnectRequestInput.actorKind -> app/tools/connect-click.tsx app/human-in-the-loop/confirm-write.tsx refuseWrongApprover -> the two ConfirmWrite button handlers (:232, :264) removed: nothing. Every behaviour above was watched fail first, and each production change was reverted afterwards to confirm the test that covers it goes red. --- agent/agui.py | 29 +- agent/composio_tools/state.py | 202 ++++++++++++-- agent/main.py | 19 +- agent/tests/test_composio_connect.py | 64 ++++- agent/tests/test_composio_identity.py | 264 ++++++++++++++++++ .../__tests__/confirm-write-approver.test.tsx | 19 +- app/human-in-the-loop/confirm-write.tsx | 9 +- app/tools/__tests__/composio-connect.test.ts | 4 + app/tools/__tests__/connect-click.test.tsx | 32 ++- app/tools/composio-connect.ts | 13 +- app/tools/connect-click.tsx | 1 + setup.md | 17 ++ 12 files changed, 623 insertions(+), 50 deletions(-) create mode 100644 agent/tests/test_composio_identity.py diff --git a/agent/agui.py b/agent/agui.py index c2ff184..8fefe05 100644 --- a/agent/agui.py +++ b/agent/agui.py @@ -13,6 +13,7 @@ from langgraph.errors import GraphRecursionError from agent import graph_recursion_limit +from composio_tools.state import with_forwarded_actor AGENT_NAME = "opentag_research" AGENT_DESCRIPTION = ( @@ -26,11 +27,37 @@ ) +def with_trusted_actor(input_data): + """One run, with its identity taken from the forwarded actor and nothing else. + + This is the only point that sees the trusted value and the untrusted one + side by side. Below it they are the same key: the adapter merges forwarded + properties and the request's `state` into one graph input, and `state` wins + — so a body naming somebody else would decide whose account a turn runs in. + Above it there is no run object to rewrite. + + Rewriting `state` rather than dropping the caller's key is deliberate. The + key must be *present* on every run: the graph is checkpointed per thread, so + a turn that forwards nobody has to say so out loud to clear the last speaker + rather than inherit them. + """ + return input_data.model_copy( + update={ + "state": with_forwarded_actor( + getattr(input_data, "state", None), + getattr(input_data, "forwarded_props", None), + ) + } + ) + + class OpenTagAGUIAgent(LangGraphAGUIAgent): """Serve the graph and turn a graph-level step-limit crash into a reply.""" async def run(self, input_data): - async for event in iter_agent_events(super().run, input_data): + async for event in iter_agent_events( + super().run, with_trusted_actor(input_data) + ): yield event diff --git a/agent/composio_tools/state.py b/agent/composio_tools/state.py index 76fc9cf..64eb14d 100644 --- a/agent/composio_tools/state.py +++ b/agent/composio_tools/state.py @@ -1,43 +1,135 @@ -"""Graph state carrying who is speaking. +"""Graph state carrying who is speaking, and the one place it is decided. The Channel forwards the verified actor with every run, and the AG-UI adapter merges forwarded properties into the graph's input. A key only survives that -merge if the state schema declares it, which is what this module is for. - -Refreshed every ordinary turn, so it cannot go stale when a second person speaks -in the same thread: the adapter treats a run as a continuation only when the -caller supplies a node name, and a Channel never does. - -One exception, and it is the reason `pending.py` carries an identity of its own -rather than reading this: a resume is delivered as a resume command, and -forwarded properties do not travel with it. +merge if the state schema declares it, which is what `ComposioAgentState` is +for. + +Two things the adapter does *not* do, and this module must: + +* The adapter merges caller-supplied `state` **over** the forwarded properties + (`{**forwarded_props, **payload_input}` in `prepare_stream`), so a request + body naming somebody else wins over the platform's own word for who spoke. +* The graph is checkpointed per thread, so `channel_actor` survives the turn + that set it. A later turn that forwards nobody inherits the last speaker and + runs in their connected accounts. + +`with_forwarded_actor` closes both: it rebuilds a run's state with +`channel_actor` taken from the forwarded properties and from nothing else, and +writes `None` when the run forwarded nobody so the previous speaker is cleared +rather than inherited. `agui.OpenTagAGUIAgent` applies it to every run, which is +the only point that can see the trusted and the untrusted value side by side. + +One exception, and it is why a resume carries its identity in the interrupt +payload rather than reading state: a resume is delivered as a resume command, +and forwarded properties do not travel with it. """ from __future__ import annotations +from collections.abc import Mapping from typing import Any, NotRequired from deepagents import DeepAgentState - -def actor_of(state: dict[str, Any] | None) -> dict[str, Any] | None: +#: Surfaces a turn can arrive from. Closed on purpose, and it is what makes the +#: `platform:id` join injective: no member contains a colon, so the first colon +#: in a key is always the separator and `(platform, id)` is recoverable from the +#: key even when an id contains one. An open set could not promise that — a +#: blank platform used to namespace people under `unknown:`, and a non-string +#: one was coerced, so `{"x": 1}` and `7` each minted a key of their own. +#: Adding a surface means adding it here. +KNOWN_PLATFORMS = frozenset({"slack", "teams"}) + +#: The one actor kind that gets a personal identity. +#: +#: `ProviderActor.kind` is the provider's own word for what sent a message, and +#: the Channels SDK documents it as untrusted metadata rather than +#: authorization. That is exactly why it is read as a filter and never as a +#: grant: `bot`, `app`, `system` and `unknown` are refused, so a workflow or an +#: integration posting into a thread cannot spend a person's connected account. +PERSONAL_KINDS = frozenset({"human"}) + +#: Every spelling of the actor a caller could put in a request's `state`. All of +#: them are dropped before the forwarded one is written. +_CALLER_ACTOR_KEYS = ("channel_actor", "channelActor") + + +def _named_identity(actor: Any) -> tuple[str, str] | None: """ - The forwarded actor, or `None` when the turn named nobody. + `(platform, id)` when this value names somebody, else `None`. - Defensive about shape because this value crosses a process boundary: a - malformed `channel_actor` reads as an anonymous turn, which costs access to - personal toolkits and never grants it. + The single type gate, shared by everything that reads an actor, so no two + callers can disagree about what a usable id is. `actor_of` rejecting a + non-string id while `actor_key` coerced one was such a disagreement: the + same actor was nobody to one function and a real Composio identity to the + other. """ - actor = (state or {}).get("channel_actor") - if not isinstance(actor, dict): + if not isinstance(actor, Mapping): return None + identifier = actor.get("id") - if not isinstance(identifier, str) or not identifier.strip(): + if not isinstance(identifier, str): + return None + identifier = identifier.strip() + if not identifier: + return None + + platform = actor.get("platform") + if not isinstance(platform, str): + return None + platform = platform.strip().lower() + if platform not in KNOWN_PLATFORMS: + return None + + return platform, identifier + + +def is_personal_kind(actor: Any) -> bool: + """Whether this actor is a person, rather than something posting as one.""" + if not isinstance(actor, Mapping): + return False + kind = actor.get("kind") + return isinstance(kind, str) and kind.strip().lower() in PERSONAL_KINDS + + +def personal_actor(actor: Any) -> dict[str, Any] | None: + """ + The person this value names, reduced to what the agent acts on. + + `id`, `platform` and `kind` and nothing else. A `ProviderActor` also carries + `name`, `handle` and `email`, and none of them decide anything here — while + the whole of `channel_actor` is echoed back in every `StateSnapshotEvent` + and kept in the thread's checkpoint. The person's display name and work + address are already known to the surface that sent them, so carrying them + through the graph buys nothing and spreads them. + """ + named = _named_identity(actor) + if named is None or not is_personal_kind(actor): + return None + platform, identifier = named + return { + "id": identifier, + "platform": platform, + "kind": actor["kind"].strip().lower(), + } + + +def actor_of(state: Mapping[str, Any] | None) -> dict[str, Any] | None: + """ + The actor this turn may act as, or `None` when the turn named nobody. + + Defensive about shape because this value crosses a process boundary: an + actor that is malformed, from an unknown surface, or not a person reads as + an anonymous turn, which costs access to personal toolkits and never grants + it. + """ + if not isinstance(state, Mapping): return None - return actor + return personal_actor(state.get("channel_actor")) -def actor_key(actor: dict[str, Any] | None) -> str | None: +def actor_key(actor: Mapping[str, Any] | None) -> str | None: """ The stable per-person key, namespaced by platform. @@ -45,21 +137,69 @@ def actor_key(actor: dict[str, Any] | None) -> str | None: out the same string for different people. Everything keyed per person — a connected account, a pending approval — keys on both parts. - A blank id is nobody, and returns `None` rather than a key ending in a colon. - Callers reaching this through `actor_of` already had that filtered, but the - connect route does not: it builds an actor from a request body, and a live - run showed an empty `actor_id` minting a real link bound to an identity no - turn would ever look up again. + Naming only: it answers "how is this identity spelled", not "may this actor + act". `actor_of` and the connect route make that second decision, both + through `is_personal_kind`, and both on top of the same `_named_identity` + gate this uses — so there is no value one of them calls nobody and the other + turns into a Composio user id. + + An id or platform that does not pass that gate is nobody, and returns `None` + rather than a key ending in a colon or beginning with `unknown:`. Callers + reaching this through `actor_of` already had that filtered, but the connect + route does not: it builds an actor from a request body, and a live run + showed an empty `actor_id` minting a real link bound to an identity no turn + would ever look up again. """ - if actor is None: + named = _named_identity(actor) + if named is None: return None - identifier = str(actor.get("id") or "").strip() - if not identifier: - return None - platform = str(actor.get("platform") or "").strip() or "unknown" + platform, identifier = named return f"{platform}:{identifier}" +def forwarded_actor(forwarded_props: Any) -> dict[str, Any] | None: + """ + The actor the Channel forwarded with this run, or `None`. + + Read from `forwardedProps` alone. A Channel puts the platform's own word for + who spoke there; a request's `state` is whatever the caller typed, and the + two arrive in the same slot by the time the graph sees them. + + Both spellings are accepted because the key is snake-cased on its way + through the adapter, and this runs before that happens on one path and after + it on another. + """ + if not isinstance(forwarded_props, Mapping): + return None + for key in _CALLER_ACTOR_KEYS: + if key in forwarded_props: + return personal_actor(forwarded_props[key]) + return None + + +def with_forwarded_actor( + state: Any, + forwarded_props: Any, +) -> dict[str, Any]: + """ + One run's state, with `channel_actor` decided by the forwarded actor alone. + + Always written, never merged. A caller's own `channel_actor` is dropped + whichever way it was spelled, and a run that forwarded nobody writes `None` + — an explicit key, because the graph is checkpointed per thread and leaving + it out lets the previous speaker's identity stand. An anonymous turn + inheriting the last speaker is how a second person in a Slack thread got a + Gmail call executed in the first person's account. + """ + merged = { + key: value + for key, value in (state.items() if isinstance(state, Mapping) else ()) + if key not in _CALLER_ACTOR_KEYS + } + merged["channel_actor"] = forwarded_actor(forwarded_props) + return merged + + class ComposioAgentState(DeepAgentState): """`DeepAgentState` plus the forwarded actor.""" diff --git a/agent/main.py b/agent/main.py index 92e0a59..c6d7f67 100644 --- a/agent/main.py +++ b/agent/main.py @@ -15,7 +15,7 @@ from agui import AGENT_DESCRIPTION, AGENT_NAME, build_agui_agent from composio_tools.connect import ConnectRefused, connect_link from composio_tools.runtime import composio_runtime -from composio_tools.state import actor_key +from composio_tools.state import actor_key, is_personal_kind app = FastAPI( title="OpenTag Agent", @@ -65,6 +65,12 @@ class ConnectRequest(BaseModel): actor_id: str platform: str toolkit: str + #: The clicker's `ProviderActor.kind`. Optional on the wire and refused when + #: absent: a runtime too old to send it cannot say whether a person clicked, + #: and "I could not tell" is not a reason to mint a bearer capability. The + #: failure is a readable 400 rather than a schema rejection, because the + #: person on the other end sees this sentence. + kind: str | None = None @app.post("/composio/connect") @@ -91,7 +97,16 @@ def composio_connect(body: ConnectRequest, request: Request): status_code=503, ) - identity = actor_key({"id": body.actor_id, "platform": body.platform}) + actor = { + "id": body.actor_id, + "platform": body.platform, + "kind": body.kind, + } + # Both halves of the same question the graph asks before it runs a personal + # tool, asked through the same two functions: is this spelled like somebody, + # and is that somebody a person. A link minted for a bot or an app binds a + # real account to an identity no turn will ever act as. + identity = actor_key(actor) if is_personal_kind(actor) else None if identity is None: return JSONResponse({"error": "No person was named."}, status_code=400) diff --git a/agent/tests/test_composio_connect.py b/agent/tests/test_composio_connect.py index bb8f35d..7caafd7 100644 --- a/agent/tests/test_composio_connect.py +++ b/agent/tests/test_composio_connect.py @@ -162,7 +162,7 @@ def test_the_route_refuses_without_a_configured_secret(client, monkeypatch): response = client.post( "/composio/connect", - json={"actor_id": "U1", "platform": "slack", "toolkit": "gmail"}, + json={"actor_id": "U1", "kind": "human", "platform": "slack", "toolkit": "gmail"}, ) assert response.status_code == 401 @@ -174,7 +174,7 @@ def test_the_route_refuses_a_wrong_secret(client, monkeypatch): response = client.post( "/composio/connect", - json={"actor_id": "U1", "platform": "slack", "toolkit": "gmail"}, + json={"actor_id": "U1", "kind": "human", "platform": "slack", "toolkit": "gmail"}, headers={"Authorization": "Bearer wrong"}, ) @@ -188,7 +188,7 @@ def test_the_route_returns_a_link_for_the_named_person(client, monkeypatch): response = client.post( "/composio/connect", - json={"actor_id": "U1", "platform": "slack", "toolkit": "gmail"}, + json={"actor_id": "U1", "kind": "human", "platform": "slack", "toolkit": "gmail"}, headers={"Authorization": "Bearer s3cret"}, ) @@ -204,7 +204,7 @@ def test_the_route_reports_an_unconfigured_deployment(client, monkeypatch): response = client.post( "/composio/connect", - json={"actor_id": "U1", "platform": "slack", "toolkit": "gmail"}, + json={"actor_id": "U1", "kind": "human", "platform": "slack", "toolkit": "gmail"}, headers={"Authorization": "Bearer s3cret"}, ) @@ -227,9 +227,63 @@ def test_the_route_refuses_a_request_naming_nobody(client, monkeypatch): for actor_id in ("", " "): response = client.post( "/composio/connect", - json={"actor_id": actor_id, "platform": "slack", "toolkit": "gmail"}, + json={ + "actor_id": actor_id, + "kind": "human", + "platform": "slack", + "toolkit": "gmail", + }, headers={"Authorization": "Bearer s3cret"}, ) assert response.status_code == 400 assert composio.created == [] + + +@pytest.mark.parametrize("kind", ["bot", "app", "system", "unknown", None]) +def test_the_route_mints_nothing_for_something_posting_as_a_person( + client, monkeypatch, kind +): + # A connect link is a bearer capability, and whoever opens it binds a real + # account to the id it was minted for. Minting one for a bot, an app, or a + # caller that could not say, binds an account to an identity no turn will + # ever act as — the same broken end state a blank `actor_id` produced. + monkeypatch.setenv("AGENT_AUTH_HEADER", "Bearer s3cret") + _runtime, composio = install_runtime(monkeypatch, {}) + + body = {"actor_id": "U1", "platform": "slack", "toolkit": "gmail"} + if kind is not None: + body["kind"] = kind + + response = client.post( + "/composio/connect", + json=body, + headers={"Authorization": "Bearer s3cret"}, + ) + + assert response.status_code == 400 + assert composio.created == [] + + +@pytest.mark.parametrize("platform", ["", "unknown", "discord"]) +def test_the_route_mints_nothing_for_a_surface_no_turn_arrives_from( + client, monkeypatch, platform +): + # A link minted under `unknown:U1` connects an account nothing looks up, + # and `unknown` was a namespace anyone could reach. + monkeypatch.setenv("AGENT_AUTH_HEADER", "Bearer s3cret") + _runtime, composio = install_runtime(monkeypatch, {}) + + response = client.post( + "/composio/connect", + json={ + "actor_id": "U1", + "kind": "human", + "platform": platform, + "toolkit": "gmail", + }, + headers={"Authorization": "Bearer s3cret"}, + ) + + assert response.status_code == 400 + assert composio.created == [] diff --git a/agent/tests/test_composio_identity.py b/agent/tests/test_composio_identity.py new file mode 100644 index 0000000..4421276 --- /dev/null +++ b/agent/tests/test_composio_identity.py @@ -0,0 +1,264 @@ +"""Who a turn acts as, decided at the boundary and nowhere else. + +The integration cases here drive the real AG-UI adapter over a real checkpointed +graph rather than asserting on a helper. Both defects they pin were invisible to +a helper-level test: one lives in how the adapter merges a request's `state` +over its forwarded properties, the other in the fact that the graph is +checkpointed per thread and a turn that says nothing leaves the last answer +standing. +""" + +from __future__ import annotations + +import asyncio +import uuid + +import pytest +from ag_ui.core import RunAgentInput, UserMessage +from langgraph.checkpoint.memory import MemorySaver +from langgraph.graph import END, START, StateGraph + +from agui import build_agui_agent +from composio_tools.state import ( + KNOWN_PLATFORMS, + ComposioAgentState, + actor_key, + actor_of, + forwarded_actor, + is_personal_kind, + with_forwarded_actor, +) + +SLACK_U1 = {"id": "U1", "kind": "human", "platform": "slack"} +SLACK_U2 = {"id": "U2", "kind": "human", "platform": "slack"} + + +class Turns: + """Every actor the graph saw, in order.""" + + def __init__(self) -> None: + self.actors: list[dict | None] = [] + + def record(self, state) -> dict: + self.actors.append(state.get("channel_actor")) + return {} + + +def agent_over(turns: Turns): + graph = StateGraph(ComposioAgentState) + graph.add_node("record", turns.record) + graph.add_edge(START, "record") + graph.add_edge("record", END) + return build_agui_agent(graph.compile(checkpointer=MemorySaver())) + + +def run_input(thread: str, text: str, *, forwarded=None, state=None) -> RunAgentInput: + return RunAgentInput( + thread_id=thread, + run_id=str(uuid.uuid4()), + state={} if state is None else state, + messages=[UserMessage(id=str(uuid.uuid4()), role="user", content=text)], + tools=[], + context=[], + forwarded_props={} if forwarded is None else forwarded, + ) + + +def drive(agent, *inputs) -> None: + async def _drive() -> None: + for one in inputs: + async for _event in agent.run(one): + pass + + asyncio.run(_drive()) + + +def test_the_forwarded_actor_reaches_the_graph(): + # The control. Without it the two cases below could both pass on a build + # that never resolves anybody. + turns = Turns() + + drive( + agent_over(turns), + run_input("t", "hi", forwarded={"channelActor": SLACK_U1}), + ) + + assert turns.actors == [{"id": "U1", "platform": "slack", "kind": "human"}] + + +def test_an_anonymous_turn_does_not_inherit_the_previous_speaker(): + # The graph is checkpointed per thread, so `channel_actor` outlives the turn + # that set it. A second person speaking in the same Slack thread — or the + # same person on a build whose Channel does not forward — used to run in the + # first person's connected accounts. + turns = Turns() + + drive( + agent_over(turns), + run_input("t", "hi", forwarded={"channelActor": SLACK_U1}), + run_input("t", "and again", forwarded={}), + ) + + assert turns.actors[1] is None + + +def test_caller_supplied_state_cannot_name_a_different_person(): + # The adapter merges a request's `state` *over* its forwarded properties, so + # the untrusted value used to win the slot the trusted one arrives in. + turns = Turns() + + drive( + agent_over(turns), + run_input( + "t", + "hi", + forwarded={"channelActor": SLACK_U1}, + state={"channel_actor": SLACK_U2}, + ), + ) + + assert turns.actors == [{"id": "U1", "platform": "slack", "kind": "human"}] + + +def test_caller_supplied_state_alone_names_nobody(): + turns = Turns() + + drive( + agent_over(turns), + run_input("t", "hi", forwarded={}, state={"channel_actor": SLACK_U2}), + ) + + assert turns.actors == [None] + + +def test_the_camelcase_spelling_in_state_is_dropped_too(): + # `state` is not key-converted on its way through the adapter, so a caller + # can spell the key either way. + assert with_forwarded_actor({"channelActor": SLACK_U2}, {}) == { + "channel_actor": None + } + + +def test_unrelated_state_survives_the_rewrite(): + assert with_forwarded_actor({"todos": ["a"]}, {"channelActor": SLACK_U1}) == { + "todos": ["a"], + "channel_actor": {"id": "U1", "platform": "slack", "kind": "human"}, + } + + +@pytest.mark.parametrize("state", [None, [], "nope", 7]) +def test_a_state_that_is_not_a_mapping_still_yields_a_cleared_actor(state): + assert with_forwarded_actor(state, {}) == {"channel_actor": None} + + +@pytest.mark.parametrize( + "platform", + ["", " ", "unknown", "discord", {"x": 1}, 7, None, ["slack"]], +) +def test_an_unusable_platform_mints_no_identity(platform): + # A blank platform used to namespace people under `unknown:`, and a + # non-string one was coerced — `{'x': 1}:U1`, `7:U1`. Each was a namespace of + # its own, reachable by anyone who could put that value in the slot. + actor = {"id": "U1", "kind": "human", "platform": platform} + + assert actor_key(actor) is None + assert actor_of({"channel_actor": actor}) is None + + +def test_a_known_platform_is_matched_case_insensitively(): + assert actor_key({"id": "U1", "kind": "human", "platform": " Slack "}) == "slack:U1" + + +@pytest.mark.parametrize("kind", ["bot", "app", "system", "unknown", "", None, 7]) +def test_only_a_person_gets_a_personal_identity(kind): + # `ProviderActor.kind` is the provider's own word for what posted, and the + # SDK calls it untrusted metadata. Read as a filter it costs a bot access; + # read as a grant it would spend a person's connected account. + actor = {"id": "U1", "kind": kind, "platform": "slack"} + + assert is_personal_kind(actor) is False + assert actor_of({"channel_actor": actor}) is None + assert forwarded_actor({"channelActor": actor}) is None + + +@pytest.mark.parametrize("identifier", [7, None, b"U1", ["U1"], {"id": "U1"}, "", " "]) +def test_actor_of_and_actor_key_agree_on_an_unusable_id(identifier): + # They disagreed: `actor_of` required a string and `actor_key` coerced one, + # so the same actor was nobody to the turn and a real Composio user id to + # everything keyed per person. + actor = {"id": identifier, "kind": "human", "platform": "slack"} + + assert actor_of({"channel_actor": actor}) is None + assert actor_key(actor) is None + + +def test_no_known_platform_contains_the_separator(): + # What makes `platform:id` injective. The platform half comes from a closed, + # colon-free set, so the first colon in a key is always the separator and the + # pair is recoverable even from an id that contains one. + assert all(":" not in platform for platform in KNOWN_PLATFORMS) + + +def test_the_platform_id_join_is_injective(): + pairs = [ + ("slack", "U1"), + ("teams", "U1"), + ("slack", "teams:U1"), + ("teams", "slack:U1"), + ("slack", "U1:"), + ("teams", ":U1"), + ] + keys = [actor_key({"id": i, "kind": "human", "platform": p}) for p, i in pairs] + + assert len(set(keys)) == len(pairs) + for key, (platform, identifier) in zip(keys, pairs, strict=True): + assert key.split(":", 1) == [platform, identifier] + + +def test_the_actor_kept_in_state_carries_no_name_or_email(): + # The whole of `channel_actor` is echoed in every StateSnapshotEvent and + # kept in the thread's checkpoint. Nothing here decides anything on a display + # name or a work address, and the surface that sent them already has them. + kept = forwarded_actor( + { + "channelActor": { + **SLACK_U1, + "name": "Ada Lovelace", + "handle": "ada", + "email": "ada@example.com", + } + } + ) + + assert kept == {"id": "U1", "platform": "slack", "kind": "human"} + + +def test_a_forwarded_actor_of_the_wrong_shape_is_nobody(): + for value in (None, "U1", 7, [], {"kind": "human"}, {"id": "U1"}): + assert forwarded_actor({"channelActor": value}) is None + assert forwarded_actor({}) is None + assert forwarded_actor(None) is None + + +def test_the_snake_cased_spelling_is_read_too(): + # The adapter snake-cases forwarded keys on the way down; this runs above + # that on one path and below it on another. + assert forwarded_actor({"channel_actor": SLACK_U1}) == { + "id": "U1", + "platform": "slack", + "kind": "human", + } + + +def test_two_different_actors_never_share_a_key(): + # `unknown` was a real namespace, not a placeholder: a blank platform and a + # literal "unknown" both keyed to `unknown:U1`, and a differently-cased one + # opened a second namespace for the same person. Two people sharing a key + # share a Composio identity, and therefore each other's connected accounts. + collided = [ + {"id": "U1", "kind": "human", "platform": ""}, + {"id": "U1", "kind": "human", "platform": "unknown"}, + {"id": "U1", "kind": "human", "platform": {"x": 1}}, + ] + + assert [actor_key(actor) for actor in collided] == [None, None, None] diff --git a/app/human-in-the-loop/__tests__/confirm-write-approver.test.tsx b/app/human-in-the-loop/__tests__/confirm-write-approver.test.tsx index a4a6458..5fd48c1 100644 --- a/app/human-in-the-loop/__tests__/confirm-write-approver.test.tsx +++ b/app/human-in-the-loop/__tests__/confirm-write-approver.test.tsx @@ -33,7 +33,7 @@ function buttonHandlers(node: unknown): Array<(ctx: unknown) => unknown> { return found; } -function interaction(actorId: string) { +function interaction(actorId: string, platform = "slack") { const update = vi.fn(async () => undefined); const resume = vi.fn(async () => undefined); const postEphemeral = vi.fn( @@ -43,7 +43,7 @@ function interaction(actorId: string) { return { ctx: { actor: { id: actorId, kind: "human" }, - platform: "slack", + platform, thread: { update, resume, postEphemeral }, message: { ref: "m1" }, action: { id: "a1" }, @@ -108,6 +108,21 @@ describe("ConfirmWrite approver", () => { expect(postEphemeral).not.toHaveBeenCalled(); }); + it("spells the platform the way the agent does, so casing cannot refuse the right person", async () => { + // The approver string is built by `actor_key` in the agent, which lowercases + // the platform. A surface reporting "Slack" would otherwise never match the + // `slack:U1` the card names, and the one person entitled to answer could not. + const handlers = buttonHandlers( + ConfirmWrite({ action: "Gmail send email", approver: "slack:U1" }), + ); + const { ctx, update, postEphemeral } = interaction("U1", "Slack"); + + await handlers[0]!(ctx); + + expect(update).toHaveBeenCalled(); + expect(postEphemeral).not.toHaveBeenCalled(); + }); + it("does not match a person on another platform who shares an id", async () => { const handlers = buttonHandlers( ConfirmWrite({ action: "Gmail send email", approver: "teams:U1" }), diff --git a/app/human-in-the-loop/confirm-write.tsx b/app/human-in-the-loop/confirm-write.tsx index 350e1e9..3062e0d 100644 --- a/app/human-in-the-loop/confirm-write.tsx +++ b/app/human-in-the-loop/confirm-write.tsx @@ -170,7 +170,14 @@ async function refuseWrongApprover( approver: string | undefined, ): Promise { if (!approver) return false; - if (`${interaction.platform}:${interaction.actor?.id ?? ""}` === approver) { + // Spelled the way `composio_tools.state.actor_key` spells it in the agent, + // which is what wrote `approver`: platform lowercased, both halves trimmed. + // Comparing raw strings let a surface reporting "Slack" miss `slack:U1`, and + // the only person entitled to answer the card would have been refused by it. + const clicker = `${interaction.platform.trim().toLowerCase()}:${ + interaction.actor?.id?.trim() ?? "" + }`; + if (clicker === approver) { return false; } await interaction.thread.postEphemeral( diff --git a/app/tools/__tests__/composio-connect.test.ts b/app/tools/__tests__/composio-connect.test.ts index ac9fe75..ad6541e 100644 --- a/app/tools/__tests__/composio-connect.test.ts +++ b/app/tools/__tests__/composio-connect.test.ts @@ -17,6 +17,7 @@ const base = { agentUrl: "http://agent.internal:8123/", agentAuthHeader: "Bearer s3cret", actorId: "U1", + actorKind: "human", platform: "slack", toolkit: "gmail", }; @@ -51,6 +52,9 @@ describe("requestConnectLink", () => { .calls[0]!; expect(JSON.parse((init as RequestInit).body as string)).toEqual({ actor_id: "U1", + // The agent mints nothing for a bot or an app, and only this side knows + // what clicked. Omitting it would make every connection anonymous. + kind: "human", platform: "slack", toolkit: "gmail", }); diff --git a/app/tools/__tests__/connect-click.test.tsx b/app/tools/__tests__/connect-click.test.tsx index 15d10ee..f1e8aed 100644 --- a/app/tools/__tests__/connect-click.test.tsx +++ b/app/tools/__tests__/connect-click.test.tsx @@ -3,7 +3,7 @@ import { handleConnectClick } from "../connect-click.js"; const LINK = "https://backend.composio.dev/connect/abc123"; -function interaction(actor: { id: string } | undefined) { +function interaction(actor: { id: string; kind: string } | undefined) { // Typed parameters, not a cast: the assertions below read the recorded // arguments, and an untyped mock records an empty tuple. const postEphemeral = vi.fn( @@ -32,28 +32,46 @@ const environment = { describe("handleConnectClick", () => { it("mints for whoever clicked, not for whoever the card was posted to", async () => { const request = vi.fn(async () => ({ ok: true as const, url: LINK })); - const { ctx } = interaction({ id: "U2" }); + const { ctx } = interaction({ id: "U2", kind: "human" }); await handleConnectClick("gmail", ctx, { environment, request }); expect(request).toHaveBeenCalledWith( - expect.objectContaining({ actorId: "U2", platform: "slack", toolkit: "gmail" }), + expect.objectContaining({ + actorId: "U2", + actorKind: "human", + platform: "slack", + toolkit: "gmail", + }), + ); + }); + + it("reports what clicked rather than asserting it was a person", async () => { + // The agent is the one gate on this, and it can only refuse what it is + // told. Sending a fixed "human" would hand a bot a link to a real account. + const request = vi.fn(async () => ({ ok: true as const, url: LINK })); + const { ctx } = interaction({ id: "B1", kind: "bot" }); + + await handleConnectClick("gmail", ctx, { environment, request }); + + expect(request).toHaveBeenCalledWith( + expect.objectContaining({ actorId: "B1", actorKind: "bot" }), ); }); it("delivers the link to that person alone", async () => { const request = vi.fn(async () => ({ ok: true as const, url: LINK })); - const { ctx, postEphemeral } = interaction({ id: "U2" }); + const { ctx, postEphemeral } = interaction({ id: "U2", kind: "human" }); await handleConnectClick("gmail", ctx, { environment, request }); expect(postEphemeral).toHaveBeenCalledTimes(1); - expect(postEphemeral.mock.calls[0]![0]).toEqual({ id: "U2" }); + expect(postEphemeral.mock.calls[0]![0]).toEqual({ id: "U2", kind: "human" }); }); it("never falls back to a DM, because a link must not follow someone elsewhere", async () => { const request = vi.fn(async () => ({ ok: true as const, url: LINK })); - const { ctx, postEphemeral } = interaction({ id: "U2" }); + const { ctx, postEphemeral } = interaction({ id: "U2", kind: "human" }); await handleConnectClick("gmail", ctx, { environment, request }); @@ -76,7 +94,7 @@ describe("handleConnectClick", () => { ok: false as const, message: "Shared apps are connected by an operator.", })); - const { ctx, postEphemeral } = interaction({ id: "U2" }); + const { ctx, postEphemeral } = interaction({ id: "U2", kind: "human" }); await handleConnectClick("linear", ctx, { environment, request }); diff --git a/app/tools/composio-connect.ts b/app/tools/composio-connect.ts index f35dfe8..cdc9742 100644 --- a/app/tools/composio-connect.ts +++ b/app/tools/composio-connect.ts @@ -13,6 +13,11 @@ export interface ConnectRequestInput { agentUrl: string; agentAuthHeader?: string; actorId: string; + /** + * The clicker's `ProviderActor.kind`. Sent because the agent refuses to mint + * a link for anything but a person, and only this side knows what clicked. + */ + actorKind: string; platform: string; toolkit: string; fetchImpl?: typeof fetch; @@ -36,6 +41,7 @@ export async function requestConnectLink({ agentUrl, agentAuthHeader, actorId, + actorKind, platform, toolkit, fetchImpl = fetch, @@ -59,7 +65,12 @@ export async function requestConnectLink({ "content-type": "application/json", authorization: agentAuthHeader, }, - body: JSON.stringify({ actor_id: actorId, platform, toolkit }), + body: JSON.stringify({ + actor_id: actorId, + kind: actorKind, + platform, + toolkit, + }), }); } catch { // The reason is a network detail; the person can only retry either way. diff --git a/app/tools/connect-click.tsx b/app/tools/connect-click.tsx index 1007c01..fe8b192 100644 --- a/app/tools/connect-click.tsx +++ b/app/tools/connect-click.tsx @@ -49,6 +49,7 @@ export async function handleConnectClick( agentUrl: environment.agentUrl, agentAuthHeader: environment.agentAuthHeader, actorId: actor.id, + actorKind: actor.kind, platform: interaction.platform, toolkit, }); diff --git a/setup.md b/setup.md index 19bdcd6..f75a8c5 100644 --- a/setup.md +++ b/setup.md @@ -415,6 +415,23 @@ older pin the actor never arrives, so every turn reads as anonymous: shared toolkits work, personal ones silently offer nothing. Check the pin in `package.json` before debugging anything else. +That forwarded value is the only thing the agent will treat as an identity, and +four rules follow from it. They fail closed — each one costs access to a +personal toolkit and none of them grants it: + +- A `channelActor` in a request's own `state` is discarded. The AG-UI adapter + merges caller state *over* forwarded properties, so without this the body + would decide whose account a turn runs in. +- A turn that forwards nobody is anonymous, and clears whoever spoke last. The + graph is checkpointed per thread, so an inherited actor would let a second + person in a Slack thread act as the first. +- Only `slack` and `teams` are recognised surfaces. Adding one means adding it + to `KNOWN_PLATFORMS` in `agent/composio_tools/state.py`; until then its turns + read as anonymous rather than sharing a namespace with everybody else's. +- Only `kind: "human"` gets a personal identity. A `bot`, `app` or `system` + actor — a workflow posting on somebody's behalf — reaches the shared toolkits + and no personal one, and cannot be minted a connect link. + Personal toolkits need two more things: - **`AGENT_AUTH_HEADER`, on both services.** The runtime asks the agent to mint From 2696484af7ec202a49778845a069165780c64391 Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 2 Sep 2026 18:40:25 +0200 Subject: [PATCH 11/23] test: make the auth, deployment, and packaging tests able to fail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every defect in this change shipped green because the tests covering it could not go red. Nine mutations of production code that a reviewer applied by hand left the suites passing; each is now caught. Auth (178/179). Deleting the entire `require_shared_secret` middleware passed 277 Python tests, because the only HTTP-level assertions went through the connect route, which refuses on its own account. The middleware is now asserted against a path nothing routes: 401 there can only have come from the middleware, and 404 proves a good secret reached routing. Swapping `compare_digest` for `==` also passed, since the two agree on every answer — the call is asserted instead, arguments and all. The same root cause on the runtime side: `app/index.ts` could stop sending `Authorization` and `readEnvironment` could stop reading `AGENT_AUTH_HEADER` with all 261 TypeScript tests green. The agent factory is extracted so the header it puts on the wire is observable, and both halves are now asserted. Found while hardening `header_matches`: `compare_digest` raises `TypeError` on non-ASCII `str` rather than returning False, and headers arrive latin-1 decoded, so `Authorization: Bearer café` answered 500 instead of 401. It compares bytes now. Deployment (160-166). The whole Composio, shared-secret and Slack secret wiring could be deleted from the CDK stack with 11/11 passing, and `optionalEnvironment` could stop being optional unnoticed. Both are now asserted as complete sets rather than by membership. On Railway, the Composio API key could be added to the internet-facing runtime service, and PORT and DAYTONA_API_KEY could be dropped, without a failure; variable names are asserted exhaustively per service. The evaluator's failure branch was dead — a non-zero exit threw before the diagnostics assertions ran — and its bin path was resolved from `process.cwd()`. `.railway/railway.ts` now type-checks. Packaging (128-137). The Dockerfile guard passed on a commented-out COPY, the loop passed vacuously when the glob matched nothing, the depth-one scan missed the nested subpackage that is the exact failure it claims to catch, `startswith("httpx")` was satisfied by `httpx-sse`, and adding `scripts/__init__.py` would have forced developer tooling into the wheel. Derivations keep a floor under them and name what does not ship. Co-Authored-By: Claude Opus 5 (1M context) --- agent/agent_auth.py | 19 +++- agent/tests/test_agent_auth.py | 114 ++++++++++++++++++++ agent/tests/test_packaging.py | 124 ++++++++++++++++------ app/env.test.ts | 51 +++++++-- app/index.ts | 21 +++- app/railway.test.ts | 115 ++++++++++++++++++-- app/server.test.ts | 62 ++++++++--- deployment/aws/test/opentag-stack.test.ts | 121 +++++++++++++++++++++ tsconfig.json | 3 +- 9 files changed, 562 insertions(+), 68 deletions(-) diff --git a/agent/agent_auth.py b/agent/agent_auth.py index 61ec166..40af9f0 100644 --- a/agent/agent_auth.py +++ b/agent/agent_auth.py @@ -34,6 +34,23 @@ def configured_secret(env: Mapping[str, str] | None = None) -> str | None: return (source.get("AGENT_AUTH_HEADER") or "").strip() or None +def _comparable(value: str) -> bytes: + """ + The bytes `compare_digest` will accept for `value`. + + `compare_digest` refuses non-ASCII `str` outright — it raises `TypeError` + rather than returning `False`. An `Authorization` header reaches us latin-1 + decoded, so a single accented character in a wrong secret would have crashed + the comparison into a 500 instead of the 401 it deserves. Bytes always + compare. + + `surrogateescape` because the expected value comes from `os.environ`, which + decodes with it: a byte the locale could not decode round-trips instead of + raising here. + """ + return value.encode("utf-8", "surrogateescape") + + def header_matches(presented: str | None, expected: str) -> bool: """ Whether a presented header is the configured secret. @@ -44,7 +61,7 @@ def header_matches(presented: str | None, expected: str) -> bool: """ if not presented: return False - return hmac.compare_digest(presented.strip(), expected) + return hmac.compare_digest(_comparable(presented.strip()), _comparable(expected)) def is_authorized( diff --git a/agent/tests/test_agent_auth.py b/agent/tests/test_agent_auth.py index cef18fc..81e5967 100644 --- a/agent/tests/test_agent_auth.py +++ b/agent/tests/test_agent_auth.py @@ -2,6 +2,12 @@ from __future__ import annotations +import hmac + +import pytest +from fastapi.testclient import TestClient + +import agent_auth from agent_auth import authorizes_capability, header_matches, is_authorized @@ -51,3 +57,111 @@ def test_surrounding_whitespace_does_not_change_a_match(): assert header_matches(" Bearer s3cret ", "Bearer s3cret") is True assert header_matches("", "Bearer s3cret") is False assert header_matches(None, "Bearer s3cret") is False + + +def test_the_secret_is_compared_in_constant_time(monkeypatch): + # `==` and `compare_digest` agree on every answer, so no assertion on a + # return value can tell them apart. The call is asserted instead: swapping + # in `==` leaves `calls` empty. + calls: list[tuple[bytes, bytes]] = [] + real = hmac.compare_digest + + def spy(left, right): + calls.append((left, right)) + return real(left, right) + + monkeypatch.setattr(agent_auth.hmac, "compare_digest", spy) + + assert header_matches("Bearer s3cret", "Bearer s3cret") is True + assert header_matches("Bearer wrong!", "Bearer s3cret") is False + + assert calls == [ + (b"Bearer s3cret", b"Bearer s3cret"), + (b"Bearer wrong!", b"Bearer s3cret"), + ] + + +def test_a_non_ascii_header_is_a_refusal_and_not_a_crash(): + # Headers arrive latin-1 decoded and `compare_digest` raises `TypeError` on + # non-ASCII `str` rather than returning False, so an accent in a wrong + # secret used to become a 500. Refusing is the only correct answer. + accented = "Bearer caf\xe9" + env = {"AGENT_AUTH_HEADER": "Bearer s3cret"} + + assert header_matches(accented, "Bearer s3cret") is False + assert is_authorized("/", accented, env=env) is False + assert authorizes_capability(accented, env=env) is False + + +@pytest.fixture +def client(monkeypatch): + monkeypatch.setenv("OPENAI_API_KEY", "sk-test") + import main + + # Server errors are surfaced as 500s rather than re-raised, so a crash in + # the middleware reads as the wrong status code instead of an error that + # could be mistaken for an unrelated failure. + return TestClient(main.app, raise_server_exceptions=False) + + +def test_the_middleware_refuses_traffic_that_carries_no_secret(client, monkeypatch): + # `/nope` is routed by nothing, so 401 can only have come from the + # middleware. Asserting on a real route cannot tell "the middleware + # refused" from "the route refused", which is how deleting the middleware + # outright went unnoticed. + monkeypatch.setenv("AGENT_AUTH_HEADER", "Bearer s3cret") + + assert client.get("/nope").status_code == 401 + wrong = client.get("/nope", headers={"Authorization": "Bearer wrong"}) + assert wrong.status_code == 401 + + +def test_the_middleware_lets_the_configured_secret_reach_routing( + client, monkeypatch +): + # 404, not 401: the request got past the middleware and found no route. + monkeypatch.setenv("AGENT_AUTH_HEADER", "Bearer s3cret") + + response = client.get("/nope", headers={"Authorization": "Bearer s3cret"}) + + assert response.status_code == 404 + + +def test_the_middleware_stays_open_when_no_secret_is_configured(client, monkeypatch): + # A local `pnpm dev` has no secret, and enforcing unconditionally would take + # every existing deployment down on upgrade. + monkeypatch.delenv("AGENT_AUTH_HEADER", raising=False) + + assert client.get("/nope").status_code == 404 + + +def test_the_middleware_keeps_health_open_for_the_platform_probe(client, monkeypatch): + monkeypatch.setenv("AGENT_AUTH_HEADER", "Bearer s3cret") + + assert client.get("/health").status_code == 200 + + +def test_the_middleware_guards_the_agent_endpoint_itself(client, monkeypatch): + # The point of the middleware. Unauthenticated it is 401; let it through and + # the AG-UI endpoint answers 422 for this body, so the two are distinct. + monkeypatch.setenv("AGENT_AUTH_HEADER", "Bearer s3cret") + + assert client.post("/", json={}).status_code == 401 + allowed = client.post( + "/", json={}, headers={"Authorization": "Bearer s3cret"} + ) + assert allowed.status_code == 422 + + +def test_the_middleware_refuses_a_non_ascii_header_without_erroring( + client, monkeypatch +): + # Sent as the latin-1 bytes a real client puts on the wire; httpx refuses to + # encode the `str` form. A 500 here is the crash this guards against. + monkeypatch.setenv("AGENT_AUTH_HEADER", "Bearer s3cret") + + response = client.get( + "/nope", headers={"Authorization": "Bearer caf\xe9".encode("latin-1")} + ) + + assert response.status_code == 401 diff --git a/agent/tests/test_packaging.py b/agent/tests/test_packaging.py index a5dd7a9..a02f6ff 100644 --- a/agent/tests/test_packaging.py +++ b/agent/tests/test_packaging.py @@ -1,52 +1,116 @@ +import re import tomllib from pathlib import Path +AGENT_ROOT = Path(__file__).resolve().parent.parent +REPO_ROOT = Path(__file__).resolve().parents[2] -def test_wheel_includes_every_runtime_module(): - agent_root = Path(__file__).resolve().parent.parent - project = tomllib.loads((agent_root / "pyproject.toml").read_text()) - packaged_modules = set(project["tool"]["setuptools"]["py-modules"]) - runtime_modules = { +# Directories that sit beside the runtime code and must never reach the wheel or +# the image. Named, rather than left to a denylist that happened to be right: +# the derivation below reads "every package on disk ships", so a `scripts` or a +# `tests` package would otherwise make this file demand that developer tooling +# be installed into site-packages. +NOT_SHIPPED_PACKAGES = frozenset({".venv", "scripts", "tests"}) + +# Same, one level up. A `conftest.py` at the agent root is pytest scaffolding, +# not a runtime module, and the wheel has no business carrying it. +NOT_SHIPPED_MODULES = frozenset({"conftest"}) + +# A floor under every derived set below. Deriving from disk is what keeps these +# assertions honest for the next person to add a module, but a derived set only +# asserts something while it has something in it: a glob that matches nothing — +# a moved test file, a renamed layout — turns every comparison here into +# `set() == set()`. These names must appear whatever the glob does. +KNOWN_MODULES = frozenset({"agent", "agent_auth", "main"}) +KNOWN_PACKAGE_ROOTS = frozenset({"coding", "composio_tools", "prompts"}) + + +def runtime_modules() -> set[str]: + """Every top-level module on disk that the wheel has to carry.""" + modules = { path.stem - for path in agent_root.glob("*.py") - if path.name != "__init__.py" + for path in AGENT_ROOT.glob("*.py") + if path.name != "__init__.py" and path.stem not in NOT_SHIPPED_MODULES + } + assert KNOWN_MODULES <= modules, f"module discovery is broken: {modules}" + return modules + + +def package_roots() -> set[str]: + """The top-level packages. What the image copies, one directory at a time.""" + roots = { + path.parent.name + for path in AGENT_ROOT.glob("*/__init__.py") + if path.parent.name not in NOT_SHIPPED_PACKAGES } + assert KNOWN_PACKAGE_ROOTS <= roots, f"package discovery is broken: {roots}" + return roots - assert packaged_modules == runtime_modules + +def runtime_packages() -> set[str]: + """ + Every package setuptools has to be named, nested ones included. + + `packages` is an explicit list and setuptools does not walk it: naming + `composio_tools` does not carry `composio_tools.adapters`, which then + imports fine from a source checkout and is missing from the wheel. A + depth-one scan is that exact failure, so this one goes all the way down. + """ + return { + ".".join(path.parent.relative_to(AGENT_ROOT).parts) + for root in package_roots() + for path in (AGENT_ROOT / root).rglob("__init__.py") + } + + +def declared_dependencies() -> dict[str, str]: + """Each declared dependency's distribution name mapped to its full requirement.""" + project = tomllib.loads((AGENT_ROOT / "pyproject.toml").read_text()) + declared = {} + for requirement in project["project"]["dependencies"]: + name = re.split(r"[\s\[<>=!~;(]", requirement, maxsplit=1)[0] + declared[name.strip().lower().replace("_", "-")] = requirement + return declared + + +def test_wheel_includes_every_runtime_module(): + project = tomllib.loads((AGENT_ROOT / "pyproject.toml").read_text()) + + assert set(project["tool"]["setuptools"]["py-modules"]) == runtime_modules() # Derived, not listed. A hardcoded list passes for whoever wrote it and # fails the next person to add a package, which is backwards: the point is # to catch a package that exists on disk and never reaches the wheel. - runtime_packages = { - path.parent.name - for path in agent_root.glob("*/__init__.py") - if path.parent.name not in {"tests", ".venv"} - } - assert set(project["tool"]["setuptools"]["packages"]) == runtime_packages + assert set(project["tool"]["setuptools"]["packages"]) == runtime_packages() def test_agent_image_copies_every_runtime_package(): # The image copies packages one line at a time, so a new package imports # fine locally and crashes the container on first import. Derived from disk - # for the same reason as the wheel assertion above. - agent_root = Path(__file__).resolve().parent.parent - repo_root = Path(__file__).resolve().parents[2] + # for the same reason as the wheel assertion above. Nested packages come + # along with their root's directory, so only the roots are checked here. dockerfile = ( - repo_root / "deployment" / "docker" / "agent.Dockerfile" + REPO_ROOT / "deployment" / "docker" / "agent.Dockerfile" ).read_text(encoding="utf-8") - for path in agent_root.glob("*/__init__.py"): - package = path.parent.name - if package in {"tests", ".venv"}: - continue - assert f"COPY agent/{package} ./{package}" in dockerfile + # Anchored and matched as a whole line, because `"COPY agent/x ./x" in text` + # is satisfied by a commented-out COPY. Compared as a set rather than one + # membership check at a time, because equality also catches a COPY left + # behind for a directory that no longer exists — which fails the build. + copied = set( + re.findall(r"^COPY agent/(\S+) \./\1$", dockerfile, flags=re.MULTILINE) + ) + + assert copied == package_roots() def test_coding_dependencies_are_declared(): - agent_root = Path(__file__).resolve().parent.parent - project = tomllib.loads((agent_root / "pyproject.toml").read_text()) - deps = project["project"]["dependencies"] - assert any(dep.startswith("daytona") for dep in deps) - assert any(dep.startswith("langchain-daytona") for dep in deps) - assert any(dep.startswith("httpx") for dep in deps) - assert any(dep.startswith("pyjwt[crypto]") for dep in deps) + declared = declared_dependencies() + + # Whole names, not prefixes: `dep.startswith("httpx")` was satisfied by + # `httpx-sse`, a different distribution that does not provide `httpx`. + assert {"daytona", "langchain-daytona", "httpx", "pyjwt"} <= set(declared) + + # And the extra, not merely the distribution: the coder signs GitHub App + # tokens with `cryptography`, which only the `crypto` extra pulls in. + assert "[crypto]" in declared["pyjwt"] diff --git a/app/env.test.ts b/app/env.test.ts index 16cbd2c..2370663 100644 --- a/app/env.test.ts +++ b/app/env.test.ts @@ -83,9 +83,21 @@ describe("readEnvironment", () => { ).toMatchObject({ agentDisplayName: "Kite" }); }); - it("does not expose platform credentials owned by Intelligence", () => { - // Both Slack tokens or neither: one alone is now a configuration error, and - // the pair is read into `slackDirect` rather than into flat fields. + it("reads the shared secret the runtime presents to the agent", () => { + // `AGENT_AUTH_HEADER` unread here is `AGENT_AUTH_HEADER` never sent: the + // agent then answers 401 and nothing in this suite noticed. + expect( + readEnvironment({ + ...requiredEnvironment, + AGENT_AUTH_HEADER: "Bearer s3cret", + }), + ).toMatchObject({ agentAuthHeader: "Bearer s3cret" }); + expect(readEnvironment(requiredEnvironment).agentAuthHeader).toBeUndefined(); + }); + + it("puts the Slack pair behind slackDirect and exposes nothing else", () => { + // Both Slack tokens or neither: one alone is a configuration error, and the + // pair is read into `slackDirect` rather than into flat fields. const environment = readEnvironment({ ...requiredEnvironment, SLACK_BOT_TOKEN: "xoxb-unused", @@ -93,9 +105,26 @@ describe("readEnvironment", () => { TEAMS_CLIENT_ID: "teams-unused", }); - expect(environment).not.toHaveProperty("slackBotToken"); - expect(environment).not.toHaveProperty("teamsClientId"); - expect(environment).not.toHaveProperty("teamsPort"); + // The whole key set. `not.toHaveProperty("slackBotToken")` cannot fail for + // a field this type never had, and it says nothing about the field that + // replaced it: dropping `slackDirect` from `readEnvironment` left every + // assertion here green while direct delivery quietly stopped existing. + expect(Object.keys(environment).sort()).toEqual([ + "agentAuthHeader", + "agentDisplayName", + "agentUrl", + "channelName", + "intelligenceApiKey", + "intelligenceApiUrl", + "intelligenceGatewayWsUrl", + "learningContainerId", + "port", + "slackDirect", + ]); + expect(environment.slackDirect).toEqual({ + botToken: "xoxb-unused", + appToken: "xapp-unused", + }); }); }); @@ -133,11 +162,17 @@ describe("readSlackDirect", () => { // One alone cannot start a Socket Mode adapter, and silently ignoring it // would leave the connect button unable to deliver with nothing to explain // why. + // + // Matched against the sentence that names the missing variable, not just + // its name: the message opens with "needs both SLACK_BOT_TOKEN and + // SLACK_APP_TOKEN", so a bare /SLACK_APP_TOKEN/ matches that fixed prefix + // and passes with the two branches swapped — pointing the operator at the + // variable they already set. expect(() => readSlackDirect({ SLACK_BOT_TOKEN: "xoxb-1" })).toThrow( - /SLACK_APP_TOKEN/, + "only SLACK_BOT_TOKEN is set", ); expect(() => readSlackDirect({ SLACK_APP_TOKEN: "xapp-1" })).toThrow( - /SLACK_BOT_TOKEN/, + "only SLACK_APP_TOKEN is set", ); }); diff --git a/app/index.ts b/app/index.ts index 05f3d8e..a1a2ef1 100644 --- a/app/index.ts +++ b/app/index.ts @@ -3,11 +3,16 @@ import { createOpenTagChannel } from "./channel.js"; import { readEnvironment, type AppEnvironment } from "./env.js"; import { createOpenTagRuntime } from "./runtime-host.js"; -export function createOpenTagApplication( - environment: AppEnvironment = readEnvironment(), -) { - // Channels agents are stateful, so each conversation gets its own SDK agent. - const agent = (threadId: string) => { +/** + * One SDK agent per conversation, because Channels agents are stateful. + * + * Exported so the `Authorization` header can be asserted. It is the runtime's + * half of the shared secret — the agent refuses traffic that arrives without it + * — and once the agent is inside a Channel nothing in this process can see what + * was put on the wire, so dropping the header here is otherwise invisible. + */ +export function createAgentFactory(environment: AppEnvironment) { + return (threadId: string) => { const instance = new SanitizingHttpAgent({ url: environment.agentUrl, headers: environment.agentAuthHeader @@ -17,6 +22,12 @@ export function createOpenTagApplication( instance.threadId = threadId; return instance; }; +} + +export function createOpenTagApplication( + environment: AppEnvironment = readEnvironment(), +) { + const agent = createAgentFactory(environment); // Intelligence owns the Slack and Teams adapters for this logical Channel. const channels = [ diff --git a/app/railway.test.ts b/app/railway.test.ts index bf9bf96..84a7ef0 100644 --- a/app/railway.test.ts +++ b/app/railway.test.ts @@ -1,6 +1,21 @@ import { execFileSync } from "node:child_process"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; +// Resolved from this file rather than from `process.cwd()`. The bin path was +// relative, so the run worked only because vitest happens to start at the +// repository root, and failed outright when it started anywhere else. +const repositoryRoot = fileURLToPath(new URL("..", import.meta.url)); +const railwayBin = join( + repositoryRoot, + "node_modules", + "railway", + "dist", + "iac", + "bin.js", +); + interface RailwayVariable { type: "literal" | "preserve"; value?: string; @@ -25,26 +40,53 @@ interface RailwayResource { variables?: Record; } -function evaluateRailwayGraph(): RailwayResource[] { - const stdout = execFileSync( - process.execPath, - ["node_modules/railway/dist/iac/bin.js"], - { - cwd: process.cwd(), +/** The evaluator's report, whatever it exits with. */ +function railwayGraphReport(): string { + try { + return execFileSync(process.execPath, [railwayBin], { + cwd: repositoryRoot, encoding: "utf8", - }, - ); - const result = JSON.parse(stdout) as { + }); + } catch (error) { + // The bin exits 1 when the graph does not evaluate, which `execFileSync` + // turns into a throw — so the two assertions below never ran on the one + // input they exist for, and a bad config surfaced as an exit code with no + // diagnostic attached. Its stdout still carries the report. + const { stdout, stderr } = error as { stdout?: string; stderr?: string }; + if (stdout) return stdout; + throw new Error( + `railway iac could not be run: ${stderr || String(error)}`, + ); + } +} + +function evaluateRailwayGraph(): RailwayResource[] { + const result = JSON.parse(railwayGraphReport()) as { ok: boolean; diagnostics: unknown[]; graph: { resources: RailwayResource[] }; }; - expect(result.ok).toBe(true); expect(result.diagnostics).toEqual([]); + expect(result.ok).toBe(true); return result.graph.resources; } +/** + * Every variable name a service carries, sorted. + * + * `toMatchObject` only reads the keys it is handed, so it is blind to a + * variable that should not be there at all — a credential belonging to one + * service quietly added to the other passes it without complaint. The whole + * name list is compared instead. + */ +function variableNames(resource: RailwayResource | undefined): string[] { + return Object.keys(resource?.variables ?? {}).sort(); +} + describe("Railway deployment graph", () => { + // An explicit timeout. The evaluator spawns a Node process that compiles the + // config, measured between 0.3s and 6.2s depending on machine load, which + // straddles vitest's 5s default and has gone red on unmodified config. it("ships the Python agent and Chromium-capable runtime services", () => { const resources = evaluateRailwayGraph(); expect(resources.map(({ name }) => name).sort()).toEqual(["agent", "runtime"]); @@ -89,6 +131,38 @@ describe("Railway deployment graph", () => { AGENT_AUTH_HEADER: { type: "preserve" }, }); + // The agent holds the Composio key and every source credential; the + // runtime must not. Named exhaustively so a credential added to the wrong + // service is a failure rather than an unread key. + expect(variableNames(agent)).toEqual([ + "AGENT_AUTH_HEADER", + "AGENT_DISPLAY_NAME", + "COMPOSIO_API_KEY", + "COMPOSIO_APPROVALS", + "COMPOSIO_AUTH_CONFIGS", + "COMPOSIO_TOOLKITS", + "COMPOSIO_USER_TOOLKITS", + "COMPOSIO_WORKSPACE_USER_ID", + "DAYTONA_API_KEY", + "DAYTONA_SNAPSHOT", + "DAYTONA_TTL_MINUTES", + "GITHUB_APP_ID", + "GITHUB_APP_INSTALLATION_ID", + "GITHUB_APP_PRIVATE_KEY_BASE64", + "GITHUB_CODER_TOKEN", + "GITHUB_MCP_URL", + "GITHUB_PERSONAL_ACCESS_TOKEN", + "INTELLIGENCE_CHANNEL_NAME", + "LINEAR_API_KEY", + "NOTION_MCP_AUTH_TOKEN", + "NOTION_MCP_URL", + "OPENAI_API_KEY", + "PORT", + "POSTHOG_MCP_URL", + "POSTHOG_PERSONAL_API_KEY", + "TAVILY_API_KEY", + ]); + const runtime = resources.find(({ name }) => name === "runtime"); expect(runtime).toMatchObject({ source: { @@ -138,5 +212,24 @@ describe("Railway deployment graph", () => { }, }, }); - }); + + // The runtime carries the shared secret it presents to the agent and the + // Slack tokens a private connect link needs — and no Composio credential: + // this is the internet-facing service and the toolkits live on the agent. + expect(variableNames(runtime)).toEqual([ + "AGENT_AUTH_HEADER", + "AGENT_DISPLAY_NAME", + "AGENT_URL", + "INTELLIGENCE_API_KEY", + "INTELLIGENCE_API_URL", + "INTELLIGENCE_CHANNEL_NAME", + "INTELLIGENCE_GATEWAY_WS_URL", + "INTELLIGENCE_LEARNING_CONTAINER_ID", + "PLAYWRIGHT_BROWSERS_PATH", + "PORT", + "RAILPACK_DEPLOY_APT_PACKAGES", + "SLACK_APP_TOKEN", + "SLACK_BOT_TOKEN", + ]); + }, 60_000); }); diff --git a/app/server.test.ts b/app/server.test.ts index 5477679..f42be52 100644 --- a/app/server.test.ts +++ b/app/server.test.ts @@ -8,7 +8,7 @@ import { type RuntimeListener, } from "../server.js"; import type { AppEnvironment } from "./env.js"; -import { createOpenTagApplication } from "./index.js"; +import { createAgentFactory, createOpenTagApplication } from "./index.js"; class FakeServer extends EventEmitter implements HttpServerLike { listening = false; @@ -134,19 +134,57 @@ describe("startOpenTagServer", () => { }); }); +const managedEnvironment: AppEnvironment = { + agentDisplayName: "OpenTag", + agentUrl: "http://agent.internal/", + intelligenceApiKey: "cpk-1_test", + intelligenceApiUrl: "https://api.intelligence.test", + intelligenceGatewayWsUrl: "wss://realtime.intelligence.test", + channelName: "open-tag", + port: 3000, +}; + +describe("createAgentFactory", () => { + it("presents the shared secret the agent checks", () => { + // The runtime's half of `AGENT_AUTH_HEADER`. Deleting the header from the + // agent config left all 261 tests in this suite green while every request + // to a secured agent started coming back 401. + const agent = createAgentFactory({ + ...managedEnvironment, + agentAuthHeader: "Bearer agent-secret", + })("thread-1"); + + expect(agent.url).toBe("http://agent.internal/"); + expect(agent.headers).toEqual({ Authorization: "Bearer agent-secret" }); + }); + + it("sends no Authorization at all when no secret is configured", () => { + // A local run has no secret and the agent lets unauthenticated traffic + // through; sending an empty or literal-undefined header instead would be a + // request the agent has to decide about. + const agent = createAgentFactory({ + ...managedEnvironment, + agentAuthHeader: undefined, + })("thread-1"); + + expect(agent.headers).toEqual({}); + }); + + it("gives each conversation its own agent, bound to its thread", () => { + // Channels agents are stateful, so a shared instance would cross threads. + const factory = createAgentFactory(managedEnvironment); + const first = factory("thread-1"); + const second = factory("thread-2"); + + expect(first.threadId).toBe("thread-1"); + expect(second.threadId).toBe("thread-2"); + expect(first).not.toBe(second); + }); +}); + describe("createOpenTagApplication", () => { it("declares one adapter-free managed Channel", () => { - const environment: AppEnvironment = { - agentDisplayName: "OpenTag", - agentUrl: "http://agent.internal/", - intelligenceApiKey: "cpk-1_test", - intelligenceApiUrl: "https://api.intelligence.test", - intelligenceGatewayWsUrl: "wss://realtime.intelligence.test", - channelName: "open-tag", - port: 3000, - }; - - const application = createOpenTagApplication(environment); + const application = createOpenTagApplication(managedEnvironment); expect( application.channels.map((channel) => ({ diff --git a/deployment/aws/test/opentag-stack.test.ts b/deployment/aws/test/opentag-stack.test.ts index 84bf0b3..3db4e38 100644 --- a/deployment/aws/test/opentag-stack.test.ts +++ b/deployment/aws/test/opentag-stack.test.ts @@ -7,6 +7,57 @@ import * as ecs from "aws-cdk-lib/aws-ecs"; import { OpenTagInfrastructureStack } from "../lib/opentag-infrastructure-stack.js"; import { OpenTagStack } from "../lib/opentag-stack.js"; +interface ContainerDefinition { + Environment?: { Name: string; Value: string }[]; + Name: string; + Secrets?: { Name: string; ValueFrom: unknown }[]; +} + +/** The one task definition's container called `name`. */ +function containerDefinition( + template: Template, + name: string, +): ContainerDefinition { + const taskDefinitions = Object.values( + template.findResources("AWS::ECS::TaskDefinition"), + ) as { Properties: { ContainerDefinitions: ContainerDefinition[] } }[]; + assert.equal(taskDefinitions.length, 1); + const container = taskDefinitions[0]?.Properties.ContainerDefinitions.find( + (candidate) => candidate.Name === name, + ); + assert.ok(container, `no ${name} container in the task definition`); + return container; +} + +/** What a container's secret for `key` must resolve to: the shared secret's field, by reference. */ +function secretsManagerField(key: string): unknown { + return { + "Fn::Join": ["", [{ Ref: "OpenTagSecretArn" }, `:${key}::`]], + }; +} + +/** A container's secrets keyed by name, so the comparison ignores declaration order. */ +function secretsByName( + template: Template, + name: string, +): Record { + return Object.fromEntries( + (containerDefinition(template, name).Secrets ?? []).map( + ({ Name, ValueFrom }) => [Name, ValueFrom], + ), + ); +} + +function expectedSecrets(keys: string[]): Record { + return Object.fromEntries(keys.map((key) => [key, secretsManagerField(key)])); +} + +function environmentNames(template: Template, name: string): string[] { + return (containerDefinition(template, name).Environment ?? []) + .map(({ Name }) => Name) + .sort(); +} + function stackWithContext( context: Record = {}, shared = false, @@ -103,6 +154,76 @@ test("creates one private rolling environment service containing both containers }); }); +test("injects each container's secrets from the shared secret, and no others", () => { + // Asserted as the whole set rather than one membership check at a time. The + // suite already had a `assert.match(json, /OPENAI_API_KEY/)` style check, and + // it passes just as happily with the entire Composio, shared-secret and Slack + // wiring deleted — which is how that wiring shipped with no coverage at all. + const template = Template.fromStack(stackWithContext()); + + assert.deepEqual( + secretsByName(template, "agent"), + expectedSecrets([ + "OPENAI_API_KEY", + // The agent owns the Composio session, so the key and the shared secret + // it checks both belong to this container and not the runtime. + "COMPOSIO_API_KEY", + "AGENT_AUTH_HEADER", + "TAVILY_API_KEY", + "DAYTONA_API_KEY", + "GITHUB_PERSONAL_ACCESS_TOKEN", + "GITHUB_CODER_TOKEN", + "POSTHOG_PERSONAL_API_KEY", + "LINEAR_API_KEY", + "NOTION_MCP_AUTH_TOKEN", + ]), + ); + assert.deepEqual( + secretsByName(template, "runtime"), + expectedSecrets([ + "INTELLIGENCE_API_KEY", + // Presented to the agent; the agent checks it. Both sides read the same + // field of the same secret or the runtime cannot reach the agent at all. + "AGENT_AUTH_HEADER", + // Only so a Composio connect link can reach one person privately. + "SLACK_BOT_TOKEN", + "SLACK_APP_TOKEN", + ]), + ); +}); + +test("leaves optional settings out of the container until context supplies them", () => { + // The whole name list, because the failure this guards against is an + // `optionalEnvironment` that stops being optional: `COMPOSIO_APPROVALS=""` + // reaching the agent is not the same as it being absent, and every + // `arrayWith` assertion in this file is blind to a key that should not exist. + const template = Template.fromStack(stackWithContext()); + + assert.deepEqual(environmentNames(template, "agent"), [ + "AGENT_DISPLAY_NAME", + "CORS_ALLOW_ORIGINS", + "DAYTONA_TTL_MINUTES", + "GITHUB_MCP_URL", + "LINEAR_MCP_URL", + "OPENAI_MODEL", + "OPENAI_REASONING_EFFORT", + "OPENAI_VERBOSITY", + "POSTHOG_MCP_URL", + "SERVER_HOST", + "SERVER_PORT", + ]); + assert.deepEqual(environmentNames(template, "runtime"), [ + "AGENT_DISPLAY_NAME", + "AGENT_URL", + "INTELLIGENCE_API_URL", + "INTELLIGENCE_CHANNEL_NAME", + "INTELLIGENCE_GATEWAY_WS_URL", + "LOG_LEVEL", + "PLAYWRIGHT_BROWSERS_PATH", + "PORT", + ]); +}); + test("allows supported non-secret environment overrides through context", () => { const template = Template.fromStack( stackWithContext({ diff --git a/tsconfig.json b/tsconfig.json index 90cd3aa..0aea939 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -20,7 +20,8 @@ "app/**/*.ts", "app/**/*.tsx", "server.ts", - "scripts/**/*.ts" + "scripts/**/*.ts", + ".railway/railway.ts" ], "exclude": ["node_modules", "e2e"] } From 560f586a593c56b4a04b683f22dc22cd923e5a9c Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 2 Sep 2026 18:43:49 +0200 Subject: [PATCH 12/23] fix(composio): make the approval gate actually gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gate let through the case it exists for. A tool Composio found and said nothing about was classified `write`, and the default approval mode gates only `destructive` — so an unclassified action ran against a real account with no card, and the verdict was cached so it kept doing so. The documentation already promised the opposite. Seven things, all on the same path from "what does this tool do" to "who said yes": - Unclassified is unclassified. `effect_of` answers `None` when the tags claim nothing, and `EffectMap` turns that into `destructive`. Only a positive answer is cached: the fail-safe one is a statement about what is not known, and freezing it would outlive the day Composio tags the tool. - A hint is read by value, not by name. Tags arriving as a mapping had their keys read as claims, so `{"readOnlyHint": False}` — a tool saying it is not read-only — read as read-only and skipped the gate entirely. A bare string is no longer iterated into characters either. - Sessions are created with `manage_connections=False`. It defaults to True, and left on the session carries tools that link accounts — a second path around the connect flow, which is the only one that binds a connection to an actor the platform verified. `authorize()` mints links over the session's own REST endpoint and does not read this flag, so the operator connect script and the Connect button are unaffected. - The classified effect reaches the card. It was parsed by the schema and then dropped, leaving the card to guess from the action's first word — which for a Composio action is the name of the app. So every Gmail action rendered a confirm button reading "Gmail", and the red sat on Cancel while the irreversible button looked like the safe one. The effect now forwards, and `humanize_slug` leads with the verb: `GMAIL_SEND_EMAIL` renders as `Send email (Gmail)`. - A wrong-approver refusal is delivered. `postEphemeral` resolves to `null` on a surface with no ephemeral message — which the managed adapter is — so the notice was silently dropped and the person saw a card that did nothing when clicked. It now falls back to a DM and then to the thread; the notice names nobody, so a public fallback leaks no account. - A card naming `unknown:` is answerable. That prefix is what the agent writes when the turn carried no platform, and no surface can ever produce it, so the right person was refused too and the graph paused for good. The id is then the only thing both sides know and it is what gets compared; a different id is still refused. - Buttons answer once, and an answer already given is not thrown away. Both buttons close over one flag, so a double press — or approve then cancel — resumes once instead of resuming a graph that is no longer paused. A failed card update no longer aborts before the resume: the card is the receipt, not the decision. Call sites of everything touched: - `classify.effect_of` (signature `Iterable[str] | None -> str` becomes `Any -> str | None`): `composio_tools/effects.py:effect_for`, `tests/test_composio_classify.py`. No others. - `classify.needs_approval`: unchanged. `composio_tools/tools.py:274`, `tests/test_composio_classify.py`. - `classify.READ_ONLY_HINT`, `classify.DESTRUCTIVE_HINT`, `classify._claimed_hints`: new, used only inside `classify.py`. - `classify.READ`/`WRITE`/`DESTRUCTIVE`: unchanged names. `DESTRUCTIVE` imported by `effects.py`; `READ` used by `needs_approval`; `WRITE` is the vocabulary the card's `effect` field and `writes` mode are defined over, and tags alone cannot produce it. - `EffectMap.effect_for`: unchanged signature. `composio_tools/tools.py`, `composio_tools/runtime.py` (construction), tests. - `SessionCache.for_scope`: unchanged signature, one more keyword passed to `sessions.create`. `SessionCache.resolve`, tests. - `tools.humanize_slug`: unchanged signature, changed output. `composio_tools/tools.py:282` and `:296`, `tests/test_composio_tools.py`. - `interrupt.ts` schema (`fields`, `attempt` now nullish): `parseConfirmWriteInterrupt` -> `app/channel.tsx:144`, `interrupt.test.ts`. - `ConfirmWriteProps.effect`: new optional prop. `app/channel.tsx:146` (the only production caller), the `components:` registry at `app/channel.tsx:63`, `human-in-the-loop/index.ts` re-export, and both card test files. - `resumeOrShowFailure` (new fifth `reopen` parameter): module-private, one caller. - `isNamedApprover`, `tellOrPost`, `WRONG_APPROVER_NOTICE`: new, module-private, called from `refuseWrongApprover` / `tellOrPost`. - `refuseWrongApprover`: unchanged signature; now called once from the shared `answer` helper rather than from each button. - `reportRecoverableError` from `app/channel-helpers.ts`: one more importer. Two existing assertions changed on purpose. "does not resume approval when the status update fails" asserted the decision-losing behaviour and is now "does not lose an approved decision when the card update fails"; the refusal's `fallbackToDM` moves from `false` to `true`, because unlike a connect link the notice is not a bearer capability. `FakeEffects` defaulted to `read`, the inverse of production's fail-safe, which let a test walk past a gate the real thing would have closed. It now defaults to `destructive`; the three tests that are about something else pass `default="read"` and say why. --- agent/composio_tools/classify.py | 69 +++++-- agent/composio_tools/effects.py | 29 ++- agent/composio_tools/sessions.py | 11 ++ agent/composio_tools/tools.py | 15 +- agent/tests/test_composio_classify.py | 107 ++++++++++- agent/tests/test_composio_connect.py | 5 +- agent/tests/test_composio_tools.py | 128 ++++++++++++- app/channel.test.ts | 146 +++++++++++++++ app/channel.tsx | 8 +- .../__tests__/confirm-write-approver.test.tsx | 113 ++++++++++- .../__tests__/confirm-write.test.tsx | 108 +++++++++-- app/human-in-the-loop/confirm-write.tsx | 176 ++++++++++++++---- app/interrupt.test.ts | 27 +++ app/interrupt.ts | 14 +- setup.md | 17 +- 15 files changed, 873 insertions(+), 100 deletions(-) diff --git a/agent/composio_tools/classify.py b/agent/composio_tools/classify.py index 6c7c544..3fd8ef6 100644 --- a/agent/composio_tools/classify.py +++ b/agent/composio_tools/classify.py @@ -1,28 +1,75 @@ """Effect classification from Composio's MCP behaviour tags. -A tool's tags are a plain list of strings that defaults to empty, so an empty -list cannot be told apart from "nobody classified this". Anything not positively -marked read-only is therefore treated as a write, which fails safe and matches -how `internal_sources.py` already treats an unclassified MCP tool. +The vocabulary is MCP's: `readOnlyHint`, `destructiveHint`, `idempotentHint`, +`openWorldHint`. Composio carries them as tag names on a tool, and its own +session filters accept the same four literals. + +Two things this module refuses to do, both of which read as safe and are not: + +* Treat "nobody said" as "nothing dangerous". `effect_of` answers `None` when + the tags claim nothing, and the caller decides — `EffectMap` gates it. The + default approval mode gates destructive calls only, so calling an + unclassified tool a write is indistinguishable from not gating it at all. +* Read a hint's *name* as its *value*. When the tags arrive as a mapping, + `{"readOnlyHint": False}` is a tool saying it is **not** read-only; the word + being present says nothing on its own. """ from __future__ import annotations -from collections.abc import Iterable +from collections.abc import Mapping +from typing import Any READ = "read" +#: A change that is not destructive. `needs_approval` still gates it under +#: `writes`. Reachable from a caller that classifies by other means (the MCP +#: interceptor's `readOnlyHint` metadata); the tag vocabulary itself cannot +#: distinguish a plain write from an unclassified tool, and this module does +#: not guess. WRITE = "write" DESTRUCTIVE = "destructive" +READ_ONLY_HINT = "readOnlyHint" +DESTRUCTIVE_HINT = "destructiveHint" + + +def _claimed_hints(tags: Any) -> frozenset[str]: + """The hints these tags positively assert, as names. + + A mapping is read by value, because that is the shape that carries one: a + hint set to `False` asserts the opposite of what its key looks like, and + only `True` — not merely truthy — is an assertion, since MCP hints are + booleans. + + A `str` is not treated as a one-element tag list. Iterating one yields + characters, and a shape nobody meant to send must not be able to talk this + module down to `read`. + """ + if isinstance(tags, Mapping): + return frozenset( + str(name) for name, value in tags.items() if value is True + ) + if tags is None or isinstance(tags, (str, bytes)): + return frozenset() + try: + return frozenset(tag for tag in tags if isinstance(tag, str)) + except TypeError: + # Not iterable. Same answer as no tags: nothing was claimed. + return frozenset() + + +def effect_of(tags: Any) -> str | None: + """The effect these tags claim, or `None` when they claim nothing. -def effect_of(tags: Iterable[str] | None) -> str: - """The effect a tool's tags claim, erring towards the more dangerous read.""" - present = set(tags or ()) - if "destructiveHint" in present: + `None` is not "safe" and not "write" — it is "unclassified", and the caller + is the one that turns it into a gate. + """ + claimed = _claimed_hints(tags) + if DESTRUCTIVE_HINT in claimed: return DESTRUCTIVE - if "readOnlyHint" in present: + if READ_ONLY_HINT in claimed: return READ - return WRITE + return None def needs_approval(effect: str, mode: str) -> bool: diff --git a/agent/composio_tools/effects.py b/agent/composio_tools/effects.py index 81e9da5..54cd054 100644 --- a/agent/composio_tools/effects.py +++ b/agent/composio_tools/effects.py @@ -31,11 +31,18 @@ def effect_for(self, slug: str) -> str: """ The effect of one slug, erring towards the dangerous reading. - A slug that cannot be looked up is destructive, not a write. `writes` - mode gates both, but `destructive` mode — the default — gates only the - first, so calling an unrecognised slug a write would run it unapproved - in the mode most deployments ship with. A hallucinated slug and a - prompt-injected one both arrive here looking exactly like a real one. + A slug that cannot be *classified* is destructive, not a write, and it + does not matter whether the lookup failed or succeeded and said + nothing. `writes` mode gates both, but `destructive` mode — the default + — gates only the first, so calling an unclassified slug a write would + run it unapproved in the mode most deployments ship with. A hallucinated + slug and a prompt-injected one both arrive here looking exactly like a + real one, and so does a real tool nobody has tagged yet. + + Only a positive answer is cached. The fail-safe one is a statement about + what is *not* known, and freezing it into the cache would outlive the + day Composio classifies the tool — a cache entry must never be able to + become the reason something is or is not gated. """ cached = self._effects.get(slug) if cached is not None: @@ -45,10 +52,9 @@ def effect_for(self, slug: str) -> str: tool: Any = self._client_factory().tools.get_raw_composio_tool_by_slug( slug ) - effect = effect_of(getattr(tool, "tags", None)) except Exception as error: # noqa: BLE001 - provider errors vary logger.warning( - "[composio] could not classify %s, treating it as destructive: %s", + "[composio] could not look %s up, treating it as destructive: %s", slug, error, ) @@ -57,5 +63,14 @@ def effect_for(self, slug: str) -> str: # only an approval prompt in the meantime. return DESTRUCTIVE + effect = effect_of(getattr(tool, "tags", None)) + if effect is None: + logger.warning( + "[composio] %s carries no behaviour tag, so it is gated as " + "destructive rather than assumed harmless.", + slug, + ) + return DESTRUCTIVE + self._effects[slug] = effect return effect diff --git a/agent/composio_tools/sessions.py b/agent/composio_tools/sessions.py index 1bb9453..4188583 100644 --- a/agent/composio_tools/sessions.py +++ b/agent/composio_tools/sessions.py @@ -80,6 +80,17 @@ def for_scope(self, scope: ResolvedScope) -> ScopedSession: # `sandbox`, not `workbench`: the latter is a deprecated alias # and passing both raises. sandbox={"enable": False}, + # Also explicit, and also not optional: this defaults to True. + # Left on, the session carries tools that initiate and manage + # connected accounts — a second path to the thing the connect + # flow exists to control. That flow binds a connection to the + # actor the platform verified and delivers the link to that + # person alone; a model calling a connection tool inside a + # session binds whatever user id the session happens to hold, + # with no card, no approver and nobody verified. Nothing here + # needs it: `authorize()` mints links over the session's own + # REST endpoint and does not read this flag. + manage_connections=False, ) self._sessions[key] = session return ScopedSession(session=session, scope=scope) diff --git a/agent/composio_tools/tools.py b/agent/composio_tools/tools.py index 61d51e7..e289825 100644 --- a/agent/composio_tools/tools.py +++ b/agent/composio_tools/tools.py @@ -142,10 +142,19 @@ def owns_slug(scope_toolkits: tuple[str, ...], slug: str) -> bool: def humanize_slug(slug: str) -> str: - """`GMAIL_SEND_EMAIL` -> `Gmail send email`, for the approval card.""" + """`GMAIL_SEND_EMAIL` becomes `Send email (Gmail)`, for the approval card. + + The verb leads and the app follows in brackets. The card labels its confirm + button with the action's first word, and reads that same word to decide + whether the action looks dangerous — so leading with the toolkit gave every + Gmail action a button reading "Gmail", and hid "delete" from the one check + that cared about it. + """ toolkit, _, rest = slug.partition("_") - words = (rest or toolkit).replace("_", " ").lower() - return f"{toolkit.capitalize()} {words}".strip() if rest else toolkit.capitalize() + if not rest: + return toolkit.capitalize() + words = rest.replace("_", " ").lower() + return f"{words[:1].upper()}{words[1:]} ({toolkit.capitalize()})" def build_composio_tools( diff --git a/agent/tests/test_composio_classify.py b/agent/tests/test_composio_classify.py index 24263e2..3797874 100644 --- a/agent/tests/test_composio_classify.py +++ b/agent/tests/test_composio_classify.py @@ -5,6 +5,7 @@ import pytest from composio_tools.classify import effect_of, needs_approval +from composio_tools.effects import EffectMap @pytest.mark.parametrize( @@ -14,17 +15,41 @@ (["destructiveHint"], "destructive"), # Both present: the dangerous claim wins. (["readOnlyHint", "destructiveHint"], "destructive"), - (["somethingElse"], "write"), - # An empty list cannot be told apart from "nobody classified this", so - # it is not read-only. - ([], "write"), - (None, "write"), + # Nothing positively claimed. Deliberately not "write": the default + # approval mode gates destructive calls only, so calling an + # unclassified tool a write is the same as not gating it at all. + (["somethingElse"], None), + ([], None), + (None, None), ], ) def test_effect_of_tags(tags, expected): assert effect_of(tags) == expected +@pytest.mark.parametrize( + ("tags", "expected"), + [ + ({"readOnlyHint": True}, "read"), + ({"destructiveHint": True}, "destructive"), + # The hint's value, not the hint's name. A tool that says "I am not + # read-only" must not read as read-only because the word is present. + ({"readOnlyHint": False}, None), + ({"readOnlyHint": False, "destructiveHint": True}, "destructive"), + ({"destructiveHint": False}, None), + # MCP hints are booleans; a truthy string is not a claim. + ({"readOnlyHint": "no"}, None), + ], +) +def test_a_hint_is_read_by_value_not_by_presence(tags, expected): + assert effect_of(tags) == expected + + +@pytest.mark.parametrize("tags", ["readOnlyHint", object(), [1, 2], 7]) +def test_a_shape_that_is_not_a_tag_list_claims_nothing(tags): + assert effect_of(tags) is None + + @pytest.mark.parametrize( ("effect", "mode", "expected"), [ @@ -40,3 +65,75 @@ def test_effect_of_tags(tags, expected): ) def test_needs_approval(effect, mode, expected): assert needs_approval(effect, mode) is expected + + +class FakeTool: + def __init__(self, tags) -> None: + self.tags = tags + + +class FakeTools: + def __init__(self, by_slug) -> None: + self._by_slug = by_slug + self.asked: list[str] = [] + + def get_raw_composio_tool_by_slug(self, slug): + self.asked.append(slug) + return self._by_slug[slug] + + +class FakeClient: + def __init__(self, by_slug) -> None: + self.tools = FakeTools(by_slug) + + +def test_a_found_but_untagged_tool_is_destructive_not_a_write(): + # The whole gate rests on this. `destructive` is the default mode and gates + # only destructive calls, so an untagged tool called a write is an ungated + # write to somebody's real account. + client = FakeClient({"GMAIL_SEND_EMAIL": FakeTool([])}) + + assert EffectMap(lambda: client).effect_for("GMAIL_SEND_EMAIL") == "destructive" + + +def test_the_fail_safe_answer_is_never_cached_as_a_verdict(): + # A tool nobody classified is gated because nothing is known about it, not + # because something dangerous is known. Caching that would freeze a guess + # into a permanent answer and hide the day Composio does classify it. + tool = FakeTool([]) + client = FakeClient({"SLACK_DO_THING": tool}) + effects = EffectMap(lambda: client) + + assert effects.effect_for("SLACK_DO_THING") == "destructive" + tool.tags = ["readOnlyHint"] + + assert effects.effect_for("SLACK_DO_THING") == "read" + assert client.tools.asked == ["SLACK_DO_THING", "SLACK_DO_THING"] + + +def test_a_classified_tool_costs_one_lookup(): + tool = FakeTool(["readOnlyHint"]) + client = FakeClient({"LINEAR_LIST_ISSUES": tool}) + effects = EffectMap(lambda: client) + + assert effects.effect_for("LINEAR_LIST_ISSUES") == "read" + assert effects.effect_for("LINEAR_LIST_ISSUES") == "read" + assert client.tools.asked == ["LINEAR_LIST_ISSUES"] + + +def test_a_lookup_that_fails_is_destructive_and_gets_another_chance(): + class Failing: + def __init__(self) -> None: + self.tools = self + self.asked: list[str] = [] + + def get_raw_composio_tool_by_slug(self, slug): + self.asked.append(slug) + raise RuntimeError("provider down") + + client = Failing() + effects = EffectMap(lambda: client) + + assert effects.effect_for("GMAIL_SEND_EMAIL") == "destructive" + assert effects.effect_for("GMAIL_SEND_EMAIL") == "destructive" + assert client.asked == ["GMAIL_SEND_EMAIL", "GMAIL_SEND_EMAIL"] diff --git a/agent/tests/test_composio_connect.py b/agent/tests/test_composio_connect.py index bb8f35d..03ae2b5 100644 --- a/agent/tests/test_composio_connect.py +++ b/agent/tests/test_composio_connect.py @@ -56,8 +56,11 @@ def create(self, *, user_id, **_kwargs): class FakeEffects: + """Nothing here gates, but the answer still matches production's fail-safe: + an unclassified slug is destructive, not read-only.""" + def effect_for(self, _slug): - return "read" + return "destructive" def runtime_for(sessions_by_user, **config_overrides): diff --git a/agent/tests/test_composio_tools.py b/agent/tests/test_composio_tools.py index a7511ad..890a2df 100644 --- a/agent/tests/test_composio_tools.py +++ b/agent/tests/test_composio_tools.py @@ -8,6 +8,8 @@ import composio_tools.tools as tools_mod from composio_tools.config import ComposioConfig +from composio_tools.effects import EffectMap +from composio_tools.scopes import ResolvedScope from composio_tools.sessions import SessionCache from composio_tools.tools import build_composio_tools, humanize_slug, owns_slug @@ -73,9 +75,11 @@ def __init__(self, sessions_by_user): self.sessions = self self._by_user = sessions_by_user self.created: list[str] = [] + self.kwargs: list[dict] = [] def create(self, *, user_id, **kwargs): self.created.append(user_id) + self.kwargs.append(kwargs) return self._by_user[user_id] @@ -91,10 +95,15 @@ def config(**overrides) -> ComposioConfig: class FakeEffects: - """Effects without a lookup. Read-only by default, so a test that is not - about approvals does not have to think about the gate.""" + """Effects without a lookup. - def __init__(self, effects=None, default="read"): + Destructive by default, because that is what production answers for a slug + nobody classified. A fake that defaults to `read` inverts the fail-safe and + lets a test walk straight past a gate the real thing would have closed — a + test asserting a call ran would then pass whether or not the gate worked. + """ + + def __init__(self, effects=None, default="destructive"): self._effects = effects or {} self._default = default self.asked: list[str] = [] @@ -239,7 +248,12 @@ def test_one_unreachable_scope_costs_only_its_own_candidates(caplog): def test_a_call_runs_in_the_account_that_owns_its_toolkit(): shared = FakeSession("open-tag") personal = FakeSession("slack:U1") - _search, run, _client = tools_for({"open-tag": shared, "slack:U1": personal}) + # Classified read on purpose: this test is about whose account runs the + # call, and an ungated one keeps the gate out of the way of that question. + _search, run, _client = tools_for( + {"open-tag": shared, "slack:U1": personal}, + effects=FakeEffects(default="read"), + ) run.invoke( {"slug": "GMAIL_SEND_EMAIL", "arguments": {"to": "a@b.c"}, "state": state("U1")} @@ -278,7 +292,9 @@ def test_a_reported_failure_is_a_failure(caplog): "open-tag", result={"data": None, "error": "Invalid request data provided", "logId": "log_1"}, ) - _search, run, _client = tools_for({"open-tag": shared}) + _search, run, _client = tools_for( + {"open-tag": shared}, effects=FakeEffects(default="read") + ) with caplog.at_level(logging.WARNING): result = run.invoke( @@ -292,7 +308,9 @@ def test_a_reported_failure_is_a_failure(caplog): def test_a_successful_call_returns_its_data(): shared = FakeSession("open-tag", result={"data": {"id": "ISS-1"}, "error": None}) - _search, run, _client = tools_for({"open-tag": shared}) + _search, run, _client = tools_for( + {"open-tag": shared}, effects=FakeEffects(default="read") + ) result = run.invoke( {"slug": "LINEAR_CREATE_ISSUE", "arguments": {}, "state": state()} @@ -341,7 +359,7 @@ def test_a_destructive_call_waits_for_approval_before_running(monkeypatch): run.invoke({"slug": "LINEAR_DELETE_ISSUE", "arguments": {"id": "ISS-1"}, "state": state()}) assert len(recorder.calls) == 1 - assert recorder.calls[0]["action"] == "Linear delete issue" + assert recorder.calls[0]["action"] == "Delete issue (Linear)" assert shared.executed == [("LINEAR_DELETE_ISSUE", {"id": "ISS-1"})] @@ -441,8 +459,11 @@ def test_an_unplaceable_slug_is_refused_before_anything_is_classified(): @pytest.mark.parametrize( ("slug", "expected"), [ - ("GMAIL_SEND_EMAIL", "Gmail send email"), - ("GOOGLECALENDAR_EVENTS_LIST", "Googlecalendar events list"), + # Verb first: the approval card labels its confirm button with the + # leading word, so leading with the toolkit gives every Gmail action a + # button reading "Gmail". + ("GMAIL_SEND_EMAIL", "Send email (Gmail)"), + ("GOOGLECALENDAR_EVENTS_LIST", "Events list (Googlecalendar)"), ("LINEAR", "Linear"), ], ) @@ -468,3 +489,92 @@ def test_the_composio_identity_is_namespaced_by_platform(): assert "teams:U1" in client.created + + +class UntaggedTool: + """A tool the SDK found, carrying the empty tag list it defaults to.""" + + def __init__(self, slug: str) -> None: + self.slug = slug + self.tags: list[str] = [] + + +class UntaggedTools: + """A live-shaped client whose tools exist and carry no behaviour tag.""" + + def __init__(self) -> None: + self.tools = self + self.asked: list[str] = [] + + def get_raw_composio_tool_by_slug(self, slug): + self.asked.append(slug) + return UntaggedTool(slug) + + +def test_a_found_but_untagged_call_is_gated_in_the_default_mode(monkeypatch): + # The gate's whole point. Composio returned the tool and said nothing about + # what it does; `destructive` — the default and the mode most deployments + # ship — gates destructive calls only, so anything less than destructive + # here is an unapproved write against somebody's real account. + shared = FakeSession("open-tag") + _search, run, _client = tools_for( + {"open-tag": shared}, + cfg=config(approvals="destructive"), + effects=EffectMap(lambda: UntaggedTools()), + ) + recorder = Recorder(approve=False) + monkeypatch.setattr(tools_mod, "require_write_confirmation", recorder) + + result = run.invoke( + {"slug": "LINEAR_CREATE_ISSUE", "arguments": {"title": "x"}, "state": state()} + ) + + assert len(recorder.calls) == 1, "an untagged tool must not run unapproved" + assert shared.executed == [] + assert "declined" in result + + +def test_the_card_carries_the_classified_effect(monkeypatch): + shared = FakeSession("open-tag") + _search, run, _client = tools_for( + {"open-tag": shared}, + effects=FakeEffects({"LINEAR_DELETE_ISSUE": "destructive"}), + ) + recorder = Recorder(approve=True) + monkeypatch.setattr(tools_mod, "require_write_confirmation", recorder) + + run.invoke({"slug": "LINEAR_DELETE_ISSUE", "arguments": {}, "state": state()}) + + assert recorder.calls[0]["extra_args"]["effect"] == "destructive" + + +def test_the_card_names_the_action_verb_first_not_the_app(monkeypatch): + # The card labels its confirm button with the action's leading word. Leading + # with the toolkit gives every Gmail action a button reading "Gmail", and + # hides the verb that decides whether the action is destructive. + shared = FakeSession("open-tag") + _search, run, _client = tools_for( + {"open-tag": shared}, + effects=FakeEffects({"LINEAR_DELETE_ISSUE": "destructive"}), + ) + recorder = Recorder(approve=True) + monkeypatch.setattr(tools_mod, "require_write_confirmation", recorder) + + run.invoke({"slug": "LINEAR_DELETE_ISSUE", "arguments": {}, "state": state()}) + + assert recorder.calls[0]["action"].split()[0] == "Delete" + + +def test_a_session_carries_no_connection_management_tools(): + # The agent has its own connect flow, which binds a connection to the actor + # the platform verified. A session that can manage connections hands the + # model a second, unverified path to the same thing. + shared = FakeSession("open-tag") + client = FakeComposio({"open-tag": shared}) + cache = SessionCache(config(), client=client) + + cache.for_scope( + ResolvedScope(user_id="open-tag", toolkits=("linear",), personal=False) + ) + + assert client.kwargs[0]["manage_connections"] is False diff --git a/app/channel.test.ts b/app/channel.test.ts index a07d865..13bb0cf 100644 --- a/app/channel.test.ts +++ b/app/channel.test.ts @@ -878,6 +878,113 @@ describe("createOpenTagChannel", () => { ]); }); + it("styles the posted card from the effect the agent classified", async () => { + // The agent looks the slug up, decides it is destructive, and sends that on + // the interrupt. Dropping it between the schema and the card leaves the red + // on Cancel and the irreversible button looking like the safe one. + const envelope = { + __copilotkit_interrupt_value__: { + action: "confirm_write", + args: { + action: "Trash message (Gmail)", + fields: null, + attempt: null, + approver: null, + effect: "destructive", + }, + }, + __copilotkit_messages__: [], + }; + const agent = new FakeAgent([ + (subscriber) => { + subscriber.onCustomEvent?.({ + event: { + type: EventType.CUSTOM, + name: "on_interrupt", + value: JSON.stringify(envelope), + }, + } as never); + }, + ]); + const { adapter, channel } = makeChannel({ agent }); + + await channel.ɵruntime.start(); + await adapter.getSink().onTurn({ + conversationKey: "c1", + replyTarget: {}, + userText: "bin that mail", + platform: "slack", + actor: { id: "U1", kind: "human" }, + }); + + expect(adapter.posted).toHaveLength(1); + const { blocks } = renderSlackMessage(adapter.posted[0]!); + const actions = blocks.find((b) => b.type === "actions") as + | { elements: { style?: string }[] } + | undefined; + expect(actions?.elements[0]?.style).toBe("danger"); + expect(actions?.elements[1]?.style).toBeUndefined(); + }); + + it("names the approver on the posted card, so a colleague's click is refused", async () => { + // Whose call it is travels from the agent, through the schema, onto the + // card, and into the click. Dropping it anywhere on that path costs nothing + // visible and quietly lets anybody in the thread spend somebody else's + // connected account. + const { adapter } = await postConfirmWrite({ + action: "Send email (Gmail)", + approver: "intelligence:U1", + effect: "write", + }); + + await adapter.getSink().onInteraction({ + id: confirmActionId(adapter), + conversationKey: "c1", + replyTarget: {}, + messageRef: { id: "msg-1" }, + actor: { id: "U2", kind: "human", name: "Someone else" }, + value: { confirmed: true }, + }); + + expect(adapter.updated).toHaveLength(0); + expect(JSON.stringify(adapter.ephemeralPosts)).toMatch( + /only they can approve/i, + ); + }); + + it("lets the named approver answer the posted card", async () => { + const { adapter } = await postConfirmWrite({ + action: "Send email (Gmail)", + approver: "intelligence:U1", + effect: "write", + }); + + await adapter.getSink().onInteraction({ + id: confirmActionId(adapter), + conversationKey: "c1", + replyTarget: {}, + messageRef: { id: "msg-1" }, + actor: { id: "U1", kind: "human", name: "The owner" }, + value: { confirmed: true }, + }); + + expect(adapter.updated).toHaveLength(1); + expect(JSON.stringify(adapter.updated)).toContain("Approved"); + expect(adapter.ephemeralPosts).toHaveLength(0); + }); + + it("carries the retry context from the interrupt onto the posted card", async () => { + const { adapter } = await postConfirmWrite({ + action: "Save project", + attempt: 2, + previous_error: 'Team "Growth" not found', + }); + + const { blocks } = renderSlackMessage(adapter.posted[0]!); + expect(JSON.stringify(blocks)).toContain("Attempt 2"); + expect(JSON.stringify(blocks)).toContain("Growth"); + }); + it("rejects malformed confirm_write interrupt payloads", async () => { const consoleError = vi .spyOn(console, "error") @@ -1032,3 +1139,42 @@ describe("createOpenTagChannel", () => { expect(JSON.stringify(secondAdapter.updated)).toContain("Ack'd by Ada"); }); }); + +/** Post one `confirm_write` card through the real interrupt handler. */ +async function postConfirmWrite(args: Record) { + const agent = new FakeAgent([ + (subscriber) => { + subscriber.onCustomEvent?.({ + event: { + type: EventType.CUSTOM, + name: "on_interrupt", + value: JSON.stringify({ + __copilotkit_interrupt_value__: { action: "confirm_write", args }, + __copilotkit_messages__: [], + }), + }, + } as never); + }, + ]); + const made = makeChannel({ agent }); + + await made.channel.ɵruntime.start(); + await made.adapter.getSink().onTurn({ + conversationKey: "c1", + replyTarget: {}, + userText: "do it", + platform: "slack", + actor: { id: "U1", kind: "human" }, + }); + + expect(made.adapter.posted).toHaveLength(1); + return made; +} + +/** The registered action id behind the posted card's confirm button. */ +function confirmActionId(adapter: FakeAdapter): string { + const button = findButton(adapter.posted[0]!, true); + const id = (button?.props.onClick as { id?: string } | undefined)?.id; + expect(id).toMatch(/^ck:/); + return id!; +} diff --git a/app/channel.tsx b/app/channel.tsx index 2171dfc..58dfba5 100644 --- a/app/channel.tsx +++ b/app/channel.tsx @@ -146,10 +146,14 @@ export function createOpenTagChannel( , ); }); diff --git a/app/human-in-the-loop/__tests__/confirm-write-approver.test.tsx b/app/human-in-the-loop/__tests__/confirm-write-approver.test.tsx index a4a6458..46bf9a1 100644 --- a/app/human-in-the-loop/__tests__/confirm-write-approver.test.tsx +++ b/app/human-in-the-loop/__tests__/confirm-write-approver.test.tsx @@ -33,18 +33,32 @@ function buttonHandlers(node: unknown): Array<(ctx: unknown) => unknown> { return found; } -function interaction(actorId: string) { +function interaction( + actorId: string, + overrides: { + postEphemeral?: ( + user: unknown, + ui: unknown, + options: { fallbackToDM: boolean }, + ) => Promise; + } = {}, +) { const update = vi.fn(async () => undefined); const resume = vi.fn(async () => undefined); + const post = vi.fn(async () => ({ id: "m2" })); const postEphemeral = vi.fn( - async (_user: unknown, _ui: unknown, _options: { fallbackToDM: boolean }) => - null, + overrides.postEphemeral ?? + (async ( + _user: unknown, + _ui: unknown, + _options: { fallbackToDM: boolean }, + ) => ({ ok: true, usedFallback: false })), ); return { ctx: { actor: { id: actorId, kind: "human" }, platform: "slack", - thread: { update, resume, postEphemeral }, + thread: { update, resume, postEphemeral, post }, message: { ref: "m1" }, action: { id: "a1" }, values: {}, @@ -52,6 +66,7 @@ function interaction(actorId: string) { } as never, update, resume, + post, postEphemeral, }; } @@ -81,9 +96,11 @@ describe("ConfirmWrite approver", () => { expect(update).not.toHaveBeenCalled(); expect(resume).not.toHaveBeenCalled(); expect(postEphemeral).toHaveBeenCalledTimes(1); - // Told privately: a public refusal would name somebody's private account in - // front of the whole thread. - expect(postEphemeral.mock.calls[0]![2]).toEqual({ fallbackToDM: false }); + // Told privately where the surface can, and by DM where it cannot. The + // notice names nobody, so it is not a secret that has to stay undelivered + // — unlike a connect link, which is a bearer capability and does not fall + // back to a DM. + expect(postEphemeral.mock.calls[0]![2]).toEqual({ fallbackToDM: true }); }); it("refuses the decline button too, not only approve", async () => { @@ -108,6 +125,88 @@ describe("ConfirmWrite approver", () => { expect(postEphemeral).not.toHaveBeenCalled(); }); + it("says so in the thread when the surface cannot deliver privately", async () => { + // `postEphemeral` resolves to `null` on a surface with no ephemeral + // message — the managed adapter reports exactly that. Ignoring the answer + // makes the refusal invisible: the person clicks, nothing happens, and the + // card sits there looking unclicked. + const handlers = buttonHandlers( + ConfirmWrite({ action: "Gmail send email", approver: "slack:U1" }), + ); + const { ctx, resume, post, postEphemeral } = interaction("U2", { + postEphemeral: async () => null, + }); + + await handlers[0]!(ctx); + + expect(postEphemeral).toHaveBeenCalledTimes(1); + expect(post).toHaveBeenCalledTimes(1); + expect(resume).not.toHaveBeenCalled(); + // The notice names nobody, so a public fallback leaks no account. + expect(JSON.stringify(post.mock.calls[0])).toMatch(/only they can approve/i); + }); + + it("still refuses, and says so, when the private message throws", async () => { + const consoleError = vi + .spyOn(console, "error") + .mockImplementation(() => undefined); + const handlers = buttonHandlers( + ConfirmWrite({ action: "Gmail send email", approver: "slack:U1" }), + ); + const { ctx, update, resume, post } = interaction("U2", { + postEphemeral: async () => { + throw new Error("ephemeral unavailable"); + }, + }); + + await handlers[0]!(ctx); + + expect(update).not.toHaveBeenCalled(); + expect(resume).not.toHaveBeenCalled(); + expect(post).toHaveBeenCalledTimes(1); + consoleError.mockRestore(); + }); + + it("lets the person answer when the agent could not name their platform", async () => { + // `unknown:` is what the agent writes when the turn carried no platform. + // No surface can ever produce that prefix, so a card naming it is a card + // nobody can answer — and the graph stays paused for good. + const handlers = buttonHandlers( + ConfirmWrite({ action: "Gmail send email", approver: "unknown:U1" }), + ); + const { ctx, update, resume, postEphemeral } = interaction("U1"); + + await handlers[0]!(ctx); + + expect(update).toHaveBeenCalled(); + expect(resume).toHaveBeenCalledWith({ confirmed: true }); + expect(postEphemeral).not.toHaveBeenCalled(); + }); + + it("still refuses somebody else when the platform is unknown", async () => { + const handlers = buttonHandlers( + ConfirmWrite({ action: "Gmail send email", approver: "unknown:U1" }), + ); + const { ctx, update, postEphemeral } = interaction("U2"); + + await handlers[0]!(ctx); + + expect(update).not.toHaveBeenCalled(); + expect(postEphemeral).toHaveBeenCalledTimes(1); + }); + + it("refuses a click nobody can be identified with", async () => { + const handlers = buttonHandlers( + ConfirmWrite({ action: "Gmail send email", approver: "slack:U1" }), + ); + const { ctx, update, resume } = interaction(""); + + await handlers[0]!(ctx); + + expect(update).not.toHaveBeenCalled(); + expect(resume).not.toHaveBeenCalled(); + }); + it("does not match a person on another platform who shares an id", async () => { const handlers = buttonHandlers( ConfirmWrite({ action: "Gmail send email", approver: "teams:U1" }), diff --git a/app/human-in-the-loop/__tests__/confirm-write.test.tsx b/app/human-in-the-loop/__tests__/confirm-write.test.tsx index e1aedc9..1c7c645 100644 --- a/app/human-in-the-loop/__tests__/confirm-write.test.tsx +++ b/app/human-in-the-loop/__tests__/confirm-write.test.tsx @@ -392,12 +392,17 @@ describe("ConfirmWrite", () => { expect(context?.elements[0]?.text).not.toMatch(/written|wrote|saved|done/i); }); - it("does not resume approval when the status update fails", async () => { + it("does not lose an approved decision when the card update fails", async () => { + // The card is the receipt, not the decision. The graph is paused on the + // answer the person already gave; dropping it because Slack would not + // repaint a message leaves that graph paused for good. + const consoleError = vi + .spyOn(console, "error") + .mockImplementation(() => undefined); const ir = renderToIR(); const create = buttonByText(ir, "Create"); - const failure = new Error("status update unavailable"); const update = vi.fn(async () => { - throw failure; + throw new Error("status update unavailable"); }); const resume = vi.fn(async () => ({ id: "m2" })); const ctx = { @@ -405,10 +410,83 @@ describe("ConfirmWrite", () => { message: { ref: { id: "m1" } }, } as unknown as InteractionContext; - await expect( - (create.props.onClick as ClickHandler)(ctx), - ).rejects.toBe(failure); - expect(resume).not.toHaveBeenCalled(); + await (create.props.onClick as ClickHandler)(ctx); + + expect(resume).toHaveBeenCalledWith({ confirmed: true }); + consoleError.mockRestore(); + }); + + it("answers once when the same button is pressed twice", async () => { + const ir = renderToIR(); + const create = buttonByText(ir, "Create"); + const update = vi.fn(async () => ({ id: "m1" })); + const resume = vi.fn(async () => ({ id: "m2" })); + const ctx = { + thread: { update, resume }, + message: { ref: { id: "m1" } }, + } as unknown as InteractionContext; + + await (create.props.onClick as ClickHandler)(ctx); + await (create.props.onClick as ClickHandler)(ctx); + + // The second press lands on a graph that is no longer paused. + expect(resume).toHaveBeenCalledTimes(1); + expect(update).toHaveBeenCalledTimes(1); + }); + + it("does not let a later Cancel overturn an approval already resumed", async () => { + const ir = renderToIR(); + const update = vi.fn(async () => ({ id: "m1" })); + const resume = vi.fn(async () => ({ id: "m2" })); + const ctx = { + thread: { update, resume }, + message: { ref: { id: "m1" } }, + } as unknown as InteractionContext; + + await (buttonByText(ir, "Create").props.onClick as ClickHandler)(ctx); + await (buttonByText(ir, "Cancel").props.onClick as ClickHandler)(ctx); + + expect(resume).toHaveBeenCalledTimes(1); + expect(resume).toHaveBeenCalledWith({ confirmed: true }); + }); + + it("takes the classified effect over the verb when styling the confirm button", () => { + // "Trash message (Gmail)" leads with a verb no local list calls dangerous. + // The agent classified it and the card must use that, or the red sits on + // Cancel while the irreversible button looks like the inviting one. + const ir = renderToIR( + , + ); + const { blocks } = renderSlackMessage(ir); + + const actions = blocks.find((b) => b.type === "actions") as + | { elements: { style?: string }[] } + | undefined; + expect(actions?.elements[0]?.style).toBe("danger"); + expect(actions?.elements[1]?.style).toBeUndefined(); + }); + + it("keeps a destructive verb dangerous even when the effect says otherwise", () => { + const ir = renderToIR(); + const { blocks } = renderSlackMessage(ir); + + const actions = blocks.find((b) => b.type === "actions") as + | { elements: { style?: string }[] } + | undefined; + expect(actions?.elements[0]?.style).toBe("danger"); + }); + + it("leaves a non-destructive classified action's confirm button neutral", () => { + const ir = renderToIR( + , + ); + const { blocks } = renderSlackMessage(ir); + + const actions = blocks.find((b) => b.type === "actions") as + | { elements: { style?: string }[] } + | undefined; + expect(actions?.elements[0]?.style).toBe("primary"); + expect(actions?.elements[1]?.style).toBe("danger"); }); it("cancel onClick updates the picker and resumes the interrupted agent", async () => { @@ -451,12 +529,14 @@ describe("ConfirmWrite", () => { expect(context?.elements[0]?.text).toContain("Declined"); }); - it("does not resume a decline when the status update fails", async () => { + it("does not lose a decline when the card update fails", async () => { + const consoleError = vi + .spyOn(console, "error") + .mockImplementation(() => undefined); const ir = renderToIR(); const cancel = buttonByText(ir, "Cancel"); - const failure = new Error("status update unavailable"); const update = vi.fn(async () => { - throw failure; + throw new Error("status update unavailable"); }); const resume = vi.fn(async () => ({ id: "m2" })); const ctx = { @@ -464,10 +544,10 @@ describe("ConfirmWrite", () => { message: { ref: { id: "m1" } }, } as unknown as InteractionContext; - await expect( - (cancel.props.onClick as ClickHandler)(ctx), - ).rejects.toBe(failure); - expect(resume).not.toHaveBeenCalled(); + await (cancel.props.onClick as ClickHandler)(ctx); + + expect(resume).toHaveBeenCalledWith({ confirmed: false }); + consoleError.mockRestore(); }); it("replaces the optimistic card with a retry state when resume fails", async () => { diff --git a/app/human-in-the-loop/confirm-write.tsx b/app/human-in-the-loop/confirm-write.tsx index 350e1e9..3c13b7f 100644 --- a/app/human-in-the-loop/confirm-write.tsx +++ b/app/human-in-the-loop/confirm-write.tsx @@ -25,6 +25,7 @@ import { Cell, } from "@copilotkit/channels"; import type { InteractionContext } from "@copilotkit/channels"; +import { reportRecoverableError } from "../channel-helpers.js"; /** One argument of the pending write, already labelled and stringified. */ export interface ConfirmWriteField { @@ -62,6 +63,14 @@ interface ConfirmWriteProps { attempt?: number; /** Why the previous attempt failed, quoted from the tool that rejected it. */ previousError?: string; + /** + * What the agent classified the action as — `read`, `write` or + * `destructive`. The agent looked the tool up; this card can only read the + * action's leading word, and for a Composio action that word is the name of + * the app. Absent for the MCP interceptor, which carries no classification + * onto the card and leaves the verb as the only signal. + */ + effect?: string; } async function resumeOrShowFailure( @@ -69,10 +78,14 @@ async function resumeOrShowFailure( messageRef: InteractionContext["message"]["ref"], action: string, confirmed: boolean, + reopen: () => void, ): Promise { try { await thread.resume({ confirmed }); } catch (error) { + // The decision never landed, so the card is answerable again. Holding it + // shut would make the retry this very message asks for impossible. + reopen(); try { await thread.update( messageRef, @@ -157,31 +170,98 @@ function retryNotice(attempt: number, previousError?: string) { ); } +/** The refusal itself. Names nobody, so it is safe anywhere in the thread. */ +const WRONG_APPROVER_NOTICE = ( + +
+ {"This one runs in someone else's connected account, so only they can approve it. The card is still waiting for them."} +
+
+); + +/** + * Whether the person who clicked is the one the agent named. + * + * The agent writes `platform:id`. Both halves must agree, because a provider id + * is unique only within its provider and one deployment can serve two. + * + * The exception is `unknown`, which is what the agent writes when the turn + * carried no platform at all. No surface can produce that prefix, so treating + * it as a platform to match would make the card unanswerable by anybody — the + * right person included — and leave the graph paused for good. The id is then + * the only thing both sides know, and it is what gets compared. + */ +function isNamedApprover( + interaction: InteractionContext, + approver: string, +): boolean { + const clickedBy = (interaction.actor?.id ?? "").trim(); + // Nobody verified pressed this. Refusing costs a click; accepting spends + // somebody's account on an unattributed press. + if (!clickedBy) return false; + + const separator = approver.indexOf(":"); + if (separator === -1) return false; + const namedPlatform = approver.slice(0, separator).trim(); + const namedId = approver.slice(separator + 1).trim(); + if (!namedId || namedId !== clickedBy) return false; + + return ( + namedPlatform === "unknown" || + namedPlatform === (interaction.platform ?? "").trim() + ); +} + +/** + * Tell one person something only they need to hear, and never silently fail to. + * + * `postEphemeral` resolves to `null` on a surface with no ephemeral message — + * the managed adapter reports exactly that — so an unchecked call is a message + * that was never delivered and never reported. The refusal names nobody, so + * when the private path cannot carry it the thread can. + */ +async function tellOrPost(interaction: InteractionContext): Promise { + try { + const delivered = await interaction.thread.postEphemeral( + interaction.actor, + WRONG_APPROVER_NOTICE, + { fallbackToDM: true }, + ); + if (delivered?.ok) return; + } catch (error) { + reportRecoverableError(error, { + operation: "confirm_write_refusal_ephemeral", + recovery: "post_refusal_in_thread", + }); + } + await interaction.thread.post(WRONG_APPROVER_NOTICE); +} + /** * Whether this click came from somebody other than the named approver. * * Enforced here rather than in the agent because only the surface knows who * pressed the button; the agent can say whose action it is and nothing more. - * The wrong person is told privately and the graph is left paused, so the right - * person can still answer. + * The wrong person is told and the graph is left paused, so the right person + * can still answer. */ async function refuseWrongApprover( interaction: InteractionContext, approver: string | undefined, ): Promise { if (!approver) return false; - if (`${interaction.platform}:${interaction.actor?.id ?? ""}` === approver) { - return false; + if (isNamedApprover(interaction, approver)) return false; + + try { + await tellOrPost(interaction); + } catch (error) { + // The refusal stands whether or not it could be delivered. Falling through + // to the click would hand somebody else's account to whoever pressed. + reportRecoverableError(error, { + operation: "confirm_write_refusal", + recovery: "refused_without_telling_the_clicker", + }); } - await interaction.thread.postEphemeral( - interaction.actor, - -
- {"This one runs in someone else's connected account, so only they can approve it."} -
-
, - { fallbackToDM: false }, - ); return true; } @@ -192,6 +272,7 @@ export function ConfirmWrite({ detail, attempt, previousError, + effect, }: ConfirmWriteProps) { const body = fields?.length ? fieldTable(fields) @@ -205,9 +286,46 @@ export function ConfirmWrite({ const verb = verbOf(action); const label = confirmLabel(verb); - // Read from the action's real verb, never from `label` — a relabelled - // destructive action is still destructive. - const destructive = DESTRUCTIVE.has(verb.toLowerCase()); + // Either signal is enough, and neither can talk the other down. The agent + // looked the tool up, so its classification is the better evidence; the verb + // still counts because an agent that carries no classification — the MCP + // interceptor — leaves the word as the only thing there is to read. Never + // from `label`: a relabelled destructive action is still destructive. + const destructive = + effect === "destructive" || DESTRUCTIVE.has(verb.toLowerCase()); + + // One decision per card. Both buttons close over this, so a double press — + // or an approve followed a moment later by a cancel — resolves the interrupt + // once instead of resuming a graph that is no longer paused. The card is also + // replaced by a button-less one on the first answer, which is what covers the + // press that arrives after this render is gone. + let answered = false; + + const answer = async ( + interaction: InteractionContext, + confirmed: boolean, + resolvedCard: Parameters[1], + ): Promise => { + if (await refuseWrongApprover(interaction, approver)) return; + if (answered) return; + answered = true; + + const { thread, message } = interaction; + try { + await thread.update(message.ref, resolvedCard); + } catch (error) { + // The card is the receipt, not the decision. A graph is paused on the + // answer this person already gave, and throwing away an approval because + // Slack would not repaint a message leaves it paused for good. + reportRecoverableError(error, { + operation: "confirm_write_card_update", + recovery: "resumed_the_agent_anyway", + }); + } + await resumeOrShowFailure(thread, message.ref, action, confirmed, () => { + answered = false; + }); + }; return ( @@ -222,10 +340,9 @@ export function ConfirmWrite({ value={{ confirmed: true }} style={destructive ? "danger" : "primary"} onClick={async (interaction: InteractionContext) => { - if (await refuseWrongApprover(interaction, approver)) return; - const { thread, message } = interaction; - await thread.update( - message.ref, + await answer( + interaction, + true,
{`✅ ${action}`}
{/* @@ -240,12 +357,6 @@ export function ConfirmWrite({
, ); - await resumeOrShowFailure( - thread, - message.ref, - action, - true, - ); }} > {label} @@ -254,21 +365,14 @@ export function ConfirmWrite({ value={{ confirmed: false }} style={destructive ? undefined : "danger"} onClick={async (interaction: InteractionContext) => { - if (await refuseWrongApprover(interaction, approver)) return; - const { thread, message } = interaction; - await thread.update( - message.ref, + await answer( + interaction, + false,
{`🚫 ${action}`}
{"🚫 Declined — nothing was written."}
, ); - await resumeOrShowFailure( - thread, - message.ref, - action, - false, - ); }} > Cancel diff --git a/app/interrupt.test.ts b/app/interrupt.test.ts index 7c82011..87d6492 100644 --- a/app/interrupt.test.ts +++ b/app/interrupt.test.ts @@ -184,6 +184,33 @@ describe("parseConfirmWriteInterrupt approver", () => { expect(args.approver ?? undefined).toBeUndefined(); }); + it("accepts an explicitly null fields, which is how the agent says none", () => { + // Every other optional key on this card is nullish, and the producer sends + // explicit nulls. A schema that only tolerates `undefined` throws inside + // the interrupt handler, and the card is never posted at all — the graph + // waits for an answer to a question nobody was ever asked. + const { args } = parseConfirmWriteInterrupt( + interruptPayload("confirm_write", { + action: "Save project", + fields: null, + attempt: null, + previous_error: null, + }), + ); + expect(args.fields ?? undefined).toBeUndefined(); + expect(args.attempt ?? undefined).toBeUndefined(); + }); + + it("carries the classified effect through to the card", () => { + const { args } = parseConfirmWriteInterrupt( + interruptPayload("confirm_write", { + action: "Gmail delete draft", + effect: "destructive", + }), + ); + expect(args.effect).toBe("destructive"); + }); + it("still accepts a payload from an agent revision predating the approver", () => { const { args } = parseConfirmWriteInterrupt( interruptPayload("confirm_write", { action: "Create issue" }), diff --git a/app/interrupt.ts b/app/interrupt.ts index f910379..ee604ff 100644 --- a/app/interrupt.ts +++ b/app/interrupt.ts @@ -5,17 +5,25 @@ const confirmWriteInterruptSchema = z.object({ action: z.literal("confirm_write"), args: z.object({ action: z.string().min(1), - /** Approver-readable rows built by the agent's `summarize_args`. */ + /** + * Approver-readable rows built by the agent's `summarize_args`. + * + * Nullish, not optional. The agent sends explicit nulls for the extras a + * card does not carry, and a schema that only tolerates `undefined` + * throws inside the interrupt handler — which posts no card at all and + * leaves the graph paused on a question nobody was ever asked. + */ fields: z .array(z.object({ label: z.string(), value: z.string() })) - .optional(), + .nullish(), /** Legacy pre-`fields` summary; still accepted across a deploy skew. */ detail: z.string().nullish(), /** * Which attempt at this write the card is asking about. Absent on a * first attempt; `2` and up mean an earlier approved attempt failed. + * Nullish for the same reason `fields` is. */ - attempt: z.number().int().min(1).optional(), + attempt: z.number().int().min(1).nullish(), /** Why the previous attempt at this same write failed. */ previous_error: z.string().nullish(), /** diff --git a/setup.md b/setup.md index 19bdcd6..101e193 100644 --- a/setup.md +++ b/setup.md @@ -436,11 +436,24 @@ answer can arrive twenty minutes later and the model still sees the result. A tool's effect comes from Composio's own behaviour tags, looked up per slug. A slug that cannot be classified is treated as **destructive**, not as a write: `writes` gates both, but `destructive` gates only the first, so the safe reading -of "unrecognised" is the stricter one. +of "unrecognised" is the stricter one. "Cannot be classified" covers both a +lookup that failed and a tool the lookup found carrying no behaviour tag — +`readOnlyHint` is the only thing that takes a call out of the gate, and only the +lookup's own answer is remembered, never the fail-safe one. The tags cannot say +"a write that is definitely not destructive", so for a Composio call `writes` +and `destructive` come to the same thing; `off` is the only mode that changes +what is asked. A call that runs in one person's own account names that person as its approver, and only they can answer the card — approving it spends their access and nobody -else's. +else's. Somebody else pressing it is told so, privately where the surface allows +one and in the thread where it does not, and the card stays up for its owner. +The card's own buttons answer once: a second press, on either button, lands on a +graph that is no longer paused and does nothing. + +Sessions are created with connection management off. The connect flow above is +the only way an account is linked, because it is the only one that binds the +connection to an actor the platform verified. ## Railway From 722f64a35e86e2b632fc88eea5d0f9d9c1aa9bb1 Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 2 Sep 2026 18:57:38 +0200 Subject: [PATCH 13/23] refactor(composio): collapse the two approval modes that could never differ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `COMPOSIO_APPROVALS` offered `destructive` and `writes` as separate modes. They gated an identical set of calls, and no configuration could have made them differ. The gate reads Composio's MCP behaviour tags, and those say exactly two things: `readOnlyHint` and `destructiveHint`. There is no tag for "a write that is definitely not destructive", and `idempotentHint` cannot stand in for one — DELETE is idempotent. `effect_of` therefore answers `read`, `destructive`, or `None`; the `write` constant it never returns is what the two modes would have had to distinguish. Since `EffectMap` gates an unclassified tool as destructive rather than guessing, every call reaching `needs_approval` is a read or destructive, and both modes gated exactly the non-reads. So the vocabulary is now `on` and `off`, and one rule: a read goes through unasked, everything else is asked about. `destructive` and `writes` still parse, as `on`. Refusing them would fail an existing deployment at boot over a value that always meant what it still means — the same upgrade break the review flagged elsewhere in this branch. Mutation-checked, not just written: - removing the deprecated-spelling mapping fails the alias test alone - narrowing the gate back to destructive-only fails 5 tests across two files Call sites of the changed symbols: `needs_approval` — `composio_tools/tools.py:274` only; signature unchanged. `APPROVAL_MODES` — `_approval_mode` in the same module, and the error message it builds. Nothing imports it across package boundaries. `WRITE` — no importers; comment corrected, constant kept because a caller that classifies by other means can still produce it and it must still gate. Docs updated where an operator would look: the `setup.md` variable table, the `setup.md` Approvals section (which now explains the collapse rather than apologising for it), and `.env.example`. The CDK fixture still passes `writes` on purpose — it exercises the deprecated spelling. pnpm check-types clean vitest 283 passed pytest 350 passed cdk (tsx --test) 13 passed Co-Authored-By: Claude Opus 5 (1M context) --- .env.example | 5 ++-- agent/composio_tools/classify.py | 26 +++++++++++++------ agent/composio_tools/config.py | 23 +++++++++++++++-- agent/tests/test_composio_classify.py | 19 +++++++++----- agent/tests/test_composio_config.py | 21 ++++++++++++++-- agent/tests/test_composio_tools.py | 9 ++++++- setup.md | 36 ++++++++++++++++----------- 7 files changed, 103 insertions(+), 36 deletions(-) diff --git a/.env.example b/.env.example index f43fec7..833fd76 100644 --- a/.env.example +++ b/.env.example @@ -76,8 +76,9 @@ export OPENAI_API_KEY=sk-... # Create a key at https://platform.ope # Each person's own account. They connect it themselves from a thread. # export COMPOSIO_USER_TOOLKITS=gmail,googlecalendar # -# off | destructive (default) | writes — which calls wait for a person. -# export COMPOSIO_APPROVALS=destructive +# on (default) | off — whether a call that is not a read waits for a person. +# `destructive` and `writes` are the old spellings; both still parse as `on`. +# export COMPOSIO_APPROVALS=on # # The Composio user_id shared toolkits act as. Defaults to the Channel name. # export COMPOSIO_WORKSPACE_USER_ID=open-tag diff --git a/agent/composio_tools/classify.py b/agent/composio_tools/classify.py index 3fd8ef6..8b14980 100644 --- a/agent/composio_tools/classify.py +++ b/agent/composio_tools/classify.py @@ -21,11 +21,14 @@ from typing import Any READ = "read" -#: A change that is not destructive. `needs_approval` still gates it under -#: `writes`. Reachable from a caller that classifies by other means (the MCP -#: interceptor's `readOnlyHint` metadata); the tag vocabulary itself cannot -#: distinguish a plain write from an unclassified tool, and this module does -#: not guess. +#: A change that is not destructive. `effect_of` never answers this — the tag +#: vocabulary cannot distinguish a plain write from an unclassified tool, and +#: this module does not guess. It exists for a caller that classifies by other +#: means (the MCP interceptor's `readOnlyHint` metadata), and it gates, because +#: only a read goes through unasked. +#: +#: That it is unreachable from the tags is exactly why the old `writes` and +#: `destructive` approval modes could never differ. See `config.APPROVAL_MODES`. WRITE = "write" DESTRUCTIVE = "destructive" @@ -73,9 +76,16 @@ def effect_of(tags: Any) -> str | None: def needs_approval(effect: str, mode: str) -> bool: - """Whether an effect must be confirmed by a person under this approval mode.""" + """Whether an effect must be confirmed by a person under this approval mode. + + One gating rule, because there was only ever one. `destructive` and `writes` + used to be separate modes and gated an identical set — the tag vocabulary + cannot express a write that is not destructive, and an unclassified tool is + gated as destructive rather than guessed at. `config.APPROVAL_MODES` records + the collapse; both old spellings still parse. + + A read is the only thing that goes through unasked. + """ if mode == "off": return False - if mode == "destructive": - return effect == DESTRUCTIVE return effect != READ diff --git a/agent/composio_tools/config.py b/agent/composio_tools/config.py index 9805a69..a8e2c08 100644 --- a/agent/composio_tools/config.py +++ b/agent/composio_tools/config.py @@ -15,7 +15,22 @@ from collections.abc import Mapping from dataclasses import dataclass, field -APPROVAL_MODES = ("off", "destructive", "writes") +APPROVAL_MODES = ("off", "on") + +#: `destructive` and `writes` were two modes that could never differ. +#: +#: The gate reads Composio's MCP behaviour tags, and those can say exactly two +#: things: `readOnlyHint` (a read) and `destructiveHint` (destructive). There is +#: no tag for "a write that is definitely not destructive", and `idempotentHint` +#: cannot stand in for one — DELETE is idempotent. Anything the tags do not +#: classify is gated as destructive, because calling it a write would have left +#: it ungated under the default mode. So every call is a read or destructive, +#: `writes` and `destructive` gated exactly the same set, and an operator +#: choosing between them was choosing between two spellings of one behaviour. +#: +#: Still accepted, because refusing them would fail an existing deployment at +#: boot over a value that always meant `on`. +DEPRECATED_APPROVAL_MODES = {"destructive": "on", "writes": "on"} class ComposioConfigError(ValueError): @@ -56,8 +71,12 @@ def _approval_mode(raw: str) -> str: `COMPOSIO_APPROVALS=` is routine in `.env` files and in compose passthrough, and must not take the agent down at boot. + + `destructive` and `writes` are folded to `on`; see + `DEPRECATED_APPROVAL_MODES` for why they could never have differed. """ - value = raw.strip().lower() or "destructive" + value = raw.strip().lower() or "on" + value = DEPRECATED_APPROVAL_MODES.get(value, value) if value not in APPROVAL_MODES: raise ComposioConfigError( f'Invalid COMPOSIO_APPROVALS: "{raw}" — expected one of ' diff --git a/agent/tests/test_composio_classify.py b/agent/tests/test_composio_classify.py index 3797874..cd4a72a 100644 --- a/agent/tests/test_composio_classify.py +++ b/agent/tests/test_composio_classify.py @@ -55,18 +55,25 @@ def test_a_shape_that_is_not_a_tag_list_claims_nothing(tags): [ ("destructive", "off", False), ("write", "off", False), - ("destructive", "destructive", True), - ("write", "destructive", False), - ("read", "destructive", False), - ("destructive", "writes", True), - ("write", "writes", True), - ("read", "writes", False), + ("read", "off", False), + ("destructive", "on", True), + ("write", "on", True), + ("read", "on", False), ], ) def test_needs_approval(effect, mode, expected): assert needs_approval(effect, mode) is expected +def test_a_read_is_the_only_thing_that_goes_through_unasked(): + # The collapse of `writes` and `destructive` into `on` is only safe because + # nothing but a read escapes the gate. An effect this test has never heard + # of must still be asked about, or a new classification would ship ungated. + for effect in ("destructive", "write", "unclassified", "", "something new"): + assert needs_approval(effect, "on") is True, effect + assert needs_approval("read", "on") is False + + class FakeTool: def __init__(self, tags) -> None: self.tags = tags diff --git a/agent/tests/test_composio_config.py b/agent/tests/test_composio_config.py index a77299a..92681bd 100644 --- a/agent/tests/test_composio_config.py +++ b/agent/tests/test_composio_config.py @@ -34,7 +34,24 @@ def test_toolkit_lists_are_split_trimmed_and_lowercased(): assert config.user_toolkits == ("gmail",) -def test_approvals_defaults_to_destructive_when_blank(): +def test_the_old_two_spellings_still_parse_and_mean_the_same_thing(): + # `destructive` and `writes` gated an identical set, so they collapsed to + # `on`. Refusing them now would fail an existing deployment at boot over a + # value that always meant what it still means. + for raw in ("destructive", "writes", "WRITES", " Destructive "): + config = read_composio_config( + { + "COMPOSIO_API_KEY": "ak_test", + "COMPOSIO_TOOLKITS": "linear", + "COMPOSIO_APPROVALS": raw, + }, + default_user_id="open-tag", + ) + assert config is not None + assert config.approvals == "on", raw + + +def test_approvals_defaults_to_on_when_blank(): # `COMPOSIO_APPROVALS=` is routine in .env files and compose passthrough. # Unset is not invalid, and must not take the agent down at boot. for raw in ("", " "): @@ -47,7 +64,7 @@ def test_approvals_defaults_to_destructive_when_blank(): default_user_id="open-tag", ) assert config is not None - assert config.approvals == "destructive" + assert config.approvals == "on" def test_unknown_approval_mode_is_refused_by_name(): diff --git a/agent/tests/test_composio_tools.py b/agent/tests/test_composio_tools.py index 890a2df..a914f82 100644 --- a/agent/tests/test_composio_tools.py +++ b/agent/tests/test_composio_tools.py @@ -394,7 +394,14 @@ def test_a_read_is_never_gated(monkeypatch): def test_the_approval_mode_decides_whether_a_write_is_gated(monkeypatch): - for mode, gated in (("off", False), ("destructive", False), ("writes", True)): + # `destructive` and `writes` are the old spellings; both now mean `on`, so + # the same write is gated under all three and only `off` lets it through. + for mode, gated in ( + ("off", False), + ("on", True), + ("destructive", True), + ("writes", True), + ): shared = FakeSession("open-tag") _search, run, _client = tools_for( {"open-tag": shared}, diff --git a/setup.md b/setup.md index bbe9fd3..5f81667 100644 --- a/setup.md +++ b/setup.md @@ -85,7 +85,7 @@ or Channel slug. | `COMPOSIO_API_KEY` | No | Master switch for Composio toolkits. Absent means the feature is never constructed | | `COMPOSIO_TOOLKITS` | No | Toolkit slugs everyone shares one connection for | | `COMPOSIO_USER_TOOLKITS` | No | Toolkit slugs scoped to whoever sent the message | -| `COMPOSIO_APPROVALS` | No | `off`, `destructive` (default), or `writes`. An unrecognized value fails startup | +| `COMPOSIO_APPROVALS` | No | `on` (default) or `off`. `destructive` and `writes` are the old spellings and still parse as `on`. An unrecognized value fails startup | | `COMPOSIO_WORKSPACE_USER_ID` | No | Composio `user_id` the shared toolkits run as; defaults to `INTELLIGENCE_CHANNEL_NAME` | | `COMPOSIO_AUTH_CONFIGS` | No | **Read only by the connect script, never by a turn.** `toolkit:auth_config_id` pairs, ids case-sensitive; pins which auth config a *shared* toolkit connects against when it has several | | `AGENT_AUTH_HEADER` | No | The runtime's shared secret. Checked when set, and **required** before a Composio connect link is minted | @@ -446,20 +446,26 @@ Personal toolkits need two more things: #### Approvals -`COMPOSIO_APPROVALS` is `off`, `destructive` (the default), or `writes`. A gated -call posts the same card as a Linear or Notion write and pauses the graph, so the -answer can arrive twenty minutes later and the model still sees the result. - -A tool's effect comes from Composio's own behaviour tags, looked up per slug. A -slug that cannot be classified is treated as **destructive**, not as a write: -`writes` gates both, but `destructive` gates only the first, so the safe reading -of "unrecognised" is the stricter one. "Cannot be classified" covers both a -lookup that failed and a tool the lookup found carrying no behaviour tag — -`readOnlyHint` is the only thing that takes a call out of the gate, and only the -lookup's own answer is remembered, never the fail-safe one. The tags cannot say -"a write that is definitely not destructive", so for a Composio call `writes` -and `destructive` come to the same thing; `off` is the only mode that changes -what is asked. +`COMPOSIO_APPROVALS` is `on` (the default) or `off`. A gated call posts the same +card as a Linear or Notion write and pauses the graph, so the answer can arrive +twenty minutes later and the model still sees the result. + +A tool's effect comes from Composio's own behaviour tags, looked up per slug. +`readOnlyHint` is the only thing that takes a call out of the gate. Everything +else is gated, including a slug that cannot be classified — which covers both a +lookup that failed and a tool the lookup found carrying no behaviour tag. Only +the lookup's own answer is remembered, never the fail-safe one, so a tool is not +permanently mislabelled by one bad lookup. + +**`destructive` and `writes` were separate modes and are now one.** They gated an +identical set and always would have. The tags can say exactly two things — +`readOnlyHint` and `destructiveHint` — so there is no way to express "a write +that is definitely not destructive", and `idempotentHint` cannot stand in for +one, because DELETE is idempotent. Since an unclassified tool is gated as +destructive rather than guessed at, every call is either a read or destructive, +and choosing between the two modes was choosing between two spellings of one +behaviour. Both still parse as `on`, so an existing deployment does not fail at +boot on upgrade; there is nothing to change unless you want the new name. A call that runs in one person's own account names that person as its approver, and only they can answer the card — approving it spends their access and nobody From 380c02f0e7b9df9354e039a47921739a4983fcb7 Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 2 Sep 2026 20:30:36 +0200 Subject: [PATCH 14/23] fix(agent): let CORS answer, let the probe in, and make the operator path run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shared-secret middleware was registered before CORS, and Starlette builds the stack so the middleware added last sits outermost — so the secret check wrapped CORS. Every browser preflight was refused before CORS ran, and no 401 carried a CORS header, which made `CORS_ALLOW_ORIGINS` inert for exactly the responses an operator with a wrong secret needs to read. CORS is now registered last. `BaseHTTPMiddleware` in front of the SSE endpoint was measured rather than assumed: against a real uvicorn socket the chunk arrival times are identical with and without it, and a client disconnect cancels the generator at the same chunk, so it neither buffers nor leaks and stays. `PUBLIC_PATHS` was matched exactly, so `/health/` was refused before `redirect_slashes` could rewrite it, and the route answered GET alone so a probe sending HEAD got 405. The operator connect script never loaded the repo `.env` — nothing it imports does — so the only correct way to connect a shared toolkit exited 1 saying Composio was not configured on deployments where it was. It also read one spelling of `redirect_url`, sent no `manage_connections=False` (the SDK defaults it to True, so the session it opened carried account-management tools), and never sent `COMPOSIO_AUTH_CONFIGS` at all. The docstring claiming an auth config cannot be pinned per call was false: `sessions.create` takes `auth_configs`, `authorize()` is merely not where it goes. Corrected rather than better documented. Also: a blank `INTELLIGENCE_CHANNEL_NAME` minted an empty Composio user id, an api key with no toolkits disabled the feature in silence, and `frozen=True` over a dict field generated a `__hash__` that raised. Tests: a wrong-secret vector that differs only past the ASCII range, which is the only one that can tell the shipped `encode("utf-8", "surrogateescape")` from `encode("ascii", "ignore")` — the shipped comparison is correct and there is no bypass, but nothing proved it. `test_the_link_is_never_logged` now establishes that a link was minted before asserting it was not logged. `COMPOSIO_WORKSPACE_ USER_ID` has a test, so deleting the line that reads it no longer leaves green. Call sites of what this adds or changes: - `agent_auth._public_path` (new, private): `agent_auth.is_authorized` only. - `config.DEFAULT_WORKSPACE_USER_ID` (new): `config.read_composio_config`, `main.composio_connect`, `connect_cli.main`, `tests/test_composio_config.py`. `agent.py:187` still spells the same default as a literal; correct either way now, since the fallback lives in `read_composio_config`. - `connect_cli.ENV_FILE` and `connect_cli.operator_environment` (new): `connect_cli.main`, and `tests/test_composio_connect_cli.py` monkeypatches `ENV_FILE`. - `ComposioConfig.auth_configs` field options only (`hash=False`); no signature change, so every construction site is untouched: `read_composio_config` and six test modules. - `main.health` now answers GET and HEAD. Probes that call it: Railway `healthcheckPath` (.railway/railway.ts:20), the compose and ECS health checks (deployment/docker-compose.yml:20, deployment/aws/lib/opentag-stack.ts:302), all GET, all unaffected. Co-Authored-By: Claude Opus 5 (1M context) --- agent/agent_auth.py | 17 ++- agent/composio_tools/config.py | 38 +++++- agent/composio_tools/connect_cli.py | 73 ++++++++++- agent/main.py | 56 +++++--- agent/tests/test_agent_auth.py | 71 ++++++++++ agent/tests/test_composio_config.py | 93 ++++++++++++- agent/tests/test_composio_connect.py | 9 +- agent/tests/test_composio_connect_cli.py | 160 +++++++++++++++++++++++ 8 files changed, 487 insertions(+), 30 deletions(-) diff --git a/agent/agent_auth.py b/agent/agent_auth.py index 40af9f0..c2a9795 100644 --- a/agent/agent_auth.py +++ b/agent/agent_auth.py @@ -24,10 +24,23 @@ from collections.abc import Mapping #: Paths served without a secret even when one is configured. The platform's -#: health probe has no way to send one. +#: health probe has no way to send one. Written without a trailing slash; +#: `_public_path` is what compares them. PUBLIC_PATHS = frozenset({"/health"}) +def _public_path(path: str) -> str: + """ + The spelling of `path` that `PUBLIC_PATHS` is written in. + + `/health/` and `/health` are the same endpoint — the router redirects one to + the other — but that redirect happens after this check, so an exactly + matched path refuses `/health/` before routing ever runs and the probe sees + a 401 it can do nothing about. + """ + return path.rstrip("/") or "/" + + def configured_secret(env: Mapping[str, str] | None = None) -> str | None: """The expected `Authorization` value, or `None` when none is configured.""" source = os.environ if env is None else env @@ -70,7 +83,7 @@ def is_authorized( env: Mapping[str, str] | None = None, ) -> bool: """Whether ordinary traffic for `path` may proceed.""" - if path in PUBLIC_PATHS: + if _public_path(path) in PUBLIC_PATHS: return True expected = configured_secret(env) if expected is None: diff --git a/agent/composio_tools/config.py b/agent/composio_tools/config.py index a8e2c08..10eaa01 100644 --- a/agent/composio_tools/config.py +++ b/agent/composio_tools/config.py @@ -11,12 +11,22 @@ from __future__ import annotations +import logging import os from collections.abc import Mapping from dataclasses import dataclass, field +logger = logging.getLogger(__name__) + APPROVAL_MODES = ("off", "on") +#: The shared Composio identity when nothing names one. Every caller passes the +#: channel name as `default_user_id`, and that variable can be present and +#: empty — `INTELLIGENCE_CHANNEL_NAME=` is routine — which is not a name. An +#: empty user id is a real Composio identity that nothing else ever resolves to, +#: so the shared connection would land where no turn looks. +DEFAULT_WORKSPACE_USER_ID = "open-tag" + #: `destructive` and `writes` were two modes that could never differ. #: #: The gate reads Composio's MCP behaviour tags, and those can say exactly two @@ -45,10 +55,17 @@ class ComposioConfig: approvals: str workspace_user_id: str #: Read only by the operator connect script; no turn consumes it. - #: `session.authorize()` resolves an auth config from the project itself and - #: takes no id, so a toolkit with several cannot be pinned per call. This - #: pins the choice when an operator connects a shared toolkit by hand. - auth_configs: Mapping[str, str] = field(default_factory=dict) + #: + #: `session.authorize()` takes no auth config id, but the session does: + #: `sessions.create(auth_configs={"linear": "ac_..."})` pins one per + #: toolkit, and the connect script passes this through. Which settles the + #: case the variable exists for — a toolkit holding several auth configs, + #: where an unpinned session lets the project resolve whichever it likes. + #: + #: `hash=False` because a dict is unhashable and `frozen=True` generates a + #: `__hash__` from every comparing field: without it, hashing a config that + #: named an auth config raised `TypeError`, and only that config. + auth_configs: Mapping[str, str] = field(default_factory=dict, hash=False) def _env(env: Mapping[str, str] | None) -> Mapping[str, str]: @@ -122,7 +139,16 @@ def read_composio_config( # A key with no toolkits names nothing to reach. Treated as unconfigured # rather than as an empty-but-enabled integration, so the agent does not # advertise tools that can only answer "nothing is set up". + # + # Said out loud, unlike an absent key: setting a key and no toolkit is a + # half-finished setup rather than a decision not to use the feature, and it + # used to turn the whole integration off in silence. if not workspace_toolkits and not user_toolkits: + logger.warning( + "[composio] COMPOSIO_API_KEY is set but neither COMPOSIO_TOOLKITS " + "nor COMPOSIO_USER_TOOLKITS names a toolkit, so connected apps are " + "off. Name at least one toolkit in either." + ) return None return ComposioConfig( @@ -131,7 +157,9 @@ def read_composio_config( user_toolkits=user_toolkits, approvals=_approval_mode(_value(source, "COMPOSIO_APPROVALS")), workspace_user_id=( - _value(source, "COMPOSIO_WORKSPACE_USER_ID") or default_user_id + _value(source, "COMPOSIO_WORKSPACE_USER_ID") + or default_user_id.strip() + or DEFAULT_WORKSPACE_USER_ID ), auth_configs=_auth_config_map(_value(source, "COMPOSIO_AUTH_CONFIGS")), ) diff --git a/agent/composio_tools/connect_cli.py b/agent/composio_tools/connect_cli.py index 15749ca..f6f3060 100644 --- a/agent/composio_tools/connect_cli.py +++ b/agent/composio_tools/connect_cli.py @@ -9,6 +9,11 @@ So this is the only correct path, and it needs no running agent: cd agent && uv run python -m composio_tools.connect_cli + +It reads the repo `.env` itself. Nothing it imports loads that file — only +`agent.py` does, and this script does not import the agent — so without it the +one correct path exited saying Composio was not configured on a deployment +where it was. """ from __future__ import annotations @@ -16,13 +21,50 @@ import os import sys from collections.abc import Mapping +from pathlib import Path from composio import Composio +from dotenv import dotenv_values -from composio_tools.config import ComposioConfig, read_composio_config +from composio_tools.config import ( + DEFAULT_WORKSPACE_USER_ID, + ComposioConfig, + read_composio_config, +) DASHBOARD_URL = "https://app.composio.dev" +#: The repo `.env`, the same file the agent itself reads at import. This module +#: imports nothing that loads it, and an operator running the script has no +#: reason to have exported the variables into their shell. +ENV_FILE = Path(__file__).resolve().parents[2] / ".env" + + +def operator_environment( + env: Mapping[str, str] | None = None, + *, + env_file: Path, +) -> Mapping[str, str]: + """ + What the operator configured: the process environment over the repo `.env`. + + Read rather than loaded — `dotenv_values` returns a mapping instead of + writing into `os.environ` — because nothing else in this process needs the + file's contents, and a script that mutates the environment it read is harder + to test than one that does not. + + Exported variables win, matching `load_dotenv`'s default: an operator who + exports a key for one run gets that key. + """ + if env is not None: + return env + from_file = { + name: value + for name, value in dotenv_values(env_file).items() + if value is not None + } + return {**from_file, **os.environ} + def resolve_shared_toolkit( config: ComposioConfig, requested: str | None @@ -51,11 +93,13 @@ def resolve_shared_toolkit( def main(argv: list[str] | None = None, env: Mapping[str, str] | None = None) -> int: arguments = sys.argv[1:] if argv is None else argv - source = os.environ if env is None else env + source = operator_environment(env, env_file=ENV_FILE) config = read_composio_config( source, - default_user_id=source.get("INTELLIGENCE_CHANNEL_NAME", "open-tag"), + default_user_id=source.get( + "INTELLIGENCE_CHANNEL_NAME", DEFAULT_WORKSPACE_USER_ID + ), ) if config is None: print( @@ -72,14 +116,32 @@ def main(argv: list[str] | None = None, env: Mapping[str, str] | None = None) -> print(message, file=sys.stderr) return 1 + # Pinned when the operator named one. A toolkit can hold several auth + # configs and the project resolves an unpinned one on its own — which is the + # ambiguity `COMPOSIO_AUTH_CONFIGS` exists to settle. The SDK takes the + # mapping when the session is created; `authorize()` has no argument for it. + pinned = config.auth_configs.get(slug) + composio = Composio(api_key=config.api_key) session = composio.sessions.create( user_id=config.workspace_user_id, toolkits=[slug], sandbox={"enable": False}, + # Not optional, and defaulted to True by the SDK: left on, the session + # carries tools that initiate and manage connected accounts. Nothing + # here needs them — `authorize()` mints the link over the session's own + # REST endpoint and does not read this flag — and the runtime's session + # cache already turns them off. + manage_connections=False, + auth_configs={slug: pinned} if pinned else None, ) request = session.authorize(slug) - url = getattr(request, "redirect_url", None) + # Both spellings, the way the connect route reads them. The Python SDK + # answers `redirect_url`; reading only that turns a camelCase answer into + # "Composio returned no link" on a request that worked. + url = getattr(request, "redirect_url", None) or getattr( + request, "redirectUrl", None + ) if not url: print( f"Composio returned no link for {slug}. Check that its auth config " @@ -88,10 +150,11 @@ def main(argv: list[str] | None = None, env: Mapping[str, str] | None = None) -> ) return 1 + pinned_note = f"\nAuth config: {pinned}." if pinned else "" print( f"Open this once, signed in as the account the team should share:\n\n{url}\n\n" f"It connects {slug} for the shared identity " - f'"{config.workspace_user_id}". Anyone in Slack then reaches it.' + f'"{config.workspace_user_id}". Anyone in Slack then reaches it.{pinned_note}' ) return 0 diff --git a/agent/main.py b/agent/main.py index c6d7f67..740a8de 100644 --- a/agent/main.py +++ b/agent/main.py @@ -13,6 +13,7 @@ from agent import build_agent from agent_auth import authorizes_capability, is_authorized from agui import AGENT_DESCRIPTION, AGENT_NAME, build_agui_agent +from composio_tools.config import DEFAULT_WORKSPACE_USER_ID from composio_tools.connect import ConnectRefused, connect_link from composio_tools.runtime import composio_runtime from composio_tools.state import actor_key, is_personal_kind @@ -23,19 +24,15 @@ version="0.1.0", ) -# Allow all origins locally, or set CORS_ALLOW_ORIGINS to restrict access. -_cors_origins = [ - o.strip() - for o in (os.getenv("CORS_ALLOW_ORIGINS") or "*").split(",") - if o.strip() -] or ["*"] -app.add_middleware( - CORSMiddleware, - allow_origins=_cors_origins, - allow_credentials=False, - allow_methods=["*"], - allow_headers=["*"], -) +# Registration order is load-bearing, and it reads backwards: Starlette builds +# the stack so that the middleware added *last* sits outermost. CORS must be the +# outer one. Added first — the way this file used to have it — the secret check +# wraps CORS, and then a browser preflight, which carries no `Authorization` +# because asking whether it may send one is the entire point of a preflight, is +# refused before CORS ever runs. Every 401 also loses its CORS headers, so a +# browser reports an opaque CORS failure instead of the status, and +# `CORS_ALLOW_ORIGINS` is inert exactly where an operator with a wrong secret +# needs to read it. @app.middleware("http") @@ -47,13 +44,36 @@ async def require_shared_secret(request: Request, call_next): connect route does not rely on this — it requires a secret of its own accord, because handing out a bearer capability to an unauthenticated caller has no correct configuration. + + `BaseHTTPMiddleware` in front of an SSE endpoint was measured rather than + assumed: against a real uvicorn socket, chunks arrive at the same moments + with it and without it, and a client disconnect still cancels the generator + at the same chunk. Nothing is buffered and nothing leaks (starlette 1.3.1, + uvicorn 0.51.0, anyio 4.14.2). """ if not is_authorized(request.url.path, request.headers.get("authorization")): return JSONResponse({"error": "unauthorized"}, status_code=401) return await call_next(request) -@app.get("/health") +# Allow all origins locally, or set CORS_ALLOW_ORIGINS to restrict access. +_cors_origins = [ + o.strip() + for o in (os.getenv("CORS_ALLOW_ORIGINS") or "*").split(",") + if o.strip() +] or ["*"] +app.add_middleware( + CORSMiddleware, + allow_origins=_cors_origins, + allow_credentials=False, + allow_methods=["*"], + allow_headers=["*"], +) + + +# HEAD as well as GET: a platform probe that sends HEAD is ordinary, and this +# route answering GET alone made it a 405 that reads like an outage. +@app.api_route("/health", methods=["GET", "HEAD"]) def health(): """Return service health.""" return {"status": "ok", "service": "opentag-agent", "version": "0.1.0"} @@ -89,7 +109,13 @@ def composio_connect(body: ConnectRequest, request: Request): return JSONResponse({"error": "unauthorized"}, status_code=401) runtime = composio_runtime( - default_user_id=os.environ.get("INTELLIGENCE_CHANNEL_NAME", "open-tag") + # The default spelled once, in the module that resolves it. A present + # but empty `INTELLIGENCE_CHANNEL_NAME` reaches here as the empty + # string rather than as this default, and `read_composio_config` falls + # through to the same constant for either. + default_user_id=os.environ.get( + "INTELLIGENCE_CHANNEL_NAME", DEFAULT_WORKSPACE_USER_ID + ) ) if runtime is None: return JSONResponse( diff --git a/agent/tests/test_agent_auth.py b/agent/tests/test_agent_auth.py index 81e5967..e8a7bc4 100644 --- a/agent/tests/test_agent_auth.py +++ b/agent/tests/test_agent_auth.py @@ -30,6 +30,14 @@ def test_health_stays_open_so_the_platform_probe_keeps_working(): assert is_authorized("/health", None, env=env) is True +def test_health_stays_open_however_the_probe_spells_the_path(): + # `/health/` is the same endpoint — the router redirects it to `/health` — + # but the redirect runs after this check, so an exactly-matched public path + # refuses the probe before routing ever sees it. + env = {"AGENT_AUTH_HEADER": "Bearer s3cret"} + assert is_authorized("/health/", None, env=env) is True + + def test_a_capability_is_refused_when_no_secret_is_configured(): # Unlike ordinary traffic, an absent secret is a refusal here: there is no # configuration in which handing connect links to unauthenticated callers is @@ -81,6 +89,22 @@ def spy(left, right): ] +def test_a_header_that_differs_only_past_ascii_is_refused(): + # The vector that can tell the shipped comparison from a broken one. Every + # other non-ASCII case here also differs in its ASCII characters, so an + # implementation that dropped what it could not encode — `encode("ascii", + # "ignore")` — would refuse them for the wrong reason and look correct. + # This one is the configured secret plus one accented character: drop the + # character and it matches, keep it and it must not. + env = {"AGENT_AUTH_HEADER": "Bearer s3cret"} + suffixed = "Bearer s3cret\xe9" + + assert suffixed.encode("ascii", "ignore") == b"Bearer s3cret" + assert header_matches(suffixed, "Bearer s3cret") is False + assert is_authorized("/", suffixed, env=env) is False + assert authorizes_capability(suffixed, env=env) is False + + def test_a_non_ascii_header_is_a_refusal_and_not_a_crash(): # Headers arrive latin-1 decoded and `compare_digest` raises `TypeError` on # non-ASCII `str` rather than returning False, so an accent in a wrong @@ -165,3 +189,50 @@ def test_the_middleware_refuses_a_non_ascii_header_without_erroring( ) assert response.status_code == 401 + + +def test_a_browser_preflight_is_answered_rather_than_refused(client, monkeypatch): + # A browser sends no `Authorization` on a preflight — it cannot, the whole + # point of the preflight is to ask whether it may. So a secret check in + # front of CORS refuses every preflight, and the browser never sends the + # real request. Nothing downstream of this ever sees the traffic. + monkeypatch.setenv("AGENT_AUTH_HEADER", "Bearer s3cret") + + response = client.options( + "/", + headers={ + "Origin": "https://ui.example", + "Access-Control-Request-Method": "POST", + }, + ) + + assert response.status_code == 200 + assert response.headers.get("access-control-allow-origin") is not None + + +def test_a_refusal_carries_the_cors_headers_so_a_browser_can_read_it( + client, monkeypatch +): + # Without them the browser reports a CORS failure instead of the 401, and + # `CORS_ALLOW_ORIGINS` is inert for exactly the responses an operator + # debugging a wrong secret needs to see. + monkeypatch.setenv("AGENT_AUTH_HEADER", "Bearer s3cret") + + response = client.post("/", json={}, headers={"Origin": "https://ui.example"}) + + assert response.status_code == 401 + assert response.headers.get("access-control-allow-origin") is not None + + +def test_the_probe_reaches_health_with_a_trailing_slash(client, monkeypatch): + monkeypatch.setenv("AGENT_AUTH_HEADER", "Bearer s3cret") + + assert client.get("/health/").status_code == 200 + + +def test_the_probe_may_ask_for_health_with_head(client, monkeypatch): + # A platform health check that sends HEAD is ordinary. It used to get 405, + # because the route answered GET alone. + monkeypatch.setenv("AGENT_AUTH_HEADER", "Bearer s3cret") + + assert client.head("/health").status_code == 200 diff --git a/agent/tests/test_composio_config.py b/agent/tests/test_composio_config.py index 92681bd..6850121 100644 --- a/agent/tests/test_composio_config.py +++ b/agent/tests/test_composio_config.py @@ -2,9 +2,15 @@ from __future__ import annotations +import logging + import pytest -from composio_tools.config import ComposioConfigError, read_composio_config +from composio_tools.config import ( + DEFAULT_WORKSPACE_USER_ID, + ComposioConfigError, + read_composio_config, +) def test_no_api_key_reports_unconfigured(): @@ -102,3 +108,88 @@ def test_auth_configs_keep_id_case_and_split_on_the_first_colon_only(): ) assert config is not None assert config.auth_configs == {"linear": "ac_ExAmPle1:aB"} + + +def test_the_workspace_user_id_override_is_what_wins(): + # No test set this variable, so deleting the line that reads it left the + # suite green while every shared call ran as the wrong Composio identity. + config = read_composio_config( + { + "COMPOSIO_API_KEY": "ak_test", + "COMPOSIO_TOOLKITS": "linear", + "COMPOSIO_WORKSPACE_USER_ID": "shared-account", + }, + default_user_id="open-tag", + ) + assert config is not None + assert config.workspace_user_id == "shared-account" + + +def test_a_blank_channel_name_never_becomes_an_empty_user_id(): + # `INTELLIGENCE_CHANNEL_NAME=` is routine, and `.get(name, "open-tag")` + # returns the empty string for it rather than the default. An empty + # Composio user id is a real identity that nothing else ever resolves to, + # so the shared connection lands somewhere no turn looks. + for blank in ("", " "): + config = read_composio_config( + {"COMPOSIO_API_KEY": "ak_test", "COMPOSIO_TOOLKITS": "linear"}, + default_user_id=blank, + ) + assert config is not None + assert config.workspace_user_id == DEFAULT_WORKSPACE_USER_ID + + # A blank override falls through to the default too, rather than winning. + config = read_composio_config( + { + "COMPOSIO_API_KEY": "ak_test", + "COMPOSIO_TOOLKITS": "linear", + "COMPOSIO_WORKSPACE_USER_ID": " ", + }, + default_user_id="open-tag", + ) + assert config is not None + assert config.workspace_user_id == "open-tag" + + +def test_a_key_with_no_toolkits_says_why_the_feature_is_off(caplog): + # Configuring a key and nothing else is a plausible half-finished setup, and + # it used to disable the whole integration in silence: no tools, no error, + # nothing in the log to read. + with caplog.at_level(logging.WARNING): + assert ( + read_composio_config( + {"COMPOSIO_API_KEY": "ak_test"}, default_user_id="open-tag" + ) + is None + ) + + assert "COMPOSIO_TOOLKITS" in caplog.text + assert "COMPOSIO_USER_TOOLKITS" in caplog.text + + +def test_an_absent_key_says_nothing_at_all(caplog): + # Not configuring the feature is not a misconfiguration, and a deployment + # that never wanted Composio must not be told about it once per read. + with caplog.at_level(logging.WARNING): + assert read_composio_config({}, default_user_id="open-tag") is None + + assert caplog.text == "" + + +def test_the_config_is_hashable_the_way_a_frozen_dataclass_promises(): + # `frozen=True` generates `__hash__`, and a dict field made it raise — so + # anything ordinary that hashes a frozen value (a set, a dict key, an + # `lru_cache` argument) crashed on a config that named an auth config, and + # only on that one. + config = read_composio_config( + { + "COMPOSIO_API_KEY": "ak_test", + "COMPOSIO_TOOLKITS": "linear", + "COMPOSIO_AUTH_CONFIGS": "linear:ac_ExAmPle1", + }, + default_user_id="open-tag", + ) + assert config is not None + assert config.auth_configs == {"linear": "ac_ExAmPle1"} + assert isinstance(hash(config), int) + assert {config} diff --git a/agent/tests/test_composio_connect.py b/agent/tests/test_composio_connect.py index 4122b2c..a8847a6 100644 --- a/agent/tests/test_composio_connect.py +++ b/agent/tests/test_composio_connect.py @@ -9,7 +9,7 @@ import composio_tools.runtime as runtime_mod from composio_tools.config import ComposioConfig -from composio_tools.connect import ConnectRefused, connect_link +from composio_tools.connect import ConnectLink, ConnectRefused, connect_link from composio_tools.runtime import ComposioRuntime, reset_composio_runtime from composio_tools.sessions import SessionCache @@ -135,8 +135,13 @@ def test_the_link_is_never_logged(caplog): runtime, _client = runtime_for(sessions) with caplog.at_level(logging.DEBUG): - connect_link(runtime, identity="slack:U1", toolkit="gmail") + result = connect_link(runtime, identity="slack:U1", toolkit="gmail") + # A refused mint logs no link either, so the assertion below holds for the + # one case this test is not about. Establish that a link was minted first, + # or the test passes for the wrong reason. + assert isinstance(result, ConnectLink) + assert result.url == LINK assert LINK not in caplog.text diff --git a/agent/tests/test_composio_connect_cli.py b/agent/tests/test_composio_connect_cli.py index aa7b042..d8fc9f8 100644 --- a/agent/tests/test_composio_connect_cli.py +++ b/agent/tests/test_composio_connect_cli.py @@ -3,6 +3,7 @@ from __future__ import annotations from composio_tools.config import ComposioConfig +import composio_tools.connect_cli as connect_cli from composio_tools.connect_cli import resolve_shared_toolkit @@ -51,3 +52,162 @@ def test_a_toolkit_in_both_lists_is_treated_as_personal(): ) assert slug is None assert "COMPOSIO_USER_TOOLKITS" in message + + +LINK = "https://backend.composio.dev/connect/abc123" + + +class FakeRequest: + """One SDK connection request. `spelling` picks the attribute it carries.""" + + def __init__(self, url=LINK, spelling="redirect_url"): + if url is not None: + setattr(self, spelling, url) + + +class FakeSessions: + def __init__(self, request): + self.created: list[dict] = [] + self._request = request + + def create(self, **kwargs): + self.created.append(kwargs) + return self + + def authorize(self, toolkit): + self.authorized = toolkit + return self._request + + +class FakeComposio: + instances: list["FakeComposio"] = [] + + def __init__(self, api_key=None, request=None): + self.api_key = api_key + self.sessions = FakeSessions(request or FakeRequest()) + FakeComposio.instances.append(self) + + +def install_sdk(monkeypatch, request=None): + """Replace the SDK client, and hand back the one the CLI constructs.""" + FakeComposio.instances.clear() + monkeypatch.setattr( + connect_cli, "Composio", lambda api_key: FakeComposio(api_key, request) + ) + return FakeComposio.instances + + +ENV = { + "COMPOSIO_API_KEY": "ak_test", + "COMPOSIO_TOOLKITS": "linear", + "COMPOSIO_USER_TOOLKITS": "gmail", +} + + +def test_the_operator_path_reads_the_repo_env_file(monkeypatch, tmp_path, capsys): + # Nothing this module imports loads the repo `.env`, and an operator running + # the script has no reason to have exported the variables into their shell. + # Without this the only correct way to connect a shared toolkit exits 1 + # saying Composio is not configured — on a deployment where it is. + env_file = tmp_path / ".env" + env_file.write_text( + "COMPOSIO_API_KEY=ak_from_env_file\n" + "COMPOSIO_TOOLKITS=linear\n" + "COMPOSIO_USER_TOOLKITS=gmail\n" + ) + monkeypatch.setattr(connect_cli, "ENV_FILE", env_file) + for name in ( + "COMPOSIO_API_KEY", + "COMPOSIO_TOOLKITS", + "COMPOSIO_USER_TOOLKITS", + "COMPOSIO_AUTH_CONFIGS", + "COMPOSIO_WORKSPACE_USER_ID", + ): + monkeypatch.delenv(name, raising=False) + instances = install_sdk(monkeypatch) + + assert connect_cli.main(["linear"]) == 0 + + assert instances[0].api_key == "ak_from_env_file" + assert LINK in capsys.readouterr().out + + +def test_the_process_environment_still_wins_over_the_env_file( + monkeypatch, tmp_path +): + # The file is a fallback, not an override: an operator who exports a key for + # one run must get that key, which is how `load_dotenv` already behaves for + # the agent itself. + env_file = tmp_path / ".env" + env_file.write_text("COMPOSIO_API_KEY=ak_from_env_file\nCOMPOSIO_TOOLKITS=linear\n") + monkeypatch.setattr(connect_cli, "ENV_FILE", env_file) + monkeypatch.setenv("COMPOSIO_API_KEY", "ak_exported") + monkeypatch.delenv("COMPOSIO_AUTH_CONFIGS", raising=False) + instances = install_sdk(monkeypatch) + + assert connect_cli.main(["linear"]) == 0 + assert instances[0].api_key == "ak_exported" + + +def test_a_missing_env_file_is_not_an_error(monkeypatch, tmp_path): + monkeypatch.setattr(connect_cli, "ENV_FILE", tmp_path / "absent.env") + install_sdk(monkeypatch) + + assert connect_cli.main(["linear"], env=ENV) == 0 + + +def test_the_link_is_read_whichever_way_the_sdk_spells_it(monkeypatch, capsys): + # The connect route reads both spellings; this path read one, so a camelCase + # response became "Composio returned no link" on a request that worked. + install_sdk(monkeypatch, request=FakeRequest(spelling="redirectUrl")) + + assert connect_cli.main(["linear"], env=ENV) == 0 + assert LINK in capsys.readouterr().out + + +def test_no_link_at_all_is_still_reported(monkeypatch, capsys): + install_sdk(monkeypatch, request=FakeRequest(url=None)) + + assert connect_cli.main(["linear"], env=ENV) == 1 + assert "no link" in capsys.readouterr().err + + +def test_the_session_pins_the_auth_config_the_operator_named(monkeypatch, capsys): + # `COMPOSIO_AUTH_CONFIGS` was parsed, documented and never sent, so a + # toolkit with more than one auth config connected through whichever one the + # project happened to resolve — the case the variable exists for. + instances = install_sdk(monkeypatch) + + assert ( + connect_cli.main( + ["linear"], env={**ENV, "COMPOSIO_AUTH_CONFIGS": "linear:ac_ExAmPle1"} + ) + == 0 + ) + + assert instances[0].sessions.created[0]["auth_configs"] == { + "linear": "ac_ExAmPle1" + } + assert "ac_ExAmPle1" in capsys.readouterr().out + + +def test_an_unpinned_toolkit_sends_no_auth_config(monkeypatch): + instances = install_sdk(monkeypatch) + + assert connect_cli.main(["linear"], env=ENV) == 0 + + assert instances[0].sessions.created[0]["auth_configs"] is None + + +def test_the_operator_session_carries_no_connection_management_tools(monkeypatch): + # The SDK defaults this to True. Left on, the session the operator opens + # carries tools that initiate and manage connected accounts — the second + # path the connect flow exists to close. Set in the runtime's session cache + # already; this path builds its own session and missed it. + instances = install_sdk(monkeypatch) + + assert connect_cli.main(["linear"], env=ENV) == 0 + + created = instances[0].sessions.created[0] + assert created["manage_connections"] is False + assert created["sandbox"] == {"enable": False} From 3ce339b101292f9c596d1461de2c5abe5f8b75ee Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 2 Sep 2026 20:31:05 +0200 Subject: [PATCH 15/23] docs(composio): say what the code does, and delete what it does not MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Group A9 of the CR: every claim these docs make about the Composio change is now checked against the code, or gone. Personal toolkits are the headline. No released @copilotkit/channels forwards `channelActor` — probed in the installed tree, where the field appears in no package — so on the pinned 0.9.0 every turn reads as anonymous and personal toolkits are configured and silent. The old advice, "check the pin in package.json", could not have worked: the preview build of the forwarding PR reports 0.9.0 too. The docs now say the state plainly, hand over a probe that answers the question the version number cannot, and say what changes when a release lands. "Adapter-free" and "no platform credential belongs here" were true until this change attached a direct Slack adapter, and "nothing here uses Socket Mode" was true until it took an xapp- token to do it. All three are scoped to the managed path they still describe, and the exception is documented once, with the reason it exists and the steps to mint the token. The rest are smaller and the same shape: `COMPOSIO_AUTH_CONFIGS` is no longer described by who reads it, the approvals validation is described as skipped when Composio is unconfigured, `INTELLIGENCE_CHANNEL_NAME` is documented as the agent variable it now also is, the Railway section enumerates the variables this change added instead of claiming a list that omits them, and the AWS secret JSON gains the three fields ECS resolves — a task missing them fails to start and rolls the deployment back. `.env.example` no longer documents AGENT_AUTH_HEADER twice, no longer breaks `source` on an unquoted Bearer value, and no longer ships a sample that trips the double-configuration warning shipped alongside it. Co-Authored-By: Claude Opus 5 (1M context) --- .env.example | 29 +++++++---- AGENTS.md | 20 ++++--- README.md | 26 ++++++---- deployment/aws/README.md | 23 ++++++++- setup.md | 109 ++++++++++++++++++++++++++++----------- 5 files changed, 149 insertions(+), 58 deletions(-) diff --git a/.env.example b/.env.example index 833fd76..3027af4 100644 --- a/.env.example +++ b/.env.example @@ -14,7 +14,6 @@ export OPENAI_API_KEY=sk-... # Create a key at https://platform.ope # -- Runtime Overrides (Optional) -- # export LOG_LEVEL=debug # Defaults to "error"; Channel lifecycle breadcrumbs log at "warn". -# export AGENT_AUTH_HEADER="Bearer ..." # Forwarded to an agent that requires authentication. # export INTELLIGENCE_API_URL=https://api.intelligence.copilotkit.ai # export INTELLIGENCE_GATEWAY_WS_URL=wss://realtime.intelligence.copilotkit.ai # export INTELLIGENCE_LEARNING_CONTAINER_ID=support-quality # Existing container in the API key's project. @@ -63,29 +62,36 @@ export OPENAI_API_KEY=sk-... # Create a key at https://platform.ope # export NOTION_MCP_AUTH_TOKEN=your-remote-mcp-bearer-token # -- Composio (Optional) -- -# Connect any Composio toolkit without writing an MCP block. Two steps per app: -# add the toolkit at https://app.composio.dev, then name its slug below. +# Connect any Composio toolkit without writing an MCP block. Three steps per +# app: add the toolkit at https://app.composio.dev, name its slug below, and +# restart the agent. A shared toolkit is also connected once, see setup.md. # Slugs are Composio's own — lowercase, unspaced: `googlecalendar`, not `gcal`. # COMPOSIO_API_KEY is the master switch; without it nothing is constructed. # export COMPOSIO_API_KEY=ak_... # # One shared identity everyone in Slack reaches. Connect each of these once with # cd agent && uv run python -m composio_tools.connect_cli -# export COMPOSIO_TOOLKITS=linear,jira +# Do not also configure the same app over MCP: `linear` here plus LINEAR_API_KEY +# above gives the agent two sets of Linear tools and startup says so. +# export COMPOSIO_TOOLKITS=jira,salesforce # # Each person's own account. They connect it themselves from a thread. +# NOT YET USABLE: this needs a @copilotkit/channels that forwards the speaker, +# and no released version does. Every turn reads as anonymous until one ships, +# so these toolkits are configured and silent. See setup.md#composio. # export COMPOSIO_USER_TOOLKITS=gmail,googlecalendar # # on (default) | off — whether a call that is not a read waits for a person. # `destructive` and `writes` are the old spellings; both still parse as `on`. # export COMPOSIO_APPROVALS=on # -# The Composio user_id shared toolkits act as. Defaults to the Channel name. +# The Composio user_id shared toolkits act as. Defaults to the agent's own +# INTELLIGENCE_CHANNEL_NAME, and to "open-tag" when that is unset there. # export COMPOSIO_WORKSPACE_USER_ID=open-tag # -# Read only by the connect script: pins which auth config a shared toolkit -# connects against when it has several. Ids are case-sensitive. -# export COMPOSIO_AUTH_CONFIGS=linear:ac_ExAmPle1 +# Pins which auth config a toolkit connects against when it has several. +# Ids are case-sensitive. Unset, Composio picks one from the project. +# export COMPOSIO_AUTH_CONFIGS=jira:ac_ExAmPle1 # -- Slack direct delivery (Optional; needed for COMPOSIO_USER_TOOLKITS) -- # Intelligence owns the Slack edge and no Slack token is otherwise needed here. @@ -95,6 +101,7 @@ export OPENAI_API_KEY=sk-... # Create a key at https://platform.ope # export SLACK_APP_TOKEN=xapp-... # -- Agent authentication (Optional; required to connect personal accounts) -- -# Sent by the runtime and now checked by the agent. Without it the connect -# endpoint refuses to mint a link, since that link is a bearer capability. -# export AGENT_AUTH_HEADER=Bearer generate-a-long-random-string +# One shared secret, the same value on both services: the runtime sends it and +# the agent checks it. Without it the connect endpoint refuses to mint a link, +# since that link is a bearer capability. Quote it — the value contains a space. +# export AGENT_AUTH_HEADER="Bearer generate-a-long-random-string" diff --git a/AGENTS.md b/AGENTS.md index 1883b26..0889ec8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,7 +42,8 @@ files. | Agent | `agent/agent.py` | LangGraph deep agent served over AG-UI | | AG-UI adapter | `agent/agui.py` | Slack recursion limit and user-facing graph-stop handling | | Persona | `agent/prompts/` | `system.py` is the base system prompt | -| Approval gate | `agent/write_confirmation.py` | Emits `confirm_write` before Linear or Notion writes | +| Approval gate | `agent/write_confirmation.py` | Emits `confirm_write` before a Linear, Notion, or Composio write | +| Composio | `agent/composio_tools/` | Toolkit sessions, per-person identity, effect classification, connect links | | Coder | `agent/coding/` | GitHub credentials, Daytona sandbox, repository publish tools, coder prompt | | Coder skills | `agent/coding/skills/` | Committed skills. Do not put them in `agent/skills/` | | Deployment | `.railway/railway.ts` | Two services, declared as code | @@ -81,9 +82,9 @@ you actually ran; do not claim a check that did not run. Intelligence project race per delivery and the loser is silently starved. Give a local runtime its own project, key, and Channel name — never reuse `open-tag`. - **Slash commands and modals are registered but unverified on the managed - path.** Delivery depends on the generated Slack manifest declaring - `slash_commands`; as of the 0.7.0 verification it declared none. Do not describe - them as working without sending a real command. + path.** Delivery depends on the generated Slack manifest, which Intelligence + produces server-side — nothing in this repository decides it. Do not describe + them as working without sending a real command against your own Channel. - **Trigger routing is not symmetric.** A mentioned turn goes to `onMention` if registered and falls back to `onMessage`; an unmentioned turn reaches `onMessage` only. `onMention` subscribes the thread. Always verify with a @@ -97,9 +98,14 @@ you actually ran; do not claim a check that did not run. assertion. Three copies of `0.7.0` drifted at once when the deps were bumped, and one of them broke the build. `app/cleanup.test.ts` asserts the pin *shape* for this reason. -- **No Slack or Teams credential belongs in this repository.** Intelligence owns - the adapters. One root `.env` configures both services; the Python agent loads - it explicitly for local development. +- **One Slack credential pair belongs here, and only one.** Intelligence owns + the adapters, so no Teams credential and no Slack signing secret goes in this + repository. The exception is `SLACK_BOT_TOKEN` plus `SLACK_APP_TOKEN`, which + attach a direct Slack adapter in Socket Mode so a Composio connect link can be + delivered to one person privately — the managed adapter reports + `supportsEphemeral: false`. Leave them unset and the managed path is the only + path. One root `.env` configures both services; the Python agent loads it + explicitly for local development. - **`@copilotkit/channels` and `@copilotkit/runtime` upgrade together.** They ship as a tested pair. - Commit messages follow the conventional prefixes already in the log (`feat:`, diff --git a/README.md b/README.md index 14395e4..a3ee259 100644 --- a/README.md +++ b/README.md @@ -321,7 +321,7 @@ agent (Python + LangGraph deepagents) ├── PostHog MCP (optional, read-only) ├── Linear MCP (optional) ├── Notion MCP (optional remote server) - └── Composio toolkits (optional; shared or per-person) + └── Composio toolkits (optional; shared team accounts) ``` | You run | CopilotKit Intelligence manages | @@ -330,16 +330,24 @@ agent (Python + LangGraph deepagents) | The long-running Node Channels runtime | Platform ingress and credentialed delivery | | Deployment, state, and logs | Runtime registration, health, and reconnects | -Neither leg is Socket Mode, and neither needs a tunnel or a public URL of your -own. Slack reaches Intelligence over HTTPS, authenticated by the signing secret -Intelligence holds. Intelligence reaches your runtime over a websocket your -process opens outbound, authenticated by `INTELLIGENCE_API_KEY`. +Neither of those legs is Socket Mode, and neither needs a tunnel or a public URL +of your own. Slack reaches Intelligence over HTTPS, authenticated by the signing +secret Intelligence holds. Intelligence reaches your runtime over a websocket +your process opens outbound, authenticated by `INTELLIGENCE_API_KEY`. There is one canonical runtime host: [`server.ts`](./server.ts). [`app/index.ts`](./app/index.ts) composes one `CopilotKitIntelligence`, one -`CopilotRuntime`, and one adapter-free managed Channel. Intelligence owns the -Slack and Microsoft Teams adapters, their credentials, and attachments — no -platform credential belongs in this repository's environment. +`CopilotRuntime`, and one managed Channel that is adapter-free by default. +Intelligence owns the Slack and Microsoft Teams adapters, their credentials, and +attachments. + +Composio's per-person toolkits are the one thing that puts platform credentials +here: `SLACK_BOT_TOKEN` and `SLACK_APP_TOKEN` attach a direct Slack adapter, in +Socket Mode, purely so a connect link can reach one person privately — the +managed adapter reports `supportsEphemeral: false` and cannot. `AGENT_AUTH_HEADER` +is a shared secret between the two services and is required before the agent will +mint such a link. Leave all three unset and nothing changes. See +[`setup.md`](./setup.md#composio). `@copilotkit/channels` and `@copilotkit/runtime` are pinned for reproducible deploys. [`package.json`](./package.json) is the source of truth for both @@ -362,7 +370,7 @@ knowledge work, and renders UI from model knowledge. | `GITHUB_PERSONAL_ACCESS_TOKEN` | Read-only repository, code, PR, and CI search | | `POSTHOG_PERSONAL_API_KEY` | PostHog analytics, read-only (use the **MCP Server** key preset) | | `LINEAR_API_KEY` | Hosted Linear MCP | -| `COMPOSIO_API_KEY` | Composio toolkits, shared or per-person (see setup.md) | +| `COMPOSIO_API_KEY` | Composio toolkits under one shared team account (see setup.md; per-person accounts need an unreleased `@copilotkit/channels`) | | `NOTION_MCP_URL` + `NOTION_MCP_AUTH_TOKEN` | Remote Notion MCP; setting only one disables it | | `DAYTONA_API_KEY` + a PAT or GitHub App | Coding subagent: edit in Daytona, then push and publish a draft PR after `confirm_write` | diff --git a/deployment/aws/README.md b/deployment/aws/README.md index 44ac2dd..f1c6dd5 100644 --- a/deployment/aws/README.md +++ b/deployment/aws/README.md @@ -72,7 +72,10 @@ Create one JSON secret with these fields: "GITHUB_CODER_TOKEN": "", "POSTHOG_PERSONAL_API_KEY": "", "LINEAR_API_KEY": "", - "NOTION_MCP_AUTH_TOKEN": "" + "NOTION_MCP_AUTH_TOKEN": "", + "COMPOSIO_API_KEY": "", + "SLACK_BOT_TOKEN": "", + "SLACK_APP_TOKEN": "" } ``` @@ -80,6 +83,12 @@ Only `INTELLIGENCE_API_KEY` and `OPENAI_API_KEY` are required by the standard deployment. Every JSON field must exist because ECS resolves each one when the task starts; use an empty string for an unused integration. +**Upgrading an existing deployment: add the three new fields to the secret +before you deploy.** `COMPOSIO_API_KEY`, `SLACK_BOT_TOKEN`, and `SLACK_APP_TOKEN` +are new in this release. A task whose secret is missing any of them fails to +start with `does not contain the specified JSON key` and the deployment rolls +back — empty strings are enough, and they leave every feature off. + Create a second Secrets Manager secret for Datadog. Its entire plaintext value must be the raw Datadog API key, not JSON. @@ -115,10 +124,22 @@ These CDK context values become container environment variables: | `posthogMcpUrl` | `POSTHOG_MCP_URL` | Hosted read-only PostHog MCP | | `linearMcpUrl` | `LINEAR_MCP_URL` | Hosted Linear MCP | | `notionMcpUrl` | `NOTION_MCP_URL` | Unset | +| `composioToolkits` | `COMPOSIO_TOOLKITS` | Unset | +| `composioUserToolkits` | `COMPOSIO_USER_TOOLKITS` | Unset | +| `composioApprovals` | `COMPOSIO_APPROVALS` | Unset, so the agent's own default `on` applies | +| `composioWorkspaceUserId` | `COMPOSIO_WORKSPACE_USER_ID` | Unset | `githubAppPrivateKeySecretArn` optionally maps a separate raw Secrets Manager secret to `GITHUB_APP_PRIVATE_KEY_BASE64` on the agent container. +Set `composioWorkspaceUserId` explicitly whenever you use `composioToolkits`. +Left unset, the Composio `user_id` that shared toolkits act as is decided by the +agent container's own fallback rather than by the `channelName` context value — +and the connect script an operator runs locally reads that id from their own +environment. If the two disagree, the link connects an account no deployed turn +ever looks up. Setting it on both sides is what makes them agree. See +[`../../setup.md`](../../setup.md#composio). + The AWS task fixes `AGENT_URL` to `http://127.0.0.1:8123/`, the runtime port to `3000`, and the agent port to `8123` because both containers share one task. Users running the images elsewhere can set `AGENT_URL`, `PORT`, `SERVER_HOST`, diff --git a/setup.md b/setup.md index 5f81667..1385694 100644 --- a/setup.md +++ b/setup.md @@ -22,10 +22,14 @@ supported; Discord, Telegram, and WhatsApp are coming soon. | Railway topology | [`.railway/railway.ts`](./.railway/railway.ts) | Two services sourced from OpenTag `main` | | AWS topology | [`deployment/aws/`](./deployment/aws) | One private Fargate task, images, secrets, and Datadog log forwarding | -The host always uses the Intelligence-owned runtime. It declares one -adapter-free Channel using the configured name. The Slack and Microsoft Teams -adapters, their credentials, and attachments are configured only in -Intelligence — never here. +The host always uses the Intelligence-owned runtime. By default it declares one +adapter-free Channel using the configured name, and the Slack and Microsoft +Teams adapters, their credentials, and attachments are configured only in +Intelligence. There is exactly one exception, and it exists for Composio's +personal toolkits: setting `SLACK_BOT_TOKEN` and `SLACK_APP_TOKEN` attaches a +direct Slack adapter in this repository as well, because a connect link has to +reach one person privately and the managed adapter cannot post such a message. +See [Composio](#composio). ## Install @@ -85,9 +89,10 @@ or Channel slug. | `COMPOSIO_API_KEY` | No | Master switch for Composio toolkits. Absent means the feature is never constructed | | `COMPOSIO_TOOLKITS` | No | Toolkit slugs everyone shares one connection for | | `COMPOSIO_USER_TOOLKITS` | No | Toolkit slugs scoped to whoever sent the message | -| `COMPOSIO_APPROVALS` | No | `on` (default) or `off`. `destructive` and `writes` are the old spellings and still parse as `on`. An unrecognized value fails startup | -| `COMPOSIO_WORKSPACE_USER_ID` | No | Composio `user_id` the shared toolkits run as; defaults to `INTELLIGENCE_CHANNEL_NAME` | -| `COMPOSIO_AUTH_CONFIGS` | No | **Read only by the connect script, never by a turn.** `toolkit:auth_config_id` pairs, ids case-sensitive; pins which auth config a *shared* toolkit connects against when it has several | +| `COMPOSIO_APPROVALS` | No | `on` (default) or `off`. `destructive` and `writes` are the old spellings and still parse as `on`. An unrecognized value fails startup, but only once Composio is configured — with no API key or no toolkit list the variable is never read | +| `COMPOSIO_WORKSPACE_USER_ID` | No | Composio `user_id` the shared toolkits run as. Defaults to this service's `INTELLIGENCE_CHANNEL_NAME`, and to `open-tag` when that variable is not set on the agent | +| `INTELLIGENCE_CHANNEL_NAME` | No | Also read here, not only by the runtime: it is the default shared-toolkit `user_id` above. The agent's own fallback is `open-tag`, so an overridden Channel name has to be set on **both** services or the shared identity differs between them | +| `COMPOSIO_AUTH_CONFIGS` | No | `toolkit:auth_config_id` pairs, ids case-sensitive. Pins which auth config a toolkit connects against when it has several. Unset, Composio picks one from the project | | `AGENT_AUTH_HEADER` | No | The runtime's shared secret. Checked when set, and **required** before a Composio connect link is minted | | `GITHUB_PERSONAL_ACCESS_TOKEN` | No | Enables read-only GitHub repository, code, PR, Actions-run, and job-log search. It remains the legacy coding fallback | | `GITHUB_MCP_URL` | No | Overrides the hosted GitHub MCP URL; OpenTag still sends read-only headers | @@ -150,10 +155,10 @@ The AG-UI endpoint is `http://localhost:8123/`; `/health` reports the | `INTELLIGENCE_API_URL` | No | Defaults to `https://api.intelligence.copilotkit.ai` | | `INTELLIGENCE_GATEWAY_WS_URL` | No | Defaults to `wss://realtime.intelligence.copilotkit.ai` | | `AGENT_AUTH_HEADER` | No | Shared secret between runtime and agent. Sent as `Authorization`; the agent checks it when set, and **requires** it before minting a Composio connect link | -| `SLACK_BOT_TOKEN` | No | With `SLACK_APP_TOKEN`, delivers Slack directly instead of through Intelligence. Needed only so a Composio connect link can reach one person privately | +| `SLACK_BOT_TOKEN` | No | With `SLACK_APP_TOKEN`, attaches a direct Slack adapter to this Channel. Needed only so a Composio connect link can reach one person privately | | `SLACK_APP_TOKEN` | No | Socket Mode token; required with `SLACK_BOT_TOKEN` and refused alone | | `PORT` | No | Channel HTTP port; defaults to `3000` | -| `LOG_LEVEL` | No | Defaults to `error`; use `debug` to see Channel lifecycle breadcrumbs | +| `LOG_LEVEL` | No | Defaults to `error`. Channel lifecycle breadcrumbs are emitted at `warn`, so set `warn` or lower to see them | | `MERMAID_URL` | No | Overrides the Mermaid browser bundle URL used by diagram rendering | The API key selects a project; the Channel name selects a Channel inside it. @@ -236,8 +241,17 @@ has both already. Its Slack handoff never asks anyone to paste a secret into cha There is no app-level `xapp-` token on the managed path. Slack reaches Intelligence over HTTPS at an Intelligence-hosted Request URL, authenticated by the signing secret Intelligence holds, and Intelligence reaches your runtime -over a websocket your process opens outbound. Nothing here uses Socket Mode, and -a Slack app configured for Socket Mode installs green and delivers nothing. +over a websocket your process opens outbound. Managed delivery never uses Socket +Mode, and a Slack app configured only for Socket Mode installs green and +delivers nothing through it. + +Composio's personal toolkits are the one feature that does need an `xapp-` +token, because they need a direct Slack adapter for private delivery and +`@copilotkit/channels-slack` runs that adapter in Socket Mode by default +(`socketMode: true`; the `xapp-` token is what it opens the socket with). Mint +one in the same Slack app under **Basic Information → App-Level Tokens** with +the `connections:write` scope, and enable **Socket Mode**. Nothing else in +OpenTag reads it. `copilotkit channels add --adapter teams --provision` can create the provider-side Teams app for you. Two Teams gates stay user-owned regardless: @@ -278,10 +292,10 @@ Mentions, messages, and button and select clicks are the proven managed-path triggers — interactivity is enabled deliberately, which is what makes human-in-the-loop fire. **Slash commands and modals are registered in code but their managed-path delivery depends on the Channel's generated Slack manifest -declaring them.** As of the last verification against `@copilotkit/channels` -0.7.0 the generated manifest declared no `slash_commands` and `view_submission` -was not handled, so those handlers compiled, started, reported online, and never -fired. Send a real command and submit a real modal before relying on either. +declaring them**, which is decided server-side by Intelligence rather than by +anything in this repository. A handler that is never delivered still compiles, +starts, and reports online, so send a real command and submit a real modal +against your own Channel before relying on either. Before a Linear or Notion mutation reaches MCP, a Python interceptor emits `confirm_write`. The Channel posts an approval card, and the button resumes the @@ -350,7 +364,7 @@ lives in the Python agent, alongside every other capability, and is gated by the same `confirm_write` card that already guards a Linear or Notion write. There is one approval mechanism in this product, not two. -Setup is **two steps per app**, not one: +Setup is **three steps per app**, not one: 1. Add the toolkit at . That creates its auth config. 2. Add its slug to `COMPOSIO_TOOLKITS` or `COMPOSIO_USER_TOOLKITS`. A **shared** @@ -380,8 +394,11 @@ toolkit lists empty is equally inert. #### Shared team accounts versus personal ones `COMPOSIO_TOOLKITS` runs every Slack user through **one** connection, under the -Composio `user_id` in `COMPOSIO_WORKSPACE_USER_ID` (defaulting to -`INTELLIGENCE_CHANNEL_NAME`). That is right for the team's Linear or Jira. +Composio `user_id` in `COMPOSIO_WORKSPACE_USER_ID` — defaulting to the agent's +own `INTELLIGENCE_CHANNEL_NAME`, and to `open-tag` when that is unset there. +That is right for the team's Linear or Jira. The connect script below reads the +same two variables from wherever you run it, so an id that differs between your +shell and the deployment connects an account no turn will look up. `COMPOSIO_USER_TOOLKITS` scopes to whoever spoke, keyed by their verified platform actor **and** the platform it came from — a provider id is unique only @@ -407,13 +424,27 @@ are: deployment never passes. It is a test button. The connect script above is the only correct path. -**Personal toolkits need a `@copilotkit/channels` that forwards the actor.** The -agent learns who spoke from `forwardedProps.channelActor`, which the Channel -started sending in the release carrying -[CopilotKit#6826](https://github.com/CopilotKit/CopilotKit/pull/6826). On an -older pin the actor never arrives, so every turn reads as anonymous: shared -toolkits work, personal ones silently offer nothing. Check the pin in -`package.json` before debugging anything else. +**Personal toolkits do not work on any released `@copilotkit/channels` yet.** +The agent learns who spoke from `forwardedProps.channelActor`, which +[CopilotKit#6826](https://github.com/CopilotKit/CopilotKit/pull/6826) adds and +which no published version sends — including the pinned 0.9.0, the current +release. So on a clean install of this repository every turn reads as anonymous: +shared toolkits work exactly as described below, and personal ones silently +offer nothing. `search_my_tools` lists no personal tool and no Connect card is +posted. Nothing is misconfigured when that happens, and there is no environment +variable that changes it. + +The version number cannot tell you when that changes, because a preview build of +the forwarding PR reports 0.9.0 too. Ask the installed package instead: + +```bash +grep -rl channelActor node_modules/.pnpm +``` + +No output means the actor is not forwarded and personal toolkits are inert. Once +a release carrying it lands, bump the pin in [`package.json`](./package.json), +reinstall, and the same command prints the packages that carry the field — +personal toolkits then need only the two things listed below. That forwarded value is the only thing the agent will treat as an identity, and four rules follow from it. They fail closed — each one costs access to a @@ -430,7 +461,10 @@ personal toolkit and none of them grants it: read as anonymous rather than sharing a namespace with everybody else's. - Only `kind: "human"` gets a personal identity. A `bot`, `app` or `system` actor — a workflow posting on somebody's behalf — reaches the shared toolkits - and no personal one, and cannot be minted a connect link. + and no personal one, and cannot be minted a connect link. The Channels SDK + documents `kind` as the provider's own metadata rather than an authorization + claim, which is exactly why it is read as a filter and never as a grant: it + can only take a personal toolkit away, never hand one over. Personal toolkits need two more things: @@ -489,10 +523,25 @@ The IaC file declares exactly: `runtime.AGENT_URL` references the agent's Railway private domain and port. Production Intelligence URLs are literal configuration, the API key is -preserved, and the Channel name is `open-tag`. `AGENT_DISPLAY_NAME` is preserved -independently on both services and must match when overridden. `OPENAI_API_KEY` -is required on `agent`; Tavily, Daytona/coder, GitHub, PostHog, Linear, and the -paired remote Notion variables are optional preserved settings. +preserved, and the Channel name is the literal `open-tag` on **both** services — +the agent's copy is what shared Composio toolkits default their `user_id` to. +`AGENT_DISPLAY_NAME` is preserved independently on both services and must match +when overridden. `OPENAI_API_KEY` is required on `agent`; Tavily, Daytona/coder, +GitHub, PostHog, Linear, and the paired remote Notion variables are optional +preserved settings. + +The variables this change added are preserved too, and which service carries +them is the whole design: + +- On `agent`: `COMPOSIO_API_KEY`, `COMPOSIO_TOOLKITS`, `COMPOSIO_USER_TOOLKITS`, + `COMPOSIO_APPROVALS`, `COMPOSIO_WORKSPACE_USER_ID`, `COMPOSIO_AUTH_CONFIGS`. + The toolkits live in the agent, so the Composio credential never reaches the + runtime. +- On `runtime`: `SLACK_BOT_TOKEN` and `SLACK_APP_TOKEN`, only so a connect link + can be delivered privately. +- On both: `AGENT_AUTH_HEADER`. It is a shared secret, so the two values have to + match; they are preserved independently and Railway will not reconcile them + for you. Evaluate the configuration locally without applying it: From 949b5e40ffd7b2a30457bc391093d11baef1ad5c Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 2 Sep 2026 20:37:24 +0200 Subject: [PATCH 16/23] fix(composio): deliver the connect link, and keep credentials out of threads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The connect button did nothing on the default deployment. Both `postEphemeral` results were discarded, and the SDK reports a non-delivery by resolving `null` or `{ ok: false }` rather than by throwing — which is exactly what the managed Intelligence adapter does, because it implements no ephemeral message at all. The minted link was dropped with no thread message and no log line. The test that covered this mocked `null` and asserted nothing about it. Delivery is now tiered and never silent: ask for the DM fallback, check what came back, and when nothing was delivered say so in the thread — never the link, because whoever completes a connect flow binds their account to the id it was minted for. The operator log names what to configure. The "I could not tell who clicked" notice went to the literal string "unknown", a user id nobody can receive; it goes to the thread now. Every step is guarded: nothing awaits this handler, so a rejection escaping it was an unhandled one. `requestConnectLink` gained the reasons it was missing. It had no timeout, so its own "try again shortly" sentence was unreachable; the `new URL()` call and header build shared the fetch's unbound `catch {}`, so a malformed AGENT_URL read as a transient failure forever and logged nothing; `>= 500` discarded the agent's own actionable 503 ("Composio is not configured on this deployment."); and `.catch(() => null)` reported an unreadable body and a body with no link as the same thing. On the security side: a model-controlled toolkit slug reached a PUBLIC mrkdwn post unescaped, where `` rendered as a live hyperlink. A slug is an identifier, so `normalizeToolkit` admits the identifier charset and nothing else — checked at both entry points rather than escaped at each render site. A mismatched shared secret showed the person the agent's bare word "unauthorized"; 401 and 403 now produce a sentence naming no credential and no variable, and any other pass-through body has the secret scrubbed out of it. The variable name goes to the log. A minted URL is checked for scheme and for the characters that end the url half of `` before it is rendered. Also: AGENT_AUTH_HEADER was the one variable read untrimmed, and a trailing newline is not a legal header value — it breaks all agent traffic at once. `??` let INTELLIGENCE_API_URL, _GATEWAY_WS_URL and _CHANNEL_NAME set to "" defeat their defaults, which is how a deploy platform's UI spells "unset". `createAgentFactory` decided on truthiness, silently dropping "" and putting " " on the wire. Tests: every fix was written red first and then mutation-checked — reverted, confirmed failing, restored. That includes four behaviours that were already correct in server.ts but untested: signal-initiated shutdown (emitting SIGINT and then calling `shutdown()` proved nothing, because the second call returns the memoized promise, so both handlers could be deleted and the suite stayed green), the listen-error listener, the AggregateError that reports every resource that failed to stop, and `onShutdownError`. Nothing built a Channel from `slackDirect` either. Call sites of what changed: - `normalizeToolkit` (new, app/tools/composio-connect.ts): called from app/tools/connect-app.tsx and app/tools/connect-click.tsx, plus the tests for both and for composio-connect. - `DEFAULT_CONNECT_TIMEOUT_MS` (new): the default of the new optional `ConnectRequestInput.timeoutMs`, set only by tests. - `requestConnectLink`: called from app/tools/connect-click.tsx (as the default of `deps.request`) and its own tests. Signature is additive. - `handleConnectClick`: `deps` gained an optional `readEnvironment`. The one production call site, app/human-in-the-loop/connect-account.tsx:42, passes two arguments and is unaffected. - `createAgentFactory` now throws on a blank-but-set secret: called from createOpenTagApplication (app/index.ts) and app/server.test.ts. - `readEnvironment` now trims and treats blank as unset: called from app/index.ts, app/tools/connect-click.tsx, and app/env.test.ts. - `connectEndpoint` unchanged. Co-Authored-By: Claude Opus 5 (1M context) --- app/env.test.ts | 89 ++++++ app/env.ts | 29 +- app/index.ts | 30 +- app/server.test.ts | 199 +++++++++++++- app/tools/__tests__/composio-connect.test.ts | 273 ++++++++++++++++++- app/tools/__tests__/connect-app.test.tsx | 44 +++ app/tools/__tests__/connect-click.test.tsx | 195 ++++++++++++- app/tools/composio-connect.ts | 224 +++++++++++++-- app/tools/connect-app.tsx | 19 +- app/tools/connect-click.tsx | 200 ++++++++++++-- 10 files changed, 1223 insertions(+), 79 deletions(-) diff --git a/app/env.test.ts b/app/env.test.ts index 2370663..2a1907f 100644 --- a/app/env.test.ts +++ b/app/env.test.ts @@ -26,6 +26,33 @@ describe("readEnvironment", () => { ).toThrow("Missing required env var: INTELLIGENCE_API_KEY"); }); + it.each(["", " ", "\n"])( + "treats a required variable set to %j as missing", + (blank) => { + // Same reasoning as the optional ones: a declared-but-empty variable is + // how a deploy platform's UI says "not set", and an all-whitespace + // AGENT_URL fails much later, inside `new URL()`, with no name attached. + expect(() => + readEnvironment({ ...requiredEnvironment, AGENT_URL: blank }), + ).toThrow("Missing required env var: AGENT_URL"); + expect(() => + readEnvironment({ ...requiredEnvironment, INTELLIGENCE_API_KEY: blank }), + ).toThrow("Missing required env var: INTELLIGENCE_API_KEY"); + }, + ); + + it("trims the required variables it does accept", () => { + expect( + readEnvironment({ + AGENT_URL: " http://localhost:8123/ ", + INTELLIGENCE_API_KEY: " cpk_test ", + }), + ).toMatchObject({ + agentUrl: "http://localhost:8123/", + intelligenceApiKey: "cpk_test", + }); + }); + it("uses the Intelligence, channel-name, and port defaults", () => { expect(readEnvironment(requiredEnvironment)).toMatchObject({ agentDisplayName: "OpenTag", @@ -95,6 +122,68 @@ describe("readEnvironment", () => { expect(readEnvironment(requiredEnvironment).agentAuthHeader).toBeUndefined(); }); + it.each(["", " ", "\n"])( + "treats an AGENT_AUTH_HEADER of %j as unset rather than as a secret", + (AGENT_AUTH_HEADER) => { + // A blank value is truthy everywhere it is checked and authorizes + // nothing, so it reads as "configured" while every request comes back + // 401. + expect( + readEnvironment({ ...requiredEnvironment, AGENT_AUTH_HEADER }) + .agentAuthHeader, + ).toBeUndefined(); + }, + ); + + it("trims AGENT_AUTH_HEADER, because a trailing newline is not a header value", () => { + // Every neighbouring variable is trimmed and this one was not. A value + // pasted with a newline makes `fetch` reject the request outright, so all + // agent traffic fails at once with nothing pointing at the cause. + expect( + readEnvironment({ + ...requiredEnvironment, + AGENT_AUTH_HEADER: " Bearer s3cret\n", + }).agentAuthHeader, + ).toBe("Bearer s3cret"); + }); + + it.each(["", " "])( + "falls back to the Intelligence defaults when the overrides are %j", + (blank) => { + // `??` only replaces `undefined`, so a variable declared and left empty — + // the normal shape of an unset value in a deploy platform's UI — became + // an empty URL and an empty channel name. Every other variable here uses + // `||` and treats blank as unset. + expect( + readEnvironment({ + ...requiredEnvironment, + INTELLIGENCE_API_URL: blank, + INTELLIGENCE_GATEWAY_WS_URL: blank, + INTELLIGENCE_CHANNEL_NAME: blank, + }), + ).toMatchObject({ + intelligenceApiUrl: DEFAULT_INTELLIGENCE_API_URL, + intelligenceGatewayWsUrl: DEFAULT_INTELLIGENCE_GATEWAY_WS_URL, + channelName: DEFAULT_INTELLIGENCE_CHANNEL_NAME, + }); + }, + ); + + it("trims the Intelligence overrides it does keep", () => { + expect( + readEnvironment({ + ...requiredEnvironment, + INTELLIGENCE_API_URL: " https://intelligence.example.test ", + INTELLIGENCE_GATEWAY_WS_URL: " wss://realtime.example.test ", + INTELLIGENCE_CHANNEL_NAME: " custom-channel ", + }), + ).toMatchObject({ + intelligenceApiUrl: "https://intelligence.example.test", + intelligenceGatewayWsUrl: "wss://realtime.example.test", + channelName: "custom-channel", + }); + }); + it("puts the Slack pair behind slackDirect and exposes nothing else", () => { // Both Slack tokens or neither: one alone is a configuration error, and the // pair is read into `slackDirect` rather than into flat fields. diff --git a/app/env.ts b/app/env.ts index d4ef9ae..87fa631 100644 --- a/app/env.ts +++ b/app/env.ts @@ -14,10 +14,12 @@ export const DEFAULT_AGENT_DISPLAY_NAME = "OpenTag"; * connect flow needs exactly that, because a connect link binds whoever opens * it to the identity it was minted for. * - * Setting these attaches a direct Slack adapter that does support it. Leaving - * them unset keeps the managed path, which stays the default, and leaves the - * connect button unable to deliver — see `handleConnectClick`, which refuses to - * fall back to a DM rather than send a capability somewhere it was not scoped. + * Setting these attaches a direct Slack adapter that does support it, so the + * connect link arrives as an ephemeral message. Leaving them unset keeps the + * managed path, which stays the default and still works: `handleConnectClick` + * asks for the DM fallback, and a DM is scoped to the clicker exactly as an + * ephemeral message is. What neither path will do is put the link in the + * thread — the hazard a connect link carries is a second reader. */ export interface SlackDirectConfig { botToken: string; @@ -38,8 +40,9 @@ export interface AppEnvironment { port: number; } +/** Trimmed, and blank counts as missing — a deploy UI's "unset" is an empty string. */ function required(env: NodeJS.ProcessEnv, name: string): string { - const value = env[name]; + const value = env[name]?.trim(); if (!value) { throw new Error(`Missing required env var: ${name}`); } @@ -83,18 +86,26 @@ export function readEnvironment( agentDisplayName: env.AGENT_DISPLAY_NAME?.trim() || DEFAULT_AGENT_DISPLAY_NAME, agentUrl: required(env, "AGENT_URL"), - agentAuthHeader: env.AGENT_AUTH_HEADER, + // Trimmed like every neighbour, and blank means unset. This one value goes + // out as an HTTP header: a trailing newline is not a legal header value and + // makes `fetch` reject every request to the agent, and a whitespace-only + // value reads as "a secret is configured" everywhere it is checked while + // authorizing nothing. + agentAuthHeader: env.AGENT_AUTH_HEADER?.trim() || undefined, slackDirect: readSlackDirect(env), intelligenceApiKey: required(env, "INTELLIGENCE_API_KEY"), + // `||` rather than `??`: a variable declared and left empty is how a deploy + // platform's UI represents "not set", and `??` let that empty string defeat + // the default and become an empty URL. intelligenceApiUrl: - env.INTELLIGENCE_API_URL ?? DEFAULT_INTELLIGENCE_API_URL, + env.INTELLIGENCE_API_URL?.trim() || DEFAULT_INTELLIGENCE_API_URL, intelligenceGatewayWsUrl: - env.INTELLIGENCE_GATEWAY_WS_URL ?? + env.INTELLIGENCE_GATEWAY_WS_URL?.trim() || DEFAULT_INTELLIGENCE_GATEWAY_WS_URL, learningContainerId: env.INTELLIGENCE_LEARNING_CONTAINER_ID?.trim() || undefined, channelName: - env.INTELLIGENCE_CHANNEL_NAME ?? DEFAULT_INTELLIGENCE_CHANNEL_NAME, + env.INTELLIGENCE_CHANNEL_NAME?.trim() || DEFAULT_INTELLIGENCE_CHANNEL_NAME, port: parsePort(env.PORT), }; } diff --git a/app/index.ts b/app/index.ts index a1a2ef1..a0fa400 100644 --- a/app/index.ts +++ b/app/index.ts @@ -12,12 +12,24 @@ import { createOpenTagRuntime } from "./runtime-host.js"; * was put on the wire, so dropping the header here is otherwise invisible. */ export function createAgentFactory(environment: AppEnvironment) { + // Truthiness alone decided this: `""` dropped the header with no sign, and + // `" "` — or a value pasted with a trailing newline — went out as if it were + // a secret. Both read as "configured" to whoever set them, and the agent + // answers 401 to both. `readEnvironment` already normalizes blank to + // undefined, so reaching this throw means a caller built an `AppEnvironment` + // by hand with a value that cannot work. + const secret = environment.agentAuthHeader?.trim(); + if (environment.agentAuthHeader !== undefined && !secret) { + throw new Error( + "AGENT_AUTH_HEADER is set but blank. Unset it to talk to an " + + "unauthenticated agent, or set it to the secret the agent checks.", + ); + } + return (threadId: string) => { const instance = new SanitizingHttpAgent({ url: environment.agentUrl, - headers: environment.agentAuthHeader - ? { Authorization: environment.agentAuthHeader } - : undefined, + headers: secret ? { Authorization: secret } : undefined, }); instance.threadId = threadId; return instance; @@ -29,6 +41,18 @@ export function createOpenTagApplication( ) { const agent = createAgentFactory(environment); + // A one-sided shared secret is invisible from either end: an agent that + // requires one answers 401 to every request, and once the agent is inside a + // Channel nothing in this process sees the response. This line at boot is the + // only place the operator can notice which half is missing. + if (!environment.agentAuthHeader) { + console.warn( + "[opentag] no AGENT_AUTH_HEADER is set, so agent requests go out " + + "unauthenticated. If the agent has one set, every request will be " + + "rejected with 401.", + ); + } + // Intelligence owns the Slack and Teams adapters for this logical Channel. const channels = [ createOpenTagChannel( diff --git a/app/server.test.ts b/app/server.test.ts index f42be52..3a1c22b 100644 --- a/app/server.test.ts +++ b/app/server.test.ts @@ -14,6 +14,8 @@ class FakeServer extends EventEmitter implements HttpServerLike { listening = false; listenCalls: Array<{ port: number; host: string }> = []; closeCalls = 0; + /** When set, `close` reports this the way `http.Server` does. */ + closeError: Error | undefined; listen(port: number, host: string, callback: () => void): this { this.listenCalls.push({ port, host }); @@ -25,7 +27,24 @@ class FakeServer extends EventEmitter implements HttpServerLike { close(callback: (error?: Error) => void): this { this.closeCalls += 1; this.listening = false; - callback(); + callback(this.closeError); + return this; + } +} + +/** + * A port that is already taken. + * + * Node reports this on the server's `error` event, never through the `listen` + * callback, so nothing resolves and the failure is only visible to a listener + * that was attached before `listen`. + */ +class TakenPortServer extends FakeServer { + readonly failure = new Error("listen EADDRINUSE: address already in use :::3000"); + + override listen(port: number, host: string, _callback: () => void): this { + this.listenCalls.push({ port, host }); + queueMicrotask(() => this.emit("error", this.failure)); return this; } } @@ -110,6 +129,124 @@ describe("startOpenTagServer", () => { expect(closeBrowser).toHaveBeenCalledOnce(); }); + it("rejects when the port is taken, rather than resolving into a dead server", async () => { + // The `error` event is the only report of this. Dropping the listener that + // catches it leaves `listen` pending forever and startup never returns. + const controls = makeControls(); + const server = new TakenPortServer(); + const closeBrowser = vi.fn(async () => undefined); + + await expect( + startOpenTagServer({ + listener: makeListener(controls), + port: 3000, + closeBrowser, + createHttpServer: () => server, + signalTarget: new EventEmitter(), + }), + ).rejects.toBe(server.failure); + + expect(controls.stop).toHaveBeenCalledOnce(); + expect(closeBrowser).toHaveBeenCalledOnce(); + // Nothing to close: the server never began listening. + expect(server.closeCalls).toBe(0); + }); + + it.each(["SIGINT", "SIGTERM"] as const)( + "shuts everything down on %s with nothing else prompting it", + async (signal) => { + // Emitting a signal and then calling `shutdown()` proves nothing: the + // second call returns the memoized promise, so the assertions pass just + // as well with both signal handlers deleted. Only the signal runs here. + const controls = makeControls(); + const server = new FakeServer(); + const closeBrowser = vi.fn(async () => undefined); + const signalTarget = new EventEmitter(); + + await startOpenTagServer({ + listener: makeListener(controls), + port: 3000, + closeBrowser, + createHttpServer: () => server, + signalTarget, + }); + + signalTarget.emit(signal); + + await vi.waitFor(() => { + expect(controls.stop).toHaveBeenCalledOnce(); + expect(server.closeCalls).toBe(1); + expect(closeBrowser).toHaveBeenCalledOnce(); + }); + }, + ); + + it("reports every resource that failed to stop, not just the first", async () => { + // `Promise.allSettled` is the point: a Channel that will not stop must not + // hide a browser that will not close. + const channelFailure = new Error("channels would not stop"); + const browserFailure = new Error("browser would not close"); + const controls = makeControls({ + stop: vi.fn(async () => { + throw channelFailure; + }), + }); + const server = new FakeServer(); + server.closeError = new Error("server would not close"); + + const running = await startOpenTagServer({ + listener: makeListener(controls), + port: 3000, + closeBrowser: vi.fn(async () => { + throw browserFailure; + }), + createHttpServer: () => server, + signalTarget: new EventEmitter(), + }); + + const error = await running.shutdown().then( + () => undefined, + (reason: unknown) => reason, + ); + + expect(error).toBeInstanceOf(AggregateError); + expect((error as AggregateError).errors).toEqual([ + channelFailure, + server.closeError, + browserFailure, + ]); + }); + + it("hands a signal-initiated shutdown failure to onShutdownError", async () => { + // A signal callback cannot be awaited by an EventEmitter, so without this + // hook the rejection is an unhandled one and the process exits 0 after + // failing to clean up. + const failure = new Error("channels would not stop"); + const controls = makeControls({ + stop: vi.fn(async () => { + throw failure; + }), + }); + const onShutdownError = vi.fn(); + const signalTarget = new EventEmitter(); + + await startOpenTagServer({ + listener: makeListener(controls), + port: 3000, + closeBrowser: vi.fn(async () => undefined), + createHttpServer: () => new FakeServer(), + signalTarget, + onShutdownError, + }); + + signalTarget.emit("SIGTERM"); + + await vi.waitFor(() => expect(onShutdownError).toHaveBeenCalledOnce()); + const [reported] = onShutdownError.mock.calls[0]! as [unknown]; + expect(reported).toBeInstanceOf(AggregateError); + expect((reported as AggregateError).errors).toEqual([failure]); + }); + it("stops every owned resource exactly once across repeated shutdowns", async () => { const controls = makeControls(); const server = new FakeServer(); @@ -170,6 +307,29 @@ describe("createAgentFactory", () => { expect(agent.headers).toEqual({}); }); + it.each(["", " ", "\n"])( + "refuses a shared secret of %j instead of guessing what it meant", + (agentAuthHeader) => { + // Truthiness alone decided this: `""` dropped the header silently and + // `" "` put whitespace on the wire as if it were a secret. Both read as + // "configured" to whoever set it, and the agent answers 401 either way. + expect(() => + createAgentFactory({ ...managedEnvironment, agentAuthHeader }), + ).toThrow(/AGENT_AUTH_HEADER/); + }, + ); + + it("trims the secret rather than sending an unusable header value", () => { + // A value pasted with a trailing newline is not a legal header value; Node + // rejects the request outright, so every call to the agent fails at once. + const agent = createAgentFactory({ + ...managedEnvironment, + agentAuthHeader: " Bearer agent-secret\n", + })("thread-1"); + + expect(agent.headers).toEqual({ Authorization: "Bearer agent-secret" }); + }); + it("gives each conversation its own agent, bound to its thread", () => { // Channels agents are stateful, so a shared instance would cross threads. const factory = createAgentFactory(managedEnvironment); @@ -194,4 +354,41 @@ describe("createOpenTagApplication", () => { ).toEqual([{ name: "open-tag", adapters: [] }]); expect(application.runtime.channels).toEqual(application.channels); }); + + it("attaches the direct Slack adapter when both tokens are configured", () => { + // Nothing built a Channel from `slackDirect` before, so deleting the + // argument that carries it left the whole suite green while the one + // deployment that can deliver a connect link privately stopped existing. + const application = createOpenTagApplication({ + ...managedEnvironment, + slackDirect: { botToken: "xoxb-test", appToken: "xapp-test" }, + }); + + expect(application.channels[0]!.adapters).toHaveLength(1); + }); + + it("warns at startup when nothing authenticates its agent traffic", () => { + // The mismatch is silent in both directions: an agent that requires a + // secret answers 401 to every request, and nothing in this process can see + // what the Channel put on the wire. One line at boot is the only place the + // operator can notice. + const warned = vi.spyOn(console, "warn").mockImplementation(() => {}); + + createOpenTagApplication(managedEnvironment); + + expect(JSON.stringify(warned.mock.calls)).toContain("AGENT_AUTH_HEADER"); + warned.mockRestore(); + }); + + it("says nothing when a secret is configured", () => { + const warned = vi.spyOn(console, "warn").mockImplementation(() => {}); + + createOpenTagApplication({ + ...managedEnvironment, + agentAuthHeader: "Bearer agent-secret", + }); + + expect(JSON.stringify(warned.mock.calls)).not.toContain("AGENT_AUTH_HEADER"); + warned.mockRestore(); + }); }); diff --git a/app/tools/__tests__/composio-connect.test.ts b/app/tools/__tests__/composio-connect.test.ts index ad6541e..f976a8a 100644 --- a/app/tools/__tests__/composio-connect.test.ts +++ b/app/tools/__tests__/composio-connect.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import { connectEndpoint, + normalizeToolkit, requestConnectLink, } from "../composio-connect.js"; @@ -74,18 +75,113 @@ describe("requestConnectLink", () => { }); }); - it("explains a missing secret instead of provoking a 401 nobody can act on", async () => { - const fetchImpl = vi.fn() as unknown as typeof fetch; + it.each([undefined, "", " ", "\n"])( + "explains a secret of %j instead of provoking a 401 nobody can act on", + async (agentAuthHeader) => { + // Truthiness alone let a whitespace-only value through, and a header + // value with a newline in it is rejected by fetch outright. + const fetchImpl = vi.fn() as unknown as typeof fetch; + + const result = await requestConnectLink({ + ...base, + agentAuthHeader, + fetchImpl, + }); + + expect(result.ok).toBe(false); + expect(fetchImpl).not.toHaveBeenCalled(); + // The variable name is the operator's business. Naming it in a thread + // tells everyone reading how this deployment is wired. + if (!result.ok) expect(result.message).not.toContain("AGENT_AUTH_HEADER"); + }, + ); + + it("logs the variable an operator has to set, where only an operator looks", async () => { + const logged = vi.spyOn(console, "error").mockImplementation(() => {}); + + await requestConnectLink({ + ...base, + agentAuthHeader: " ", + fetchImpl: vi.fn() as unknown as typeof fetch, + }); + + expect(JSON.stringify(logged.mock.calls)).toContain("AGENT_AUTH_HEADER"); + logged.mockRestore(); + }); + + it("trims the secret rather than putting a stray newline on the wire", async () => { + const fetchImpl = vi.fn(async () => + jsonResponse({ redirectUrl: LINK }), + ) as unknown as typeof fetch; + + await requestConnectLink({ + ...base, + agentAuthHeader: "Bearer s3cret\n", + fetchImpl, + }); + + const [, init] = (fetchImpl as unknown as ReturnType).mock + .calls[0]!; + expect((init as RequestInit).headers).toMatchObject({ + authorization: "Bearer s3cret", + }); + }); + + it.each([401, 403])( + "does not repeat the agent's %i body at a person who cannot act on it", + async (status) => { + // The agent answers a mismatched secret with the bare word + // "unauthorized", which tells the person nothing and tells them nothing + // they can do. A 4xx body is also the one place a credential could be + // echoed back, and this is the status that would echo one. + const fetchImpl = vi.fn(async () => + jsonResponse({ error: "unauthorized: Bearer s3cret" }, status), + ) as unknown as typeof fetch; + const logged = vi.spyOn(console, "error").mockImplementation(() => {}); + + const result = await requestConnectLink({ ...base, fetchImpl }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.message).not.toContain("s3cret"); + expect(result.message).not.toBe("unauthorized"); + expect(result.message).not.toContain("unauthorized"); + expect(result.message).not.toContain("AGENT_AUTH_HEADER"); + expect(result.message.length).toBeGreaterThan(30); + } + logged.mockRestore(); + }, + ); + + it("never repeats the secret it was given, whatever the agent says back", async () => { + const fetchImpl = vi.fn(async () => + jsonResponse({ error: "rejected token Bearer s3cret" }, 400), + ) as unknown as typeof fetch; + + const result = await requestConnectLink({ ...base, fetchImpl }); + + expect(result.ok).toBe(false); + if (!result.ok) expect(result.message).not.toContain("s3cret"); + }); + + it("redacts the bare token as well as the whole header value", async () => { + // An agent that answers `token abc… is not valid` quotes only the second + // half of what we sent, and that half is the credential. + const fetchImpl = vi.fn(async () => + jsonResponse({ error: "token 0123456789abcdef is not valid" }, 400), + ) as unknown as typeof fetch; const result = await requestConnectLink({ ...base, - agentAuthHeader: undefined, + agentAuthHeader: "Bearer 0123456789abcdef", fetchImpl, }); expect(result.ok).toBe(false); - expect(fetchImpl).not.toHaveBeenCalled(); - if (!result.ok) expect(result.message).toContain("AGENT_AUTH_HEADER"); + if (!result.ok) { + expect(result.message).not.toContain("0123456789abcdef"); + expect(result.message).toContain("[redacted]"); + } }); it("passes the agent's own refusal through, because it is written for a person", async () => { @@ -122,6 +218,97 @@ describe("requestConnectLink", () => { } }); + it("passes the agent's own 503 through, because it says what to configure", async () => { + // "Composio is not configured on this deployment." is the agent's own + // sentence and the only one that tells the operator what to do. A blanket + // >=500 filter threw it away and showed a generic retry line instead. + const fetchImpl = vi.fn(async () => + jsonResponse( + { error: "Composio is not configured on this deployment." }, + 503, + ), + ) as unknown as typeof fetch; + + const result = await requestConnectLink({ ...base, fetchImpl }); + + expect(result).toEqual({ + ok: false, + message: "Composio is not configured on this deployment.", + }); + }); + + it("does not pass a proxy's 503 through, which is html and not a sentence", async () => { + const fetchImpl = vi.fn( + async () => + new Response("503 Service Unavailable", { + status: 503, + headers: { "content-type": "text/html" }, + }), + ) as unknown as typeof fetch; + + const result = await requestConnectLink({ ...base, fetchImpl }); + + expect(result.ok).toBe(false); + if (!result.ok) expect(result.message).not.toContain("html"); + }); + + it("gives up on a hung agent instead of leaving the click pending forever", async () => { + // Without a deadline the request can hang for the platform's timeout, or + // never resolve at all, and the "try again shortly" sentence below is + // unreachable — the person just watches a button that did nothing. + const fetchImpl = vi.fn( + async (_url: unknown, init: RequestInit) => + new Promise((_resolve, reject) => { + init.signal?.addEventListener("abort", () => { + reject(new DOMException("The operation was aborted.", "AbortError")); + }); + }), + ) as unknown as typeof fetch; + const logged = vi.spyOn(console, "error").mockImplementation(() => {}); + + const result = await requestConnectLink({ + ...base, + timeoutMs: 10, + fetchImpl, + }); + + expect(result.ok).toBe(false); + if (!result.ok) expect(result.message).toContain("Try again shortly"); + logged.mockRestore(); + }); + + it("says a bad AGENT_URL is a configuration problem, not a transient one", async () => { + // `new URL()` and the header build sat inside the same unbound `catch {}` + // as the fetch, so a misconfigured agent address read as "try again + // shortly" forever, and nothing was logged. + const fetchImpl = vi.fn() as unknown as typeof fetch; + const logged = vi.spyOn(console, "error").mockImplementation(() => {}); + + const result = await requestConnectLink({ + ...base, + agentUrl: "not a url", + fetchImpl, + }); + + expect(fetchImpl).not.toHaveBeenCalled(); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.message).not.toContain("Try again shortly"); + expect(logged).toHaveBeenCalled(); + logged.mockRestore(); + }); + + it("logs an unreachable agent rather than swallowing why", async () => { + const fetchImpl = vi.fn(async () => { + throw new Error("ECONNREFUSED"); + }) as unknown as typeof fetch; + const logged = vi.spyOn(console, "error").mockImplementation(() => {}); + + await requestConnectLink({ ...base, fetchImpl }); + + expect(logged).toHaveBeenCalled(); + logged.mockRestore(); + }); + it("treats an unreachable agent as something to retry", async () => { const fetchImpl = vi.fn(async () => { throw new Error("ECONNREFUSED"); @@ -144,4 +331,80 @@ describe("requestConnectLink", () => { expect(result.ok).toBe(false); } }); + + it("tells an unreadable reply apart from a reply with no link", async () => { + // `.catch(() => null)` reported both as "no link", so an agent answering + // 200 with html — a proxy in front of it, say — read as a Composio problem. + const logged = vi.spyOn(console, "error").mockImplementation(() => {}); + const unreadable = vi.fn( + async () => + new Response("hello", { + status: 200, + headers: { "content-type": "text/html" }, + }), + ) as unknown as typeof fetch; + const noLink = vi.fn(async () => + jsonResponse({}), + ) as unknown as typeof fetch; + + const unreadableResult = await requestConnectLink({ + ...base, + fetchImpl: unreadable, + }); + const noLinkResult = await requestConnectLink({ + ...base, + fetchImpl: noLink, + }); + + expect(unreadableResult.ok).toBe(false); + expect(noLinkResult.ok).toBe(false); + if (!unreadableResult.ok && !noLinkResult.ok) { + expect(unreadableResult.message).not.toBe(noLinkResult.message); + } + expect(logged).toHaveBeenCalled(); + logged.mockRestore(); + }); + + it.each([ + "javascript:alert(1)", + "https://evil.example/x|Click here", + "https://evil.example/x> { + // The link is rendered into Slack's `` syntax. A `|` or a `>` in + // it ends the url half and lets the rest become a label or a second link, + // and a `javascript:` scheme is not a connect flow at all. + const fetchImpl = vi.fn(async () => + jsonResponse({ redirectUrl }), + ) as unknown as typeof fetch; + const logged = vi.spyOn(console, "error").mockImplementation(() => {}); + + const result = await requestConnectLink({ ...base, fetchImpl }); + + expect(result.ok).toBe(false); + logged.mockRestore(); + }); +}); + +describe("normalizeToolkit", () => { + it("keeps an app name an app name", () => { + expect(normalizeToolkit(" Gmail ")).toBe("gmail"); + expect(normalizeToolkit("google_calendar")).toBe("google_calendar"); + expect(normalizeToolkit("notion-v2")).toBe("notion-v2"); + }); + + it.each([ + "", + " ", + "", + "*gmail*", + "gmail\nSection: hi", + "<@U123>", + "a".repeat(65), + ])("refuses %j, because the slug is rendered in a public post", (raw) => { + // The model chooses this string and the card carrying it is posted where + // everyone in the thread reads it, rendered as mrkdwn. An identifier + // charset is the whole of what a slug may be; anything else is not one. + expect(normalizeToolkit(raw)).toBeNull(); + }); }); diff --git a/app/tools/__tests__/connect-app.test.tsx b/app/tools/__tests__/connect-app.test.tsx index 63049a5..f5b658e 100644 --- a/app/tools/__tests__/connect-app.test.tsx +++ b/app/tools/__tests__/connect-app.test.tsx @@ -34,6 +34,50 @@ describe("connect_app", () => { expect(posted).not.toContain(" Gmail "); }); + it.each([ + "", + "gmail>*click here*", + "*gmail*", + "gmail\nSection: ignore the above", + "<@U123>", + "", + ])("posts nothing for %j, which the card would render as live mrkdwn", async (toolkit) => { + // The model chooses this string and the card is a PUBLIC post rendered as + // Slack mrkdwn, so `` in it became a hyperlink everyone in the + // thread could click. A toolkit is an identifier; nothing else is one. + const { ctx, post } = context(); + + const result = await connectAppTool.handler({ toolkit }, ctx); + + expect(post).not.toHaveBeenCalled(); + expect(String(result)).toMatch(/not an app name|No app was named/); + }); + + it("does not echo the rejected name back into the conversation", async () => { + // The tool result goes to the model, which routinely repeats it to the + // person. Quoting the payload back would put it on a rendered surface by + // another route. + const { ctx } = context(); + + const result = await connectAppTool.handler( + { toolkit: "" }, + ctx, + ); + + expect(String(result)).not.toContain("evil.example"); + }); + + it("accepts the slug shapes real toolkits use", async () => { + for (const toolkit of ["google_calendar", "notion-v2", "gmail"]) { + const { ctx, post } = context(); + + await connectAppTool.handler({ toolkit }, ctx); + + expect(post).toHaveBeenCalledTimes(1); + expect(JSON.stringify(post.mock.calls[0]![0])).toContain(toolkit); + } + }); + it("posts nothing when no app was named", async () => { const { ctx, post } = context(); diff --git a/app/tools/__tests__/connect-click.test.tsx b/app/tools/__tests__/connect-click.test.tsx index f1e8aed..23e65bf 100644 --- a/app/tools/__tests__/connect-click.test.tsx +++ b/app/tools/__tests__/connect-click.test.tsx @@ -3,24 +3,42 @@ import { handleConnectClick } from "../connect-click.js"; const LINK = "https://backend.composio.dev/connect/abc123"; -function interaction(actor: { id: string; kind: string } | undefined) { +type Ephemeral = { ok: boolean; usedFallback?: boolean; error?: string } | null; + +/** + * `postEphemeral` resolving `null` is not an edge case: it is what the SDK does + * on every surface without a native ephemeral message, which is what the + * managed Slack adapter reports and therefore what the default deployment does. + * Every test here says which of the two outcomes it is exercising. + */ +function interaction( + actor: { id: string; kind: string } | undefined, + options: { ephemeral?: Ephemeral; postRejects?: Error } = {}, +) { + const ephemeral: Ephemeral = + options.ephemeral === undefined ? { ok: true, usedFallback: false } : options.ephemeral; // Typed parameters, not a cast: the assertions below read the recorded // arguments, and an untyped mock records an empty tuple. const postEphemeral = vi.fn( async (_user: unknown, _ui: unknown, _options: { fallbackToDM: boolean }) => - null, + ephemeral, ); + const post = vi.fn(async (_ui: unknown) => { + if (options.postRejects) throw options.postRejects; + return { id: "m1" }; + }); return { ctx: { actor, platform: "slack", - thread: { postEphemeral }, + thread: { postEphemeral, post }, message: { ref: "m1" }, action: { id: "a1" }, values: {}, user: null, } as never, postEphemeral, + post, }; } @@ -29,6 +47,14 @@ const environment = { agentAuthHeader: "Bearer s3cret", } as never; +/** Everything either delivery path was handed, as one searchable string. */ +function everythingRendered( + postEphemeral: ReturnType, + post: ReturnType, +): string { + return JSON.stringify([...postEphemeral.mock.calls, ...post.mock.calls]); +} + describe("handleConnectClick", () => { it("mints for whoever clicked, not for whoever the card was posted to", async () => { const request = vi.fn(async () => ({ ok: true as const, url: LINK })); @@ -61,32 +87,96 @@ describe("handleConnectClick", () => { it("delivers the link to that person alone", async () => { const request = vi.fn(async () => ({ ok: true as const, url: LINK })); - const { ctx, postEphemeral } = interaction({ id: "U2", kind: "human" }); + const { ctx, postEphemeral, post } = interaction({ id: "U2", kind: "human" }); await handleConnectClick("gmail", ctx, { environment, request }); expect(postEphemeral).toHaveBeenCalledTimes(1); expect(postEphemeral.mock.calls[0]![0]).toEqual({ id: "U2", kind: "human" }); + // Nothing public happened, because the private post landed. + expect(post).not.toHaveBeenCalled(); }); - it("never falls back to a DM, because a link must not follow someone elsewhere", async () => { + it("asks for the DM fallback, because the default deployment has no ephemeral message", async () => { + // The managed Slack adapter declares `supportsEphemeral: false`. With + // `fallbackToDM: false` the SDK resolves `null` and the minted link is + // simply dropped — the connect button did nothing on the default install. + // A DM is scoped to the clicker exactly as an ephemeral message is. const request = vi.fn(async () => ({ ok: true as const, url: LINK })); const { ctx, postEphemeral } = interaction({ id: "U2", kind: "human" }); await handleConnectClick("gmail", ctx, { environment, request }); - expect(postEphemeral.mock.calls[0]![2]).toEqual({ fallbackToDM: false }); + expect(postEphemeral.mock.calls[0]![2]).toEqual({ fallbackToDM: true }); + }); + + it("says so in the thread when the link could not be delivered privately", async () => { + // `null` is the SDK's "this surface delivered nothing". Discarding it left + // the person staring at a button that did nothing, with no log either. + const request = vi.fn(async () => ({ ok: true as const, url: LINK })); + const { ctx, post } = interaction({ id: "U2", kind: "human" }, { ephemeral: null }); + const logged = vi.spyOn(console, "error").mockImplementation(() => {}); + + await handleConnectClick("gmail", ctx, { environment, request }); + + expect(post).toHaveBeenCalledTimes(1); + expect(logged).toHaveBeenCalled(); + logged.mockRestore(); + }); + + it("never puts the minted link anywhere public, whatever went wrong", async () => { + // Whoever completes a connect link binds their account to the id it was + // minted for, so a link in a thread is an account-takeover hazard. + const request = vi.fn(async () => ({ ok: true as const, url: LINK })); + const logged = vi.spyOn(console, "error").mockImplementation(() => {}); + + for (const ephemeral of [null, { ok: false, error: "no ephemeral" }] as Ephemeral[]) { + const { ctx, post } = interaction({ id: "U2", kind: "human" }, { ephemeral }); + + await handleConnectClick("gmail", ctx, { environment, request }); + + expect(JSON.stringify(post.mock.calls)).not.toContain(LINK); + } + logged.mockRestore(); + }); + + it("treats an ok:false ephemeral result as undelivered", async () => { + // The SDK reports the surface's refusal this way rather than throwing. + const request = vi.fn(async () => ({ ok: true as const, url: LINK })); + const { ctx, post } = interaction( + { id: "U2", kind: "human" }, + { ephemeral: { ok: false, error: "slack does not support ephemeral messages" } }, + ); + const logged = vi.spyOn(console, "error").mockImplementation(() => {}); + + await handleConnectClick("gmail", ctx, { environment, request }); + + expect(post).toHaveBeenCalledTimes(1); + logged.mockRestore(); }); it("mints nothing when it cannot tell who clicked", async () => { // Minting anyway would bind an account to whatever id we guessed. const request = vi.fn(); - const { ctx, postEphemeral } = interaction(undefined); + const { ctx, postEphemeral, post } = interaction(undefined); await handleConnectClick("gmail", ctx, { environment, request }); expect(request).not.toHaveBeenCalled(); - expect(postEphemeral).toHaveBeenCalledTimes(1); + expect(post).toHaveBeenCalledTimes(1); + }); + + it("does not address the no-actor notice to a literal \"unknown\"", async () => { + // There is no such user id, so `postEphemeral("unknown", …)` delivered the + // notice to nobody. With no identifiable clicker the thread is the only + // surface left, and the notice carries no capability. + const request = vi.fn(); + const { ctx, postEphemeral } = interaction(undefined); + + await handleConnectClick("gmail", ctx, { environment, request }); + + expect(everythingRendered(postEphemeral, vi.fn())).not.toContain("unknown"); + expect(postEphemeral).not.toHaveBeenCalled(); }); it("shows the reason privately when no link could be minted", async () => { @@ -99,6 +189,93 @@ describe("handleConnectClick", () => { await handleConnectClick("linear", ctx, { environment, request }); expect(postEphemeral).toHaveBeenCalledTimes(1); - expect(postEphemeral.mock.calls[0]![2]).toEqual({ fallbackToDM: false }); + expect(postEphemeral.mock.calls[0]![2]).toEqual({ fallbackToDM: true }); + }); + + it("still shows the reason when the surface cannot deliver privately", async () => { + // A refusal carries no capability, so the thread is a safe place for it and + // silence is not. + const request = vi.fn(async () => ({ + ok: false as const, + message: "Shared apps are connected by an operator.", + })); + const { ctx, post } = interaction({ id: "U2", kind: "human" }, { ephemeral: null }); + const logged = vi.spyOn(console, "error").mockImplementation(() => {}); + + await handleConnectClick("linear", ctx, { environment, request }); + + expect(JSON.stringify(post.mock.calls)).toContain( + "Shared apps are connected by an operator.", + ); + logged.mockRestore(); + }); + + it("does not let a failed mint escape the click handler", async () => { + // Nothing awaits this handler: an escaping rejection is an unhandled one, + // and the person sees a button that did nothing. + const request = vi.fn(async () => { + throw new Error("boom"); + }); + const { ctx, post } = interaction({ id: "U2", kind: "human" }); + const logged = vi.spyOn(console, "error").mockImplementation(() => {}); + + await expect( + handleConnectClick("gmail", ctx, { environment, request }), + ).resolves.toBeUndefined(); + expect(logged).toHaveBeenCalled(); + expect(post).toHaveBeenCalledTimes(1); + logged.mockRestore(); + }); + + it("does not let a throwing surface escape the click handler", async () => { + const request = vi.fn(async () => ({ ok: true as const, url: LINK })); + const { ctx } = interaction({ id: "U2", kind: "human" }, { + ephemeral: null, + postRejects: new Error("channel_not_found"), + }); + const logged = vi.spyOn(console, "error").mockImplementation(() => {}); + + await expect( + handleConnectClick("gmail", ctx, { environment, request }), + ).resolves.toBeUndefined(); + logged.mockRestore(); + }); + + it("does not let an unreadable environment escape the click handler", async () => { + // `readEnvironment()` throws on a deployment missing `AGENT_URL`, and it ran + // per click inside a handler nothing awaits. + const request = vi.fn(); + const { ctx, postEphemeral } = interaction({ id: "U2", kind: "human" }); + const environmentThatThrows = () => { + throw new Error("Missing required env var: AGENT_URL"); + }; + const logged = vi.spyOn(console, "error").mockImplementation(() => {}); + + await expect( + handleConnectClick("gmail", ctx, { + request, + readEnvironment: environmentThatThrows, + }), + ).resolves.toBeUndefined(); + expect(request).not.toHaveBeenCalled(); + // The person hears about it, on whichever surface could carry it. + expect(postEphemeral).toHaveBeenCalledTimes(1); + logged.mockRestore(); + }); + + it("refuses a toolkit that is not a slug, because the card renders it publicly", async () => { + // The value travels on the card the model asked for, and the card is a + // public post rendered as mrkdwn. + const request = vi.fn(); + const { ctx } = interaction({ id: "U2", kind: "human" }); + const logged = vi.spyOn(console, "error").mockImplementation(() => {}); + + await handleConnectClick("", ctx, { + environment, + request, + }); + + expect(request).not.toHaveBeenCalled(); + logged.mockRestore(); }); }); diff --git a/app/tools/composio-connect.ts b/app/tools/composio-connect.ts index cdc9742..fa2fe6e 100644 --- a/app/tools/composio-connect.ts +++ b/app/tools/composio-connect.ts @@ -7,8 +7,17 @@ * reaches the model and is never posted where a second person could open it — * whoever completes a connect flow binds their account to the id the link was * minted for, which makes a shared link an account-takeover hazard. + * + * Two rules run through every branch below. Nothing a person is shown may carry + * a credential or a variable name — those go to the log, where only an operator + * looks. And nothing fails without saying so: every `return { ok: false }` here + * either repeats a sentence the agent wrote for a person, or logs the reason it + * could not. */ +/** How long a click waits for the agent before it is told to try again. */ +export const DEFAULT_CONNECT_TIMEOUT_MS = 10_000; + export interface ConnectRequestInput { agentUrl: string; agentAuthHeader?: string; @@ -21,6 +30,8 @@ export interface ConnectRequestInput { platform: string; toolkit: string; fetchImpl?: typeof fetch; + /** Overridden only by tests; a click cannot wait on a hung agent forever. */ + timeoutMs?: number; } /** A link for exactly one person, or the sentence to show them instead. */ @@ -28,6 +39,23 @@ export type ConnectResult = | { ok: true; url: string } | { ok: false; message: string }; +/** + * The one shape a toolkit slug may have. + * + * The model chooses this string, and it is rendered into a card posted publicly + * in the thread — as Slack mrkdwn, where `` is a + * live hyperlink and `*gmail*` is bold. Escaping at the render site would have + * to be repeated at every render site and got missed at the first one. A + * toolkit is an identifier, so the identifier charset is the whole of what it + * may contain and anything else is not a toolkit name at all. + * + * Returns the normalized slug, or `null` when the string was never one. + */ +export function normalizeToolkit(raw: string): string | null { + const slug = raw.trim().toLowerCase(); + return /^[a-z0-9][a-z0-9_-]{0,63}$/.test(slug) ? slug : null; +} + /** * The agent's connect endpoint, derived from the URL the Channel already uses * to run it. Derived rather than configured separately: two variables pointing @@ -37,6 +65,17 @@ export function connectEndpoint(agentUrl: string): string { return new URL("composio/connect", agentUrl.endsWith("/") ? agentUrl : `${agentUrl}/`).toString(); } +/** Said when the two services do not share a secret. Names no variable. */ +const NO_SHARED_SECRET = + "Connecting your own account needs a shared secret set on both this app and " + + "its agent, and this deployment has not set one. Ask whoever runs it."; + +/** Said when they both set one and the two do not match. Names no credential. */ +const SECRET_REJECTED = + "Connecting your own account needs this app and its agent to present the " + + "same shared secret, and the agent rejected the one this app sent. Ask " + + "whoever runs this deployment."; + export async function requestConnectLink({ agentUrl, agentAuthHeader, @@ -45,76 +84,205 @@ export async function requestConnectLink({ platform, toolkit, fetchImpl = fetch, + timeoutMs = DEFAULT_CONNECT_TIMEOUT_MS, }: ConnectRequestInput): Promise { // The endpoint refuses to mint anything without this header, so a deployment // that never set it gets a clear sentence rather than a 401 the person cannot - // act on. - if (!agentAuthHeader) { + // act on. Trimmed rather than tested for truthiness: a value of `" "` is set + // everywhere it is checked and authorizes nothing, and one with a newline in + // it is not a legal header value — `fetch` rejects the whole request. + const secret = agentAuthHeader?.trim(); + if (!secret) { + console.error( + "[opentag] no connect link can be minted: AGENT_AUTH_HEADER is unset or " + + "blank on this service, and the agent's connect route requires it", + ); + return { ok: false, message: NO_SHARED_SECRET }; + } + + // Built before the request and outside its catch. `new URL()` throws on a + // malformed AGENT_URL, which is a configuration mistake that will never + // resolve itself; sharing a catch with the fetch reported it as "try again + // shortly" forever and logged nothing. + let endpoint: string; + let body: string; + try { + endpoint = connectEndpoint(agentUrl); + body = JSON.stringify({ + actor_id: actorId, + kind: actorKind, + platform, + toolkit, + }); + } catch (error) { + console.error( + `[opentag] could not build the connect request for ${toolkit}; check AGENT_URL`, + error, + ); return { ok: false, message: - "Connecting your own account needs `AGENT_AUTH_HEADER` set on both services. Ask whoever runs this deployment.", + `Could not start the ${toolkit} connection: this deployment's agent ` + + "address is not a usable URL. Ask whoever runs it.", }; } + const controller = new AbortController(); + const deadline = setTimeout(() => controller.abort(), timeoutMs); let response: Response; try { - response = await fetchImpl(connectEndpoint(agentUrl), { + response = await fetchImpl(endpoint, { method: "POST", headers: { "content-type": "application/json", - authorization: agentAuthHeader, + authorization: secret, }, - body: JSON.stringify({ - actor_id: actorId, - kind: actorKind, - platform, - toolkit, - }), + body, + signal: controller.signal, }); - } catch { - // The reason is a network detail; the person can only retry either way. + } catch (error) { + // The reason is a network detail; the person can only retry either way. It + // still belongs in the log, where the operator can see whether every click + // is failing and why. + console.error( + `[opentag] the agent could not be reached to mint a ${toolkit} connect link`, + error, + ); return { ok: false, message: `Could not reach the agent to start the ${toolkit} connection. Try again shortly.`, }; + } finally { + clearTimeout(deadline); } if (!response.ok) { - const detail = await readErrorMessage(response); + if (response.status === 401 || response.status === 403) { + // The agent's own body here is the bare word "unauthorized", which tells + // the person nothing they can act on. It is also the one response that + // could quote the credential back, and a thread is the last place that + // may appear. + console.error( + `[opentag] the agent rejected this service's AGENT_AUTH_HEADER (${response.status}) ` + + `while minting a ${toolkit} connect link; the two halves do not match`, + ); + return { ok: false, message: SECRET_REJECTED }; + } + + const detail = withoutSecret(await readErrorMessage(response), secret); + if (detail === null) { + console.error( + `[opentag] the agent answered ${response.status} with no usable reason ` + + `while minting a ${toolkit} connect link`, + ); + } return { ok: false, message: detail ?? `Could not start the ${toolkit} connection.`, }; } - const payload = (await response.json().catch(() => null)) as { - redirectUrl?: unknown; - } | null; - const url = payload?.redirectUrl; - if (typeof url !== "string" || url.length === 0) { + let payload: { redirectUrl?: unknown } | null; + try { + payload = (await response.json()) as { redirectUrl?: unknown } | null; + } catch (error) { + // Not the same thing as "the agent had no link for you": this is something + // other than the agent answering — a proxy, usually — and conflating the + // two sent the person off to check their Composio configuration. + console.error( + `[opentag] the agent's ${toolkit} connect reply was not JSON`, + error, + ); return { ok: false, - message: `Could not start the ${toolkit} connection. Try again shortly.`, + message: `Could not start the ${toolkit} connection: the reply was unreadable. Try again shortly.`, + }; + } + + const url = safeConnectUrl(payload?.redirectUrl); + if (url === null) { + console.error( + `[opentag] the agent returned no usable ${toolkit} connect link`, + ); + return { + ok: false, + message: `Could not start the ${toolkit} connection: no link came back. Try again shortly.`, }; } return { ok: true, url }; } +/** + * The agent's sentence with the one credential this side knows taken out of it. + * + * A 4xx body is written for a person and goes straight into a thread. We handed + * the agent exactly one secret, so that is exactly one string we can recognize + * on the way back — both as the whole header value and as the token inside it, + * because an error like `token abc… is not valid` quotes only the second. + */ +function withoutSecret(message: string | null, secret: string): string | null { + if (message === null) return null; + let scrubbed = message; + for (const needle of secretNeedles(secret)) { + scrubbed = scrubbed.split(needle).join("[redacted]"); + } + return scrubbed; +} + +/** The header value, and the token in it when that is long enough to be one. */ +function secretNeedles(secret: string): string[] { + const needles = new Set(); + if (secret.length > 0) needles.add(secret); + const token = secret.split(/\s+/).at(-1); + if (token && token.length >= 6) needles.add(token); + return [...needles].sort((a, b) => b.length - a.length); +} + +/** + * The minted link, if it is one that may be rendered. + * + * It is rendered into Slack's `` syntax, where `|` and `>` end the + * url half — a link carrying either could smuggle a label of its own or a + * second link past the person reading it. The scheme is checked because a + * `javascript:` or `data:` URL in that position is not a connect flow. + */ +function safeConnectUrl(raw: unknown): string | null { + if (typeof raw !== "string" || raw.length === 0) return null; + if (/[<>|"'\s]/.test(raw)) return null; + let parsed: URL; + try { + parsed = new URL(raw); + } catch { + return null; + } + if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return null; + return raw; +} + +/** A JSON body is the agent answering; anything else is something in front of it. */ +function isJson(response: Response): boolean { + return (response.headers.get("content-type") ?? "").includes("json"); +} + /** * The agent's own sentence when it has one. * - * Only from a 4xx: those are its considered refusals ("that app is connected by - * an operator, not from Slack"), and they are written for a person. A 5xx body - * is a stack trace or a proxy's HTML. + * From a 4xx: those are its considered refusals ("that app is connected by an + * operator, not from Slack"), and they are written for a person. And from a + * 503, which is the agent saying it is not configured for this — equally its + * own sentence and the only one that names what to fix. Every other 5xx is a + * stack trace. The content type is checked because a proxy's 503 is HTML and + * carries no sentence for anyone. */ async function readErrorMessage(response: Response): Promise { - if (response.status >= 500) return null; + if (response.status >= 500 && response.status !== 503) return null; + if (!isJson(response)) return null; try { - const payload = (await response.json()) as { error?: unknown }; - return typeof payload.error === "string" && payload.error.length > 0 - ? payload.error - : null; + const payload = (await response.json()) as { error?: unknown } | null; + const error = payload?.error; + if (typeof error !== "string") return null; + const trimmed = error.trim(); + return trimmed.length > 0 ? trimmed : null; } catch { return null; } diff --git a/app/tools/connect-app.tsx b/app/tools/connect-app.tsx index 76003c4..f451e26 100644 --- a/app/tools/connect-app.tsx +++ b/app/tools/connect-app.tsx @@ -15,6 +15,7 @@ import { defineChannelTool } from "@copilotkit/channels"; import { z } from "zod"; import { ConnectAccount } from "../human-in-the-loop/connect-account.js"; +import { normalizeToolkit } from "./composio-connect.js"; export const connectAppTool = defineChannelTool({ name: "connect_app", @@ -29,8 +30,22 @@ export const connectAppTool = defineChannelTool({ .describe("The app to connect, as the search reported it, e.g. 'gmail'"), }), async handler({ toolkit }, { thread }) { - const slug = toolkit.trim().toLowerCase(); - if (!slug) return "No app was named, so no button was posted."; + if (!toolkit.trim()) return "No app was named, so no button was posted."; + + // The model chose this string and the card carrying it is a PUBLIC post + // rendered as mrkdwn, where `` is a live hyperlink and `*x*` is + // bold. A toolkit is an identifier, so anything outside the identifier + // charset is not a toolkit name and no card is posted for it. The rejected + // value is not quoted back: the model repeats tool results to people, which + // would put it on a rendered surface by a second route. + const slug = normalizeToolkit(toolkit); + if (slug === null) { + return ( + "That is not an app name, so no button was posted. App names are " + + "lowercase identifiers like 'gmail' or 'google_calendar'; ask the " + + "person which app they mean." + ); + } await thread.post(); return ( diff --git a/app/tools/connect-click.tsx b/app/tools/connect-click.tsx index fe8b192..caaa844 100644 --- a/app/tools/connect-click.tsx +++ b/app/tools/connect-click.tsx @@ -4,43 +4,125 @@ * Kept out of the card so it can be tested without rendering one, and out of * `composio-connect.ts` so that module stays a pure client with no knowledge of * threads or delivery. + * + * Nothing awaits this handler, so nothing here may throw: a rejection escaping + * it is an unhandled one, and all the person sees is a button that did nothing. + * Every step below either delivers something or logs why it could not. */ -import type { InteractionContext } from "@copilotkit/channels"; +import type { InteractionContext, Renderable } from "@copilotkit/channels"; import { readEnvironment } from "../env.js"; import { ConnectFailed, ConnectLink, type ConnectRequest, } from "../human-in-the-loop/connect-account.js"; -import { requestConnectLink } from "./composio-connect.js"; +import { normalizeToolkit, requestConnectLink } from "./composio-connect.js"; + +type Interaction = InteractionContext; /** - * Deliver privately, or say why not — to the clicker, either way. + * Deliver privately, and say so publicly when that was not possible. + * + * `Thread.postEphemeral` reports a non-delivery two ways, and neither is an + * exception: `null` when the surface has no native ephemeral message and was + * told not to DM, and `{ ok: false }` when the adapter offers no private + * message at all. Both results were discarded here, so on the default + * deployment — the managed Intelligence adapter, which declares + * `supportsEphemeral: false` — the minted link went nowhere, the thread stayed + * silent, and nothing was logged. The button did nothing, twice over. * - * `fallbackToDM: false`: a connect link must not follow someone into a DM when - * the surface cannot show an ephemeral message. On a surface that cannot, the - * right outcome is that nothing is delivered rather than a bearer capability - * arriving somewhere it was not scoped to. + * So: ask for the DM fallback, because a DM is scoped to the clicker exactly as + * an ephemeral message is — the hazard a connect link carries is a *second + * reader*, and a DM has none. Then check what came back. When nothing was + * delivered the thread gets a sentence saying so, never the link: whoever + * completes a connect flow binds their account to the id it was minted for, so + * a link a second person can read is an account takeover. + * + * The managed adapter today implements no `postEphemeral` at all, so on that + * surface the honest outcome is still the notice — but it is now a said one, + * with a log line naming what to configure, and the DM path starts working the + * moment that adapter or the direct Slack pair provides it. */ export async function handleConnectClick( toolkit: string, - interaction: InteractionContext, + interaction: Interaction, deps: { environment?: ReturnType; + readEnvironment?: typeof readEnvironment; request?: typeof requestConnectLink; } = {}, ): Promise { - const environment = deps.environment ?? readEnvironment(); + try { + await runConnectClick(toolkit, interaction, deps); + } catch (error) { + // The last resort. Everything below is already guarded, so reaching here + // means something threw that was not expected to — and the person is still + // looking at a button that appears to have done nothing. + console.error(`[opentag] the ${toolkit} connect click failed outright`, error); + await postToThread( + interaction, + , + ); + } +} + +async function runConnectClick( + toolkit: string, + interaction: Interaction, + deps: { + environment?: ReturnType; + readEnvironment?: typeof readEnvironment; + request?: typeof requestConnectLink; + }, +): Promise { const request = deps.request ?? requestConnectLink; - const actor = interaction.actor; + // The value travels on the card, and the card was posted from a name the + // model chose. A click after a restart re-derives that card from its stored + // props, so this is the last place the value is checked before it is rendered + // again. + const slug = normalizeToolkit(toolkit); + if (slug === null) { + console.error( + "[opentag] a connect click carried something that is not an app name; nothing was minted", + ); + await postToThread( + interaction, + , + ); + return; + } + + const actor = interaction.actor; if (!actor?.id) { // Without a verified clicker there is nobody to mint for. Minting anyway - // would bind an account to whatever id we guessed. - await interaction.thread.postEphemeral( - actor ?? "unknown", + // would bind an account to whatever id we guessed. The notice goes to the + // thread rather than to a made-up id: `postEphemeral("unknown", …)` + // addresses a user that does not exist, so nobody ever saw it. + console.error( + "[opentag] a connect click arrived with no identifiable actor; nothing was minted", + ); + await postToThread( + interaction, , - { fallbackToDM: false }, + ); + return; + } + + let environment: ReturnType; + try { + environment = deps.environment ?? (deps.readEnvironment ?? readEnvironment)(); + } catch (error) { + // `readEnvironment()` throws on a deployment missing `AGENT_URL`, and it + // runs per click — inside a handler nothing awaits. + console.error( + "[opentag] a connect click could not read this deployment's configuration", + error, + ); + await deliver( + interaction, + actor, + , ); return; } @@ -51,16 +133,90 @@ export async function handleConnectClick( actorId: actor.id, actorKind: actor.kind, platform: interaction.platform, - toolkit, + toolkit: slug, }); - await interaction.thread.postEphemeral( + if (!result.ok) { + // A refusal carries no capability, so the thread is a safe second home for + // it — and silence is not one. + await deliver(interaction, actor, ); + return; + } + + const delivered = await deliverPrivately( + interaction, actor, - result.ok ? ( - - ) : ( - - ), - { fallbackToDM: false }, + , ); + if (delivered) return; + + // Names the fix, in the one place only an operator reads. The managed + // Intelligence delivery adapter implements no `postEphemeral` at all, so + // neither a native ephemeral message nor the SDK's DM fallback exists on that + // path; setting the direct Slack pair attaches an adapter that has both. + console.error( + `[opentag] a minted ${slug} connect link could not be delivered privately ` + + "and was discarded rather than posted publicly. This surface offers no " + + "private message; set SLACK_BOT_TOKEN and SLACK_APP_TOKEN to attach the " + + "direct Slack adapter, which does.", + ); + await postToThread( + interaction, + + ); +} + +/** Privately if the surface can, in the thread if it cannot. Never silent. */ +async function deliver( + interaction: Interaction, + actor: NonNullable, + ui: Renderable, +): Promise { + if (await deliverPrivately(interaction, actor, ui)) return; + await postToThread(interaction, ui); +} + +/** + * True only when the surface actually put this in front of that one person. + * + * `null` means the surface delivered nothing; `{ ok: false }` means it declined + * and said why. Neither is an exception, which is how both came to be dropped. + */ +async function deliverPrivately( + interaction: Interaction, + actor: NonNullable, + ui: Renderable, +): Promise { + try { + const result = await interaction.thread.postEphemeral(actor, ui, { + fallbackToDM: true, + }); + if (result?.ok) return true; + console.error( + "[opentag] the surface delivered no private connect message:", + result?.error ?? "no ephemeral message and no DM on this surface", + ); + } catch (error) { + console.error("[opentag] private connect delivery failed", error); + } + return false; +} + +/** The public half. Only ever a sentence — never a link. */ +async function postToThread( + interaction: Interaction, + ui: Renderable, +): Promise { + try { + await interaction.thread.post(ui); + } catch (error) { + console.error("[opentag] could not post the connect notice to the thread", error); + } } From 2f855fb4d13215fceabca5ba761341efafe850fa Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 2 Sep 2026 20:37:34 +0200 Subject: [PATCH 17/23] fix(composio): say when a lookup failed instead of answering "nothing found" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every failure on this path had a way of arriving as a success. `search_my_tools` read none of the three fields the response uses to report a failed search — `success`, `error`, `Result.error` — so a server-side outage reached the model as an empty tool list, which the model then reports to a person as a settled fact about their connected apps. Every scope failing returned the same empty-but-successful payload. Now a search that did not run says so, a partial answer carries `searchFailures` naming what was not searched, and an absent key is the only way to read "we looked and found nothing". `owning.session.execute` was the one unguarded provider call, and the only one that runs *after* an approval has been spent: a raise there ended the turn with the card's last word still "running". It is guarded, the failure reaches the thread through the same `emit_write_failure` the coding tools use, and it goes under the label the card carried rather than the raw slug. `_as_dict` answering `{}` for a shape it did not recognise made an unreadable result indistinguishable from a success carrying nothing; `_execute_fields` answers `None` for that and the caller says it failed. The attribute branch now goes through `_plain` like the mapping one, so a nested SDK model no longer reaches the model as a repr. The empty `except: pass` in `_plain` logs. Sessions dropped while resolving a turn were dropped into silence, so callers said "not configured for you" — a statement about somebody's setup — when the truth was that a lookup failed. `resolve` returns both halves. The cache never invalidated or bounded, so one stale session took an identity out until the process restarted: it is an LRU capped at MAX_SESSIONS, and a scope whose search or execute fails is invalidated so the next turn builds a fresh one. `except Exception` at three provider calls swallowed `TypeError` and `AttributeError` from an SDK whose signature moved. A broken build read as a permanent runtime outage, which is the one diagnosis that leads nobody to the cause. All three re-raise; the execute one reports to the thread first, because the approval was already spent. The process-wide runtime ignored its `env` and `default_user_id` after the first call — a cache that is right the first time and wrong afterwards. It is keyed on its arguments, and cleared before each build so a `ComposioConfigError` cannot leave a previous answer standing behind a key it no longer belongs to. `forwarded_actor` returned on the first key *present* rather than the first key that names a person, so a null `channel_actor` beside a real `channelActor` discarded a person the Channel had identified and ran the turn anonymously. `with_trusted_actor` rewrites `input.state`, but the adapter has a second entry point: `prepare_regenerate_stream` forks from `time_travel_checkpoint.values` and reads neither `input.state` nor the forwarded properties. It is entered on a message-shape heuristic rather than a flag, and it is reachable in production — the managed adapter keeps one LangGraph thread per conversation, so from the second turn on the transcript arrives with ids the checkpoint has never seen. A regenerated turn therefore ran as whoever spoke when that checkpoint was written: the integration test pins U2 speaking and U1 acting. The actor is now stamped in `langgraph_default_merge_state`, the one seam both paths pass through and the last point before the graph runs. Test fixtures pinned `approvals="destructive"`, a value `read_composio_config` has not been able to produce since the mode collapse, written straight into the dataclass. They are `"on"`, the deprecated spellings are exercised through the parser that is the only thing which still accepts them, and a new test pins the fixture to what the parser actually returns so it cannot drift again. Call sites of what changed: - `SessionCache.resolve` now returns `ResolvedSessions` rather than a tuple of `ScopedSession`. Callers: `composio_tools/tools.py::sessions_for`, `tests/test_composio_sessions.py`. No others in the repo — the connect route and CLI use `for_scope`/`client`, not `resolve`. - New `SessionCache.invalidate` (called from both tools on a provider failure), `SessionCache.size` (tests), `DroppedScope`, `ResolvedSessions`, `MAX_SESSIONS`. - New module-private helpers in `tools.py`: `_search_failure`, `_execute_fields`, `_scope_name`, `_unreachable`. No callers outside the module. - `emit_write_failure` imported into `tools.py`; unchanged, and used the same way `coding/repository_tools.py` already uses it. - `OpenTagAGUIAgent.langgraph_default_merge_state` overrides `ag_ui_langgraph.LangGraphAgent`'s; called by `prepare_stream` and `prepare_regenerate_stream`. - `runtime._built_from` is new and module-private; `reset_composio_runtime` clears it. Its callers (`tests/test_composio_connect.py`, `tests/test_health.py`) are unchanged. - Test helper `parsed_config` added in `tests/test_composio_tools.py`. Every new test was written red, and each fix was reverted afterwards to confirm the test fails without it — 19 mutants, 19 killed. Co-Authored-By: Claude Opus 5 (1M context) --- agent/agui.py | 23 ++ agent/composio_tools/effects.py | 10 + agent/composio_tools/runtime.py | 30 +- agent/composio_tools/sessions.py | 81 +++- agent/composio_tools/state.py | 11 +- agent/composio_tools/tools.py | 264 +++++++++++-- agent/tests/test_composio_approval_resume.py | 4 +- agent/tests/test_composio_classify.py | 38 +- agent/tests/test_composio_identity.py | 124 +++++- agent/tests/test_composio_sessions.py | 184 ++++++++- agent/tests/test_composio_tools.py | 380 ++++++++++++++++++- 11 files changed, 1075 insertions(+), 74 deletions(-) diff --git a/agent/agui.py b/agent/agui.py index 8fefe05..1997059 100644 --- a/agent/agui.py +++ b/agent/agui.py @@ -60,6 +60,29 @@ async def run(self, input_data): ): yield event + def langgraph_default_merge_state(self, state, messages, input): + """Every graph input, with its identity stamped by this run's actor. + + Rewriting `input.state` is not enough on its own. The adapter has two + entry points: `prepare_stream` reads `input.state`, and + `prepare_regenerate_stream` — which it enters on a message-shape + heuristic, not on a flag anybody sets — forks from + `time_travel_checkpoint.values` and reads neither `input.state` nor the + forwarded properties. A turn taking that path used to run as whoever + spoke when that checkpoint was written. + + That is reachable on the managed adapter, which keeps one LangGraph + thread per conversation: from the second turn on, the transcript arrives + carrying ids the checkpoint has never seen, which is exactly what the + heuristic reads as an edit. + + This method is the one seam both paths pass through, and it is the last + point before the graph runs, so the actor is decided here for every run + whichever way the adapter got there. + """ + merged = super().langgraph_default_merge_state(state, messages, input) + return with_forwarded_actor(merged, getattr(input, "forwarded_props", None)) + def build_agui_agent(graph, *, recursion_limit: int | None = None): """Wire the Slack/AG-UI adapter with the graph's resolved step limit.""" diff --git a/agent/composio_tools/effects.py b/agent/composio_tools/effects.py index 54cd054..55f8ed4 100644 --- a/agent/composio_tools/effects.py +++ b/agent/composio_tools/effects.py @@ -52,6 +52,16 @@ def effect_for(self, slug: str) -> str: tool: Any = self._client_factory().tools.get_raw_composio_tool_by_slug( slug ) + except (TypeError, AttributeError): + # Not a provider having a bad day: a call that no longer matches the + # SDK, or a client that no longer carries `tools`. Folded into the + # branch below it becomes "could not look it up, treating it as + # destructive" for every slug, for the life of the process — a + # sentence that describes an outage and leads nobody to the actual + # cause. Raised instead, because a build whose SDK calls no longer + # land is broken rather than degraded, and gating every read behind + # an approval card is a symptom that gets blamed on something else. + raise except Exception as error: # noqa: BLE001 - provider errors vary logger.warning( "[composio] could not look %s up, treating it as destructive: %s", diff --git a/agent/composio_tools/runtime.py b/agent/composio_tools/runtime.py index 74eb387..480e90d 100644 --- a/agent/composio_tools/runtime.py +++ b/agent/composio_tools/runtime.py @@ -11,6 +11,7 @@ import logging from collections.abc import Mapping from dataclasses import dataclass +from typing import Any from composio_tools.config import ComposioConfig, read_composio_config from composio_tools.effects import EffectMap @@ -21,6 +22,10 @@ _runtime: ComposioRuntime | None = None _built = False +#: The arguments the cached answer was built from. A cache that ignores the +#: arguments it was called with is not a cache, it is a wrong answer that is +#: right the first time. +_built_from: Any = None @dataclass(frozen=True) @@ -58,15 +63,30 @@ def composio_runtime( Cached including the `None` answer: an unconfigured deployment must not re-read the environment and re-log on every request to the connect route. """ - global _runtime, _built - if not _built: - _runtime = build_composio_runtime(env, default_user_id=default_user_id) - _built = True + global _runtime, _built, _built_from + key = (env, default_user_id) + if _built and _built_from == key: + return _runtime + + # Cleared *before* the build, so a `ComposioConfigError` cannot leave the + # previous answer standing behind a key it no longer belongs to. Both call + # sites pass the same arguments, so in a running deployment this rebuilds + # nothing; what it removes is the case where they stop being the same and + # one of them silently gets the other's configuration. + _runtime = None + _built = False + _built_from = None + + runtime = build_composio_runtime(env, default_user_id=default_user_id) + _runtime = runtime + _built = True + _built_from = key return _runtime def reset_composio_runtime() -> None: """Drop the cached runtime. For tests, which vary the environment.""" - global _runtime, _built + global _runtime, _built, _built_from _runtime = None _built = False + _built_from = None diff --git a/agent/composio_tools/sessions.py b/agent/composio_tools/sessions.py index 4188583..75cf11a 100644 --- a/agent/composio_tools/sessions.py +++ b/agent/composio_tools/sessions.py @@ -8,6 +8,7 @@ from __future__ import annotations import logging +from collections import OrderedDict from dataclasses import dataclass from typing import Any, Protocol @@ -18,6 +19,14 @@ logger = logging.getLogger(__name__) +#: How many sessions one process keeps at once. +#: +#: A session holds no credential and costs one round trip to rebuild, but a +#: deployment serving a whole workspace mints one per person and the process +#: outlives every conversation — so this is bounded, and the least recently used +#: identity is the one that pays for the next arrival. +MAX_SESSIONS = 256 + class Session(Protocol): """The part of a Composio session this package uses.""" @@ -39,6 +48,28 @@ class ScopedSession: scope: ResolvedScope +@dataclass(frozen=True) +class DroppedScope: + """A scope that could not produce a session, and the provider's reason.""" + + scope: ResolvedScope + reason: str + + +@dataclass(frozen=True) +class ResolvedSessions: + """Both halves of resolving a turn's scopes. + + `dropped` exists because a caller holding only `sessions` cannot tell a + person with no personal toolkits from a person whose account could not be + reached this turn — and it tells them "not configured for you", which is a + settled fact about their setup rather than the outage it actually is. + """ + + sessions: tuple[ScopedSession, ...] + dropped: tuple[DroppedScope, ...] + + class SessionCache: """ Sessions keyed by identity and toolkit set. @@ -50,7 +81,14 @@ class SessionCache: def __init__(self, config: ComposioConfig, *, client: Any | None = None) -> None: self._config = config self._client = client - self._sessions: dict[tuple[str, tuple[str, ...]], Session] = {} + self._sessions: OrderedDict[tuple[str, tuple[str, ...]], Session] = ( + OrderedDict() + ) + + @property + def size(self) -> int: + """How many sessions are held. For tests and for a health check.""" + return len(self._sessions) def client(self) -> Any: """The SDK client, constructed on first use. @@ -62,10 +100,28 @@ def client(self) -> Any: self._client = Composio(api_key=self._config.api_key) return self._client + def _key(self, scope: ResolvedScope) -> tuple[str, tuple[str, ...]]: + return (scope.user_id, scope.toolkits) + + def invalidate(self, scope: ResolvedScope) -> None: + """Forget one scope's session so the next use builds a fresh one. + + A session that has started failing goes on failing for as long as it is + cached, so without this one stale session takes an identity out of + service until the process restarts. Dropping it costs a single round + trip, and nothing is lost: the connected accounts live on Composio's + side, not in here. + """ + self._sessions.pop(self._key(scope), None) + def for_scope(self, scope: ResolvedScope) -> ScopedSession: """The session for one scope, created on first use and reused after.""" - key = (scope.user_id, scope.toolkits) + key = self._key(scope) session = self._sessions.get(key) + if session is not None: + # Most recently used, so the eviction below takes an identity that + # has gone quiet rather than one in the middle of a conversation. + self._sessions.move_to_end(key) if session is None: session = self.client().sessions.create( user_id=scope.user_id, @@ -93,11 +149,13 @@ def for_scope(self, scope: ResolvedScope) -> ScopedSession: manage_connections=False, ) self._sessions[key] = session + while len(self._sessions) > MAX_SESSIONS: + self._sessions.popitem(last=False) return ScopedSession(session=session, scope=scope) - def resolve(self, scopes: tuple[ResolvedScope, ...]) -> tuple[ScopedSession, ...]: + def resolve(self, scopes: tuple[ResolvedScope, ...]) -> ResolvedSessions: """ - Live sessions for every scope that can produce one. + Live sessions for every scope that can produce one, and the rest named. A scope whose session cannot be created is logged and dropped rather than raising. One unreachable personal account must not take the team's @@ -105,15 +163,27 @@ def resolve(self, scopes: tuple[ResolvedScope, ...]) -> tuple[ScopedSession, ... can still answer — while one that raises here answers nothing and explains nothing. + Dropped is not the same as absent, so the dropped scopes come back with + their reasons. A caller that sees only the survivors tells the person + "connected apps are not configured for you", which is a statement about + their setup and not about the lookup that just failed. + The log names the scope so an operator can tell whose account went missing, and the provider's reason so they can tell why. Neither is a credential: the api key never leaves this module, and a failure to create a session is not itself a capability. """ resolved: list[ScopedSession] = [] + dropped: list[DroppedScope] = [] for scope in scopes: try: resolved.append(self.for_scope(scope)) + except (TypeError, AttributeError): + # The SDK no longer takes what this module passes it. That is a + # broken build, and every scope will fail the same way — read as + # an unreachable account it becomes a permanent, misleading + # "that person is not connected". + raise except Exception as error: # noqa: BLE001 - provider errors vary logger.warning( "[composio] no session for user=%s toolkits=%s — " @@ -122,4 +192,5 @@ def resolve(self, scopes: tuple[ResolvedScope, ...]) -> tuple[ScopedSession, ... ",".join(scope.toolkits), error, ) - return tuple(resolved) + dropped.append(DroppedScope(scope=scope, reason=str(error))) + return ResolvedSessions(sessions=tuple(resolved), dropped=tuple(dropped)) diff --git a/agent/composio_tools/state.py b/agent/composio_tools/state.py index 64eb14d..9793ab9 100644 --- a/agent/composio_tools/state.py +++ b/agent/composio_tools/state.py @@ -167,13 +167,18 @@ def forwarded_actor(forwarded_props: Any) -> dict[str, Any] | None: Both spellings are accepted because the key is snake-cased on its way through the adapter, and this runs before that happens on one path and after - it on another. + it on another. Both can therefore arrive in the same mapping, which is why + the search is for the first key that *names somebody* rather than the first + key that is present: a null or malformed `channel_actor` sitting beside a + real `channelActor` used to discard it, and the turn then ran anonymously — + no personal toolkits, for a person the Channel had identified. """ if not isinstance(forwarded_props, Mapping): return None for key in _CALLER_ACTOR_KEYS: - if key in forwarded_props: - return personal_actor(forwarded_props[key]) + actor = personal_actor(forwarded_props.get(key)) + if actor is not None: + return actor return None diff --git a/agent/composio_tools/tools.py b/agent/composio_tools/tools.py index e289825..cdc547f 100644 --- a/agent/composio_tools/tools.py +++ b/agent/composio_tools/tools.py @@ -22,10 +22,14 @@ from composio_tools.classify import needs_approval from composio_tools.config import ComposioConfig from composio_tools.effects import EffectMap -from composio_tools.scopes import resolve_scopes -from composio_tools.sessions import ScopedSession, SessionCache +from composio_tools.scopes import ResolvedScope, resolve_scopes +from composio_tools.sessions import DroppedScope, ResolvedSessions, SessionCache from composio_tools.state import actor_key, actor_of -from write_confirmation import require_write_confirmation, summarize_args +from write_confirmation import ( + emit_write_failure, + require_write_confirmation, + summarize_args, +) logger = logging.getLogger(__name__) @@ -47,8 +51,16 @@ def _plain(value: Any) -> Any: if callable(dump): try: return dump() - except Exception: # noqa: BLE001 - a model that cannot dump is not fatal - pass + except Exception as error: # noqa: BLE001 - not fatal, but never silent + # Falling through leaves an object no reader here understands, and + # both readers treat that as a failure rather than as empty data. + # Said out loud because it is a change in the SDK, and the symptom + # downstream ("nothing came back") points nowhere near it. + logger.warning( + "[composio] could not read a %s as data: %s", + type(value).__name__, + error, + ) if isinstance(value, dict): return {key: _plain(item) for key, item in value.items()} if isinstance(value, list): @@ -70,6 +82,30 @@ def _as_strings(value: Any) -> list[str]: return [item for item in _as_list(value) if isinstance(item, str)] +def _execute_fields(result: Any) -> dict[str, Any] | None: + """ + One execute result as fields, or `None` when nothing here can read it. + + `_as_dict` answered `{}` for every shape it did not recognise, and `{}` reads + downstream as no error and no data — a success carrying nothing. An + unrecognised result is not a success; it is a result nobody read, and the + caller has to be able to tell the difference. + + The attribute path goes through `_plain` exactly like the mapping one. It + did not, and the SDK nests models inside models, so `data` reached the model + as an object whose repr was all it could see. + """ + plain = _plain(result) + if isinstance(plain, dict): + return plain + fields = { + name: _plain(getattr(result, name)) + for name in ("data", "error", "log_id", "logId") + if hasattr(result, name) + } + return fields or None + + def _candidates_of(response: Any) -> list[dict[str, Any]]: """ Every candidate one scope offers, in the order that scope ranked them. @@ -101,6 +137,72 @@ def _candidates_of(response: Any) -> list[dict[str, Any]]: return candidates +def _search_failure(response: Any) -> str | None: + """ + Why this search did not run, or `None` when it ran. + + Three fields say it and all three are read: `success` is the response's own + verdict, `error` carries the reason ("X out of Y searches failed, reasons: + …"), and `Result.error` reports the single query we send failing on its own. + + A response that carries no candidates *because* it failed must never reach + the model as an empty list. The model reports an empty list to a person as a + settled fact — "you have no tool for that" — and a server-side outage is not + a fact about anybody's connected apps. + """ + payload = _plain(response) + if not isinstance(payload, dict): + # `_as_dict` answers `{}` here, which is indistinguishable from a + # response that legitimately found nothing. + return ( + "the provider returned a response this agent cannot read " + f"({type(response).__name__})" + ) + + stated = payload.get("error") + reason = stated.strip() if isinstance(stated, str) else "" + failed = payload.get("success") is False or bool(reason) + + for entry in _as_list(payload.get("results")): + per_query = _as_dict(entry).get("error") + if isinstance(per_query, str) and per_query.strip(): + failed = True + reason = reason or per_query.strip() + + if not failed: + return None + return reason or "the provider reported the search as failed" + + +def _scope_name(scope: ResolvedScope) -> str: + """A scope named by what it reaches, not by whose id it holds. + + The failure list is read by the model, so it says "gmail (your account)" + rather than the Composio user id — which is the person's platform identity + and buys the model nothing. + """ + toolkits = ", ".join(scope.toolkits) or "no toolkits" + return f"{toolkits} ({'your account' if scope.personal else 'the shared account'})" + + +def _unreachable(dropped: tuple[DroppedScope, ...]) -> str: + """What to say when this turn resolved no session at all. + + "Not configured for you" is a statement about somebody's setup, and telling + a person to connect an app they already connected is the wrong instruction + — so it is said only when nothing was even attempted. + """ + if not dropped: + return "Connected apps are not configured for you." + reasons = "; ".join( + f"{_scope_name(entry.scope)}: {entry.reason}" for entry in dropped + ) + return ( + "Connected apps could not be reached on this turn. This is a lookup " + f"failure and not a missing setup: {reasons}" + ) + + def _interleave(per_scope: list[list[dict[str, Any]]]) -> list[dict[str, Any]]: """ Round-robin across scopes rather than concatenating them. @@ -165,7 +267,7 @@ def build_composio_tools( """The Composio tools for this deployment, or none at all.""" effects = effects or EffectMap(cache.client) - def sessions_for(state: dict[str, Any] | None) -> tuple[ScopedSession, ...]: + def sessions_for(state: dict[str, Any] | None) -> ResolvedSessions: # The platform-namespaced key, not the raw provider id. A provider id is # unique only within its provider, so one deployment serving Slack and # Teams would otherwise give `U1` on either platform the same Composio @@ -196,23 +298,51 @@ def search_my_tools( Args: query: What you want to do, in plain words, e.g. 'send an email'. """ - scopes = sessions_for(state) + resolved = sessions_for(state) + scopes = resolved.sessions if not scopes: - return "Connected apps are not configured for you." + return _unreachable(resolved.dropped) per_scope: list[list[dict[str, Any]]] = [] needs_connection: list[str] = [] + # A scope that never produced a session is a scope that was not + # searched, and it is carried here for the same reason a failed search + # is: silence would make a partial answer look like a whole one. + failures: list[str] = [ + f"{_scope_name(entry.scope)}: {entry.reason}" for entry in resolved.dropped + ] for entry in scopes: try: response = entry.session.search(query=query) + except (TypeError, AttributeError): + # A call that no longer matches the SDK's signature is a broken + # build, not a scope having a bad day. Folded into the outage + # branch below it would read as "that app is unreachable" on + # every turn and forever, which is the one diagnosis that leads + # nobody to the actual cause. + raise except Exception as error: # noqa: BLE001 - provider errors vary - # One scope's failure costs its own candidates and nothing else. + # One scope's failure costs its own candidates and nothing else + # — but it is still carried back, because "we did not look" and + # "we looked and found nothing" are different answers. logger.warning( "[composio] search failed for user=%s: %s", entry.scope.user_id, error, ) + cache.invalidate(entry.scope) + failures.append(f"{_scope_name(entry.scope)}: {error}") + continue + + failure = _search_failure(response) + if failure is not None: + logger.warning( + "[composio] search reported a failure for user=%s: %s", + entry.scope.user_id, + failure, + ) + failures.append(f"{_scope_name(entry.scope)}: {failure}") continue per_scope.append(_candidates_of(response)) @@ -233,16 +363,30 @@ def search_my_tools( if isinstance(toolkit, str) and toolkit not in needs_connection: needs_connection.append(toolkit) + if failures and not per_scope: + # Nothing was searched. Returning `{"tools": []}` here is the + # failure this whole function most has to avoid: it is a lookup + # outage wearing the words "no tools found". + return ( + "Searching connected apps failed, so this is not an empty " + "result — nothing was searched. " + "; ".join(failures) + ) + merged = _interleave(per_scope) # A candidate with no schema cannot be called, so it must never displace # one that can — but it still ships, so the model can see it exists. ordered = [item for item in merged if item["inputSchema"] is not None] + [ item for item in merged if item["inputSchema"] is None ] - return { + payload: dict[str, Any] = { "tools": ordered[:MAX_RESULTS], "needsConnection": needs_connection, } + if failures: + # Present only when there were failures, so an absent key means + # every scope answered and an empty `tools` really is empty. + payload["searchFailures"] = failures + return payload @tool def run_my_tool( @@ -256,22 +400,43 @@ def run_my_tool( slug: The tool slug from search_my_tools, e.g. 'GMAIL_SEND_EMAIL'. arguments: Arguments matching that tool's input schema. """ - scopes = sessions_for(state) + resolved = sessions_for(state) + scopes = resolved.sessions if not scopes: - return "Connected apps are not configured for you." + return _unreachable(resolved.dropped) owning = next( (entry for entry in scopes if owns_slug(entry.scope.toolkits, slug)), None, ) if owning is None: + # The app may be configured and simply unreachable this turn. Saying + # "no connected app provides it" would send the model, and then the + # person, to fix a setup that is not broken. + lost = next( + ( + entry + for entry in resolved.dropped + if owns_slug(entry.scope.toolkits, slug) + ), + None, + ) + if lost is not None: + return ( + f"{slug} belongs to {_scope_name(lost.scope)}, which could " + f"not be reached on this turn: {lost.reason}" + ) return ( f"No connected app here provides {slug}. " "Call search_my_tools and use a slug it returned." ) effect = effects.effect_for(slug) - if needs_approval(effect, config.approvals): + # The label the card carried, and whether there was a card at all. Both + # decide what a later failure is allowed to say, and to whom. + label = humanize_slug(slug) + gated = needs_approval(effect, config.approvals) + if gated: # The same card, and the same pause, that already gate a Linear or # Notion write. One gate for every action a person has to sign off # on, rather than a second mechanism that behaves almost the same. @@ -279,7 +444,7 @@ def run_my_tool( # The graph resumes after the decision, so unlike the channel-side # version the model sees the result of an approved call. approved = require_write_confirmation( - action=humanize_slug(slug), + action=label, fields=summarize_args(arguments), extra_args={ # Who may answer this card. A personal call runs in one @@ -293,32 +458,69 @@ def run_my_tool( }, ) if not approved: - return f"{humanize_slug(slug)} was declined, so nothing ran." + return f"{label} was declined, so nothing ran." - # `arguments` is keyword-only in the Python SDK. The TypeScript one took - # it positionally, and a hand-written fake happily accepted either. - result = owning.session.execute(slug, arguments=arguments) - fields = _as_dict(result) if not hasattr(result, "error") else None - error = fields.get("error") if fields is not None else getattr(result, "error", None) - data = fields.get("data") if fields is not None else getattr(result, "data", None) - log_id = ( - fields.get("logId") or fields.get("log_id") - if fields is not None - else getattr(result, "log_id", None) - ) + def failed(reason: Any, *, log_id: Any = None) -> str: + """One failure, told to everyone who is waiting on it. - # Mandatory, not defensive: execute reports a failed tool in `error` and - # does not raise, so a try/except alone reads every failed write as a - # success. - if error: + The model hears it as a tool result, by slug — the handle it calls + things by. The thread hears it under the label the card carried, + and only when there *was* a card: an approver whose last sight of + this action was "running" has no other way to learn it did not. + """ logger.warning( "[composio] %s failed for user=%s (log=%s): %s", slug, owning.scope.user_id, log_id, + reason, + ) + if gated: + emit_write_failure(label, str(reason)) + return f"{slug} failed: {reason}" + + # `arguments` is keyword-only in the Python SDK. The TypeScript one took + # it positionally, and a hand-written fake happily accepted either. + try: + result = owning.session.execute(slug, arguments=arguments) + except (TypeError, AttributeError) as error: + # A broken build rather than a failed tool, so it is not turned into + # a result the model will read as "try again". Reported to the + # thread on the way out all the same: the approval was already + # spent, and this raise is the end of the turn. + logger.warning( + "[composio] %s could not be called for user=%s — the SDK does " + "not accept this call: %s", + slug, + owning.scope.user_id, error, ) - return f"{slug} failed: {error}" + if gated: + emit_write_failure(label, f"{type(error).__name__}: {error}") + raise + except Exception as error: # noqa: BLE001 - provider errors vary + # The one provider call that used to run unguarded, and the only one + # that runs *after* a person has approved something. Escaping here + # ends the turn with the card still reading "running". + cache.invalidate(owning.scope) + return failed(error) + + fields = _execute_fields(result) + if fields is None: + return failed( + "the provider returned a result this agent cannot read " + f"({type(result).__name__})" + ) + + error = fields.get("error") + data = fields.get("data") + log_id = fields.get("log_id") or fields.get("logId") + + # Mandatory, not defensive: execute reports a failed tool in `error` and + # does not raise, so a try/except alone reads every failed write as a + # success. + if error: + return failed(error, log_id=log_id) return data diff --git a/agent/tests/test_composio_approval_resume.py b/agent/tests/test_composio_approval_resume.py index 1dc1a89..4fb745b 100644 --- a/agent/tests/test_composio_approval_resume.py +++ b/agent/tests/test_composio_approval_resume.py @@ -109,7 +109,7 @@ def test_an_approval_after_the_run_ends_still_runs_in_the_asking_person_s_accoun api_key="ak_test", workspace_toolkits=("linear",), user_toolkits=("gmail",), - approvals="destructive", + approvals="on", workspace_user_id="open-tag", ) cache = SessionCache( @@ -178,7 +178,7 @@ def test_a_declined_approval_runs_nothing(): api_key="ak_test", workspace_toolkits=(), user_toolkits=("gmail",), - approvals="destructive", + approvals="on", workspace_user_id="open-tag", ) cache = SessionCache(config, client=RecordingComposio({"slack:U1": personal})) diff --git a/agent/tests/test_composio_classify.py b/agent/tests/test_composio_classify.py index cd4a72a..95125a7 100644 --- a/agent/tests/test_composio_classify.py +++ b/agent/tests/test_composio_classify.py @@ -15,9 +15,9 @@ (["destructiveHint"], "destructive"), # Both present: the dangerous claim wins. (["readOnlyHint", "destructiveHint"], "destructive"), - # Nothing positively claimed. Deliberately not "write": the default - # approval mode gates destructive calls only, so calling an - # unclassified tool a write is the same as not gating it at all. + # Nothing positively claimed. Deliberately not "write": the tags cannot + # express a write that is not destructive, so answering "write" here + # would be a guess dressed as a classification. (["somethingElse"], None), ([], None), (None, None), @@ -95,9 +95,9 @@ def __init__(self, by_slug) -> None: def test_a_found_but_untagged_tool_is_destructive_not_a_write(): - # The whole gate rests on this. `destructive` is the default mode and gates - # only destructive calls, so an untagged tool called a write is an ungated - # write to somebody's real account. + # The whole gate rests on this. `write` is a value the tags cannot express + # and `needs_approval` lets nothing but a classified read through, so an + # untagged tool called a write is a write nobody is asked about. client = FakeClient({"GMAIL_SEND_EMAIL": FakeTool([])}) assert EffectMap(lambda: client).effect_for("GMAIL_SEND_EMAIL") == "destructive" @@ -144,3 +144,29 @@ def get_raw_composio_tool_by_slug(self, slug): assert effects.effect_for("GMAIL_SEND_EMAIL") == "destructive" assert effects.effect_for("GMAIL_SEND_EMAIL") == "destructive" assert client.asked == ["GMAIL_SEND_EMAIL", "GMAIL_SEND_EMAIL"] + + +def test_a_lookup_signature_break_is_not_read_as_a_provider_outage(): + # "Could not look it up, treating it as destructive" is the right thing to + # say about a provider having a bad day. Said about an SDK that renamed a + # parameter it is a diagnosis that leads nobody to the cause, and every call + # for the rest of the process is gated for a reason nobody can find. + class Breaking: + def __init__(self) -> None: + self.tools = self + + def get_raw_composio_tool_by_slug(self, slug, **kwargs): + raise TypeError( + "get_raw_composio_tool_by_slug() missing 1 required argument" + ) + + with pytest.raises(TypeError): + EffectMap(lambda: Breaking()).effect_for("GMAIL_SEND_EMAIL") + + +def test_a_client_that_lost_its_tools_collection_is_not_an_outage_either(): + class NoTools: + pass + + with pytest.raises(AttributeError): + EffectMap(lambda: NoTools()).effect_for("GMAIL_SEND_EMAIL") diff --git a/agent/tests/test_composio_identity.py b/agent/tests/test_composio_identity.py index 4421276..2830e08 100644 --- a/agent/tests/test_composio_identity.py +++ b/agent/tests/test_composio_identity.py @@ -14,7 +14,8 @@ import uuid import pytest -from ag_ui.core import RunAgentInput, UserMessage +from ag_ui.core import AssistantMessage, RunAgentInput, UserMessage +from langchain_core.messages import AIMessage from langgraph.checkpoint.memory import MemorySaver from langgraph.graph import END, START, StateGraph @@ -34,14 +35,26 @@ class Turns: - """Every actor the graph saw, in order.""" + """Every actor the graph saw, in order. + + The node also appends messages, because the adapter decides between a + normal run and a time-travel regeneration by comparing the checkpoint's + message count with the run's — a node that writes nothing can never reach + the second path. + """ def __init__(self) -> None: self.actors: list[dict | None] = [] def record(self, state) -> dict: self.actors.append(state.get("channel_actor")) - return {} + index = len(self.actors) + return { + "messages": [ + AIMessage(content="ok", id=f"a{index}-1"), + AIMessage(content="done", id=f"a{index}-2"), + ] + } def agent_over(turns: Turns): @@ -52,12 +65,27 @@ def agent_over(turns: Turns): return build_agui_agent(graph.compile(checkpointer=MemorySaver())) -def run_input(thread: str, text: str, *, forwarded=None, state=None) -> RunAgentInput: +def run_input( + thread: str, + text: str, + *, + forwarded=None, + state=None, + message_id=None, + extra_messages=(), +) -> RunAgentInput: return RunAgentInput( thread_id=thread, run_id=str(uuid.uuid4()), state={} if state is None else state, - messages=[UserMessage(id=str(uuid.uuid4()), role="user", content=text)], + messages=[ + UserMessage( + id=str(uuid.uuid4()) if message_id is None else message_id, + role="user", + content=text, + ), + *extra_messages, + ], tools=[], context=[], forwarded_props={} if forwarded is None else forwarded, @@ -120,6 +148,58 @@ def test_caller_supplied_state_cannot_name_a_different_person(): assert turns.actors == [{"id": "U1", "platform": "slack", "kind": "human"}] +def test_a_regenerated_turn_does_not_replay_the_checkpointed_actor(): + # The adapter has a second entry point. `prepare_regenerate_stream` forks + # from the checkpoint's own values and never reads `input.state`, so the + # rewrite that stamps the trusted actor on every run is a no-op there and + # the fork carries whoever spoke when that checkpoint was written. + # + # Reachable on the managed adapter, which keeps one LangGraph thread per + # conversation: from the second turn on, the transcript arrives with ids the + # checkpoint has never seen and the heuristic below fires. + turns = Turns() + agent = agent_over(turns) + + drive( + agent, + run_input("t", "hi", forwarded={"channelActor": SLACK_U1}, message_id="m1"), + run_input( + "t", + "hi", + forwarded={"channelActor": SLACK_U2}, + message_id="m1", + extra_messages=[ + AssistantMessage(id="unseen-1", role="assistant", content="ok") + ], + ), + ) + + assert len(turns.actors) == 2, turns.actors + assert turns.actors[1] == {"id": "U2", "platform": "slack", "kind": "human"} + + +def test_a_regenerated_turn_that_forwards_nobody_clears_the_actor(): + turns = Turns() + agent = agent_over(turns) + + drive( + agent, + run_input("t", "hi", forwarded={"channelActor": SLACK_U1}, message_id="m1"), + run_input( + "t", + "hi", + forwarded={}, + message_id="m1", + extra_messages=[ + AssistantMessage(id="unseen-1", role="assistant", content="ok") + ], + ), + ) + + assert len(turns.actors) == 2, turns.actors + assert turns.actors[1] is None + + def test_caller_supplied_state_alone_names_nobody(): turns = Turns() @@ -240,6 +320,40 @@ def test_a_forwarded_actor_of_the_wrong_shape_is_nobody(): assert forwarded_actor(None) is None +def test_a_present_but_unusable_spelling_does_not_discard_a_usable_one(): + # Both spellings arrive in the same dictionary — one path snake-cases the + # forwarded keys and one does not. Returning on the first key that is + # *present* rather than the first that names somebody threw away a real + # actor sitting beside a null, and the turn ran anonymously: no personal + # toolkits, for a person the Channel did identify. + assert forwarded_actor({"channel_actor": None, "channelActor": SLACK_U1}) == { + "id": "U1", + "platform": "slack", + "kind": "human", + } + assert forwarded_actor({"channel_actor": {"kind": "human"}, "channelActor": SLACK_U1}) == { + "id": "U1", + "platform": "slack", + "kind": "human", + } + + +def test_a_usable_actor_wins_whichever_spelling_carries_it(): + for props in ( + {"channel_actor": SLACK_U1, "channelActor": None}, + {"channel_actor": None, "channelActor": SLACK_U1}, + ): + assert forwarded_actor(props) == { + "id": "U1", + "platform": "slack", + "kind": "human", + } + + +def test_neither_spelling_naming_anybody_is_still_nobody(): + assert forwarded_actor({"channel_actor": None, "channelActor": {"id": ""}}) is None + + def test_the_snake_cased_spelling_is_read_too(): # The adapter snake-cases forwarded keys on the way down; this runs above # that on one path and below it on another. diff --git a/agent/tests/test_composio_sessions.py b/agent/tests/test_composio_sessions.py index 42a05e5..d0be822 100644 --- a/agent/tests/test_composio_sessions.py +++ b/agent/tests/test_composio_sessions.py @@ -4,9 +4,13 @@ import logging -from composio_tools.config import ComposioConfig +import pytest + +import composio_tools.runtime as runtime_mod +from composio_tools.config import ComposioConfig, ComposioConfigError +from composio_tools.runtime import composio_runtime, reset_composio_runtime from composio_tools.scopes import ResolvedScope -from composio_tools.sessions import SessionCache +from composio_tools.sessions import MAX_SESSIONS, SessionCache class FakeSession: @@ -37,7 +41,7 @@ def config() -> ComposioConfig: api_key="ak_test", workspace_toolkits=("linear",), user_toolkits=("gmail",), - approvals="destructive", + approvals="on", workspace_user_id="open-tag", ) @@ -95,7 +99,7 @@ def test_one_unreachable_identity_does_not_cost_the_others(caplog): ) ) - assert [entry.scope.user_id for entry in resolved] == ["open-tag"] + assert [entry.scope.user_id for entry in resolved.sessions] == ["open-tag"] assert "U1" in caplog.text assert "gmail" in caplog.text assert "no connected account" in caplog.text @@ -109,3 +113,175 @@ def test_the_api_key_stays_out_of_the_failure_log(caplog): cache.resolve((scope("U1", "gmail", personal=True),)) assert "ak_test" not in caplog.text + + +def test_a_scope_that_was_dropped_is_reported_rather_than_silently_missing(): + # The caller's two answers are "you have no personal toolkits" and "your + # personal toolkits could not be reached this turn". Dropping the second + # into silence turns an outage into a settled fact about somebody's setup. + client = FakeComposio(fail_for={"U1"}) + cache = SessionCache(config(), client=client) + + resolved = cache.resolve( + (scope("open-tag", "linear"), scope("U1", "gmail", personal=True)) + ) + + assert [entry.scope.user_id for entry in resolved.sessions] == ["open-tag"] + assert [entry.scope.user_id for entry in resolved.dropped] == ["U1"] + assert "no connected account" in resolved.dropped[0].reason + + +def test_an_invalidated_session_is_rebuilt_on_the_next_use(): + # A session that has started failing keeps failing for as long as it is + # cached, so one stale session takes an identity out until the process + # restarts. Dropping it costs one round trip. + client = FakeComposio() + cache = SessionCache(config(), client=client) + + first = cache.for_scope(scope("U1", "gmail", personal=True)) + cache.invalidate(scope("U1", "gmail", personal=True)) + second = cache.for_scope(scope("U1", "gmail", personal=True)) + + assert second.session is not first.session + assert len(client.sessions.calls) == 2 + + +def test_invalidating_one_identity_leaves_the_others_alone(): + client = FakeComposio() + cache = SessionCache(config(), client=client) + + kept = cache.for_scope(scope("U2", "gmail", personal=True)) + cache.for_scope(scope("U1", "gmail", personal=True)) + cache.invalidate(scope("U1", "gmail", personal=True)) + + assert cache.for_scope(scope("U2", "gmail", personal=True)).session is kept.session + + +def test_invalidating_a_scope_that_was_never_cached_is_not_an_error(): + cache = SessionCache(config(), client=FakeComposio()) + + cache.invalidate(scope("nobody", "gmail", personal=True)) + + +def test_the_cache_is_bounded(): + # One session per person, and the process outlives every conversation. An + # unbounded map is a slow leak in any workspace bigger than a team. + client = FakeComposio() + cache = SessionCache(config(), client=client) + + for index in range(MAX_SESSIONS + 5): + cache.for_scope(scope(f"U{index}", "gmail", personal=True)) + + assert cache.size == MAX_SESSIONS + + +def test_the_least_recently_used_session_is_the_one_evicted(): + client = FakeComposio() + cache = SessionCache(config(), client=client) + + first = cache.for_scope(scope("U0", "gmail", personal=True)).session + for index in range(1, MAX_SESSIONS): + cache.for_scope(scope(f"U{index}", "gmail", personal=True)) + # Touching U0 makes it the most recent, so the next insert must evict U1. + assert cache.for_scope(scope("U0", "gmail", personal=True)).session is first + cache.for_scope(scope("LAST", "gmail", personal=True)) + + assert cache.for_scope(scope("U0", "gmail", personal=True)).session is first + assert cache.for_scope(scope("U1", "gmail", personal=True)).session is not None + assert [call["user_id"] for call in client.sessions.calls].count("U1") == 2 + + +def test_a_session_signature_break_is_not_reported_as_an_unreachable_account(caplog): + # `create` losing a keyword is a broken build. Logged as "no session for + # this user, running the turn without it" it reads as one person's account + # being unreachable, on every turn, forever. + class Breaking: + def __init__(self) -> None: + self.sessions = self + + def create(self, **kwargs): + raise TypeError("create() got an unexpected keyword argument 'sandbox'") + + cache = SessionCache(config(), client=Breaking()) + + with pytest.raises(TypeError): + cache.resolve((scope("open-tag", "linear"),)) + + +# The process-wide runtime that hands the graph and the connect route the *same* +# session cache. Two caches would mean two sessions per identity, so what this +# function answers — and when it answers from cache — is part of the same story +# as the cache itself. + + +@pytest.fixture(autouse=True) +def _clean_runtime(): + reset_composio_runtime() + yield + reset_composio_runtime() + + +def env(**overrides) -> dict[str, str]: + return { + "COMPOSIO_API_KEY": "ak_test", + "COMPOSIO_TOOLKITS": "linear", + **overrides, + } + + +def test_the_runtime_is_built_once_for_the_same_arguments(): + first = composio_runtime(env(), default_user_id="open-tag") + again = composio_runtime(env(), default_user_id="open-tag") + + assert first is again + + +def test_a_different_environment_is_not_answered_from_the_first_one(): + # The arguments are not decoration. Answering the second call from the + # first one's environment hands back a runtime configured for toolkits the + # caller did not ask for — and the reason it is hard to see is that it is + # right the first time. + first = composio_runtime(env(), default_user_id="open-tag") + second = composio_runtime( + env(COMPOSIO_TOOLKITS="notion"), default_user_id="open-tag" + ) + + assert first.config.workspace_toolkits == ("linear",) + assert second.config.workspace_toolkits == ("notion",) + + +def test_a_different_default_user_id_is_not_answered_from_the_first_one(): + composio_runtime(env(), default_user_id="open-tag") + second = composio_runtime(env(), default_user_id="other-channel") + + assert second.config.workspace_user_id == "other-channel" + + +def test_an_unconfigured_deployment_is_still_answered_from_cache(monkeypatch): + # The `None` answer is cached too, so a deployment without Composio does not + # re-read the environment on every request to the connect route. + reads: list[int] = [] + real = runtime_mod.read_composio_config + + def counting(*args, **kwargs): + reads.append(1) + return real(*args, **kwargs) + + monkeypatch.setattr(runtime_mod, "read_composio_config", counting) + + assert composio_runtime({}, default_user_id="open-tag") is None + assert composio_runtime({}, default_user_id="open-tag") is None + assert len(reads) == 1 + + +def test_a_configuration_error_leaves_nothing_cached(): + broken = env(COMPOSIO_APPROVALS="sometimes") + + with pytest.raises(ComposioConfigError): + composio_runtime(broken, default_user_id="open-tag") + # Raised again rather than answered from a half-built cache, and a fixed + # environment is read rather than refused for the life of the process. + with pytest.raises(ComposioConfigError): + composio_runtime(broken, default_user_id="open-tag") + + assert composio_runtime(env(), default_user_id="open-tag") is not None diff --git a/agent/tests/test_composio_tools.py b/agent/tests/test_composio_tools.py index a914f82..a580fc4 100644 --- a/agent/tests/test_composio_tools.py +++ b/agent/tests/test_composio_tools.py @@ -7,7 +7,7 @@ import pytest import composio_tools.tools as tools_mod -from composio_tools.config import ComposioConfig +from composio_tools.config import ComposioConfig, read_composio_config from composio_tools.effects import EffectMap from composio_tools.scopes import ResolvedScope from composio_tools.sessions import SessionCache @@ -32,11 +32,29 @@ def model_dump(self): return self._payload -def search_response(*slugs, schema=SCHEMA, statuses=None): +def search_response( + *slugs, + schema=SCHEMA, + statuses=None, + success=True, + error=None, + result_error=None, +): + """A search response shaped like `SessionSearchResponse`. + + `success` and `error` are top-level fields of the real model and + `result_error` is `Result.error`; all three say a search failed, and a + response that carries no candidates *because* it failed must never read as + "no tools found". + """ return Model( { # snake_case, as the Python SDK emits. - "results": [{"primary_tool_slugs": list(slugs)}], + "success": success, + "error": error, + "results": [ + {"primary_tool_slugs": list(slugs), "error": result_error} + ], "tool_schemas": { slug: {"description": f"{slug} does a thing", "input_schema": schema} for slug in slugs @@ -47,20 +65,34 @@ def search_response(*slugs, schema=SCHEMA, statuses=None): class FakeSession: - def __init__(self, user_id, response=None, result=None, fail_search=False): + def __init__( + self, + user_id, + response=None, + result=None, + fail_search=False, + search_error=None, + execute_error=None, + ): self.user_id = user_id - self._response = response or search_response() + self._response = response if response is not None else search_response() self._result = result if result is not None else {"data": {"ok": True}} self._fail_search = fail_search + self._search_error = search_error + self._execute_error = execute_error self.executed: list[tuple[str, dict]] = [] def search(self, *, query): + if self._search_error is not None: + raise self._search_error if self._fail_search: raise RuntimeError("scope unreachable") return self._response def execute(self, slug, *, arguments): self.executed.append((slug, arguments)) + if self._execute_error is not None: + raise self._execute_error return self._result def authorize(self, toolkit): @@ -84,16 +116,46 @@ def create(self, *, user_id, **kwargs): def config(**overrides) -> ComposioConfig: + """A config as `read_composio_config` would return it. + + `approvals` is `"on"` because that is the only gating mode the parser can + now produce; `destructive` and `writes` are spellings it folds into it. A + fixture writing a folded spelling straight into the dataclass tests a value + no deployment can hold, and it goes on passing after the parser stops + producing it. + """ defaults = { "api_key": "ak_test", "workspace_toolkits": ("linear",), "user_toolkits": ("gmail",), - "approvals": "destructive", + "approvals": "on", "workspace_user_id": "open-tag", } return ComposioConfig(**{**defaults, **overrides}) +def parsed_config(approvals: str) -> ComposioConfig: + """A config built the way a deployment builds one — through the parser.""" + parsed = read_composio_config( + { + "COMPOSIO_API_KEY": "ak_test", + "COMPOSIO_TOOLKITS": "linear", + "COMPOSIO_USER_TOOLKITS": "gmail", + "COMPOSIO_APPROVALS": approvals, + }, + default_user_id="open-tag", + ) + assert parsed is not None + return parsed + + +def test_the_fixture_matches_what_the_parser_produces(): + # The guard on the fixture above. Pinned by hand, it drifted once already: + # it held `destructive` for a while after `destructive` stopped being a + # value any deployment could have. + assert config() == parsed_config("") + + class FakeEffects: """Effects without a lookup. @@ -245,6 +307,131 @@ def test_one_unreachable_scope_costs_only_its_own_candidates(caplog): assert "scope unreachable" in caplog.text +def test_a_failed_search_is_not_reported_as_no_tools_found(): + # `success: False` is the response saying the search itself did not run. + # Answering "no tools found" tells the model the apps have nothing to offer, + # and the model then explains that to a person as a settled fact. + shared = FakeSession( + "open-tag", + search_response(success=False, error="1 out of 1 searches failed: upstream 500"), + ) + search, _run, _client = tools_for({"open-tag": shared}) + + result = search.invoke({"query": "x", "state": state()}) + + assert isinstance(result, str), result + assert "upstream 500" in result + assert "failed" in result.lower() + + +def test_a_top_level_search_error_is_a_failure(): + shared = FakeSession("open-tag", search_response("LINEAR_OK", error="quota exceeded")) + search, _run, _client = tools_for({"open-tag": shared}) + + result = search.invoke({"query": "x", "state": state()}) + + assert isinstance(result, str), result + assert "quota exceeded" in result + + +def test_a_per_query_search_error_is_a_failure(): + # `Result.error` is per query and we send exactly one, so a query that + # failed is the whole search failing for that scope. + shared = FakeSession( + "open-tag", search_response(result_error="index unavailable") + ) + search, _run, _client = tools_for({"open-tag": shared}) + + result = search.invoke({"query": "x", "state": state()}) + + assert isinstance(result, str), result + assert "index unavailable" in result + + +def test_an_unreadable_search_response_is_a_failure(): + # Neither a dict nor a model that dumps to one. `_as_dict` answers `{}` for + # this, which is indistinguishable from a response that found nothing. + shared = FakeSession("open-tag", "not a response at all") + search, _run, _client = tools_for({"open-tag": shared}) + + result = search.invoke({"query": "x", "state": state()}) + + assert isinstance(result, str), result + assert "failed" in result.lower() or "could not" in result.lower() + + +def test_every_scope_failing_is_not_an_empty_success(): + shared = FakeSession("open-tag", fail_search=True) + personal = FakeSession("slack:U1", fail_search=True) + search, _run, _client = tools_for({"open-tag": shared, "slack:U1": personal}) + + result = search.invoke({"query": "x", "state": state("U1")}) + + assert isinstance(result, str), result + assert "scope unreachable" in result + + +def test_a_partial_search_failure_is_named_alongside_what_did_come_back(): + # One scope answering is not the same as every scope answering, and the + # difference is exactly "your Gmail was not searched". + shared = FakeSession("open-tag", search_response("LINEAR_OK")) + personal = FakeSession("slack:U1", fail_search=True) + search, _run, _client = tools_for({"open-tag": shared, "slack:U1": personal}) + + result = search.invoke({"query": "x", "state": state("U1")}) + + assert [entry["slug"] for entry in result["tools"]] == ["LINEAR_OK"] + assert result["searchFailures"], result + + +def test_a_search_signature_break_is_not_swallowed_as_an_outage(): + # An SDK that renamed a parameter is a broken deployment, not one scope + # having a bad day. Logged as an outage and skipped, it reads as "that app + # is down" forever. + shared = FakeSession( + "open-tag", + search_error=TypeError("search() got an unexpected keyword argument 'query'"), + ) + search, _run, _client = tools_for({"open-tag": shared}) + + with pytest.raises(TypeError): + search.invoke({"query": "x", "state": state()}) + + +def test_a_scope_that_could_not_be_reached_is_not_called_a_missing_setup(): + # "Connected apps are not configured for you" is a statement about somebody's + # setup. A session that failed to build is an outage, and telling a person to + # go and connect an app they already connected is the wrong instruction. + search, _run, _client = tools_for({}) # every `create` raises KeyError + + result = search.invoke({"query": "x", "state": state()}) + + assert isinstance(result, str), result + assert "not configured" not in result + assert "could not" in result.lower() or "failed" in result.lower() + + +def test_running_a_tool_when_no_scope_could_be_reached_says_so(): + _search, run, _client = tools_for({}) + + result = run.invoke({"slug": "LINEAR_CREATE_ISSUE", "arguments": {}, "state": state()}) + + assert isinstance(result, str), result + assert "not configured" not in result + + +def test_a_failed_search_drops_the_session_so_the_next_turn_gets_a_fresh_one(): + # A session that has started failing keeps failing while it is cached, so + # one bad session takes an identity out until the process restarts. + shared = FakeSession("open-tag", fail_search=True) + search, _run, client = tools_for({"open-tag": shared}) + + search.invoke({"query": "x", "state": state()}) + search.invoke({"query": "x", "state": state()}) + + assert client.created == ["open-tag", "open-tag"] + + def test_a_call_runs_in_the_account_that_owns_its_toolkit(): shared = FakeSession("open-tag") personal = FakeSession("slack:U1") @@ -319,6 +506,169 @@ def test_a_successful_call_returns_its_data(): assert result == {"id": "ISS-1"} +class Reports: + """Stands in for the message the thread gets when a confirmed write fails.""" + + def __init__(self) -> None: + self.calls: list[tuple[str, str]] = [] + + def __call__(self, action, error): + self.calls.append((action, error)) + + +def approved(monkeypatch): + """Approve every card, and record what the thread was told afterwards.""" + monkeypatch.setattr(tools_mod, "require_write_confirmation", Recorder(approve=True)) + reports = Reports() + monkeypatch.setattr(tools_mod, "emit_write_failure", reports) + return reports + + +def test_a_raising_execute_does_not_escape_after_the_approval_is_spent(monkeypatch): + # The only unguarded provider call, and it runs *after* the person has + # approved. A raise here ends the turn with the card's last word still + # "running", so the approver cannot tell an outage from a completed action. + shared = FakeSession("open-tag", execute_error=RuntimeError("gateway timeout")) + _search, run, _client = tools_for( + {"open-tag": shared}, effects=FakeEffects({"LINEAR_DELETE_ISSUE": "destructive"}) + ) + reports = approved(monkeypatch) + + result = run.invoke( + {"slug": "LINEAR_DELETE_ISSUE", "arguments": {}, "state": state()} + ) + + assert "gateway timeout" in result + assert reports.calls == [("Delete issue (Linear)", "gateway timeout")] + + +def test_an_approved_call_that_reports_a_failure_tells_the_thread(monkeypatch): + # The card is the last thing the person saw. Told nothing, they read it as + # done — and the label has to be the one the card carried, not the slug, + # because the slug is not what they approved. + shared = FakeSession( + "open-tag", result={"data": None, "error": "Invalid request", "log_id": "l1"} + ) + _search, run, _client = tools_for( + {"open-tag": shared}, effects=FakeEffects({"LINEAR_DELETE_ISSUE": "destructive"}) + ) + reports = approved(monkeypatch) + + result = run.invoke( + {"slug": "LINEAR_DELETE_ISSUE", "arguments": {}, "state": state()} + ) + + assert reports.calls == [("Delete issue (Linear)", "Invalid request")] + # The model keeps the slug, which is the handle it calls things by. + assert "LINEAR_DELETE_ISSUE" in result + + +def test_a_failure_nobody_approved_is_not_announced_in_the_thread(monkeypatch): + # An ungated read that fails is the model's problem to explain. Announcing + # it would put a warning in the thread for something nobody was asked about. + shared = FakeSession("open-tag", result={"data": None, "error": "nope"}) + _search, run, _client = tools_for( + {"open-tag": shared}, effects=FakeEffects(default="read") + ) + reports = approved(monkeypatch) + + result = run.invoke( + {"slug": "LINEAR_LIST_ISSUES", "arguments": {}, "state": state()} + ) + + assert "nope" in result + assert reports.calls == [] + + +def test_an_unreadable_execute_result_is_not_a_success(): + # `_as_dict` answers `{}` for a shape it does not know, and `{}` reads as + # "no error, no data" — a success carrying nothing. + shared = FakeSession("open-tag", result="the tool ran, probably") + _search, run, _client = tools_for( + {"open-tag": shared}, effects=FakeEffects(default="read") + ) + + result = run.invoke( + {"slug": "LINEAR_CREATE_ISSUE", "arguments": {}, "state": state()} + ) + + assert isinstance(result, str), result + assert "LINEAR_CREATE_ISSUE" in result + assert "failed" in result.lower() or "cannot read" in result.lower() + + +class AttributeResult: + """A result that answers by attribute rather than by `model_dump`.""" + + def __init__(self, data=None, error=None): + self.data = data + self.error = error + self.log_id = "log_7" + + +def test_an_attribute_shaped_result_is_read_as_plain_data(): + # The attribute branch used to hand `data` back untouched, so a nested SDK + # model reached the model as an object whose repr was all it could see. + shared = FakeSession("open-tag", result=AttributeResult(data=Model({"id": "ISS-1"}))) + _search, run, _client = tools_for( + {"open-tag": shared}, effects=FakeEffects(default="read") + ) + + result = run.invoke( + {"slug": "LINEAR_CREATE_ISSUE", "arguments": {}, "state": state()} + ) + + assert result == {"id": "ISS-1"} + + +def test_an_attribute_shaped_failure_is_still_a_failure(caplog): + shared = FakeSession("open-tag", result=AttributeResult(error="Invalid request")) + _search, run, _client = tools_for( + {"open-tag": shared}, effects=FakeEffects(default="read") + ) + + with caplog.at_level(logging.WARNING): + result = run.invoke( + {"slug": "LINEAR_CREATE_ISSUE", "arguments": {}, "state": state()} + ) + + assert "Invalid request" in result + assert "log_7" in caplog.text + + +def test_an_execute_signature_break_is_not_reported_as_a_failed_tool(monkeypatch): + # A renamed parameter is a broken build. Reported to the model as "the tool + # failed" it becomes something the model retries, forever. + shared = FakeSession( + "open-tag", + execute_error=TypeError("execute() got an unexpected keyword argument"), + ) + _search, run, _client = tools_for( + {"open-tag": shared}, effects=FakeEffects({"LINEAR_DELETE_ISSUE": "destructive"}) + ) + reports = approved(monkeypatch) + + with pytest.raises(TypeError): + run.invoke({"slug": "LINEAR_DELETE_ISSUE", "arguments": {}, "state": state()}) + + # The person is still looking at a card that says the action is running. + assert reports.calls and reports.calls[0][0] == "Delete issue (Linear)" + + +def test_a_result_that_cannot_be_dumped_says_so(caplog): + # An empty `except: pass` here turned a model that refused to dump into an + # empty result, which is the same silence this whole path exists to remove. + class Refuses: + def model_dump(self): + raise ValueError("cannot serialise") + + with caplog.at_level(logging.WARNING): + plain = tools_mod._plain(Refuses()) + + assert isinstance(plain, Refuses) + assert "cannot serialise" in caplog.text + + @pytest.mark.parametrize( ("toolkits", "slug", "expected"), [ @@ -396,6 +746,10 @@ def test_a_read_is_never_gated(monkeypatch): def test_the_approval_mode_decides_whether_a_write_is_gated(monkeypatch): # `destructive` and `writes` are the old spellings; both now mean `on`, so # the same write is gated under all three and only `off` lets it through. + # + # Built through the parser, because that is the only place the old + # spellings survive — writing one into the dataclass would assert on a + # value no deployment can hold. for mode, gated in ( ("off", False), ("on", True), @@ -405,7 +759,7 @@ def test_the_approval_mode_decides_whether_a_write_is_gated(monkeypatch): shared = FakeSession("open-tag") _search, run, _client = tools_for( {"open-tag": shared}, - cfg=config(approvals=mode), + cfg=parsed_config(mode), effects=FakeEffects({"LINEAR_CREATE_ISSUE": "write"}), ) recorder = Recorder(approve=True) @@ -425,7 +779,7 @@ def test_only_the_person_whose_account_it_is_may_approve(monkeypatch): _search, run, _client = tools_for( {"open-tag": shared, "slack:U1": personal}, effects=FakeEffects({"GMAIL_SEND_EMAIL": "write"}), - cfg=config(approvals="writes"), + cfg=parsed_config("writes"), ) recorder = Recorder(approve=True) monkeypatch.setattr(tools_mod, "require_write_confirmation", recorder) @@ -441,7 +795,7 @@ def test_a_shared_call_names_no_particular_approver(monkeypatch): _search, run, _client = tools_for( {"open-tag": shared}, effects=FakeEffects({"LINEAR_CREATE_ISSUE": "write"}), - cfg=config(approvals="writes"), + cfg=parsed_config("writes"), ) recorder = Recorder(approve=True) monkeypatch.setattr(tools_mod, "require_write_confirmation", recorder) @@ -520,13 +874,13 @@ def get_raw_composio_tool_by_slug(self, slug): def test_a_found_but_untagged_call_is_gated_in_the_default_mode(monkeypatch): # The gate's whole point. Composio returned the tool and said nothing about - # what it does; `destructive` — the default and the mode most deployments - # ship — gates destructive calls only, so anything less than destructive - # here is an unapproved write against somebody's real account. + # what it does, and the default mode gates everything that is not a + # classified read — so an untagged tool called anything less than + # destructive is an unapproved write against somebody's real account. shared = FakeSession("open-tag") _search, run, _client = tools_for( {"open-tag": shared}, - cfg=config(approvals="destructive"), + cfg=parsed_config(""), effects=EffectMap(lambda: UntaggedTools()), ) recorder = Recorder(approve=False) From e6b28f20231bcf985e9e7c2d62de2b65bf877a6c Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 2 Sep 2026 20:45:26 +0200 Subject: [PATCH 18/23] fix(approval): fail safe on this side of the wire too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The approval gate's own fix left four ways for the far side to be safer than the near one, and the tests that were meant to hold it green were holding a shape production cannot emit. The card now fails safe the way the agent does. `EffectMap.effect_for` answers `destructive` for a slug it could not classify; the card read `effect === "destructive"` and rendered everything else — an unreadable word, and no classification at all — as neutral. `effect` is now the closed vocabulary `composio_tools.classify` defines, and only `read` and `write` buy a neutral button. The wire schema catches an unknown word to `destructive` rather than throwing on it: a card asked in red is better than a graph paused on a question nobody was asked. An applied approval can no longer be sent twice. `reopen()` re-armed the shared `answered` flag behind a card whose buttons were already gone, so a `resume` that failed on the way out — after the graph had already resumed and the write had already run — could be sent again by the same closure. It is removed, and the failure card no longer says the write never happened: it says the outcome is unknown, because it is. When the correction card cannot be posted either, the false "Approved" receipt it would have replaced is reported rather than only thrown past. The `namedPlatform === "unknown"` carve-out is gone. `KNOWN_PLATFORMS` is closed and `_named_identity` refuses anything outside it, so `actor_key` cannot spell that prefix — and a platform check with a prefix it waves through is one any producer can opt out of. The interrupt path is guarded. `parseConfirmWriteInterrupt` threw a raw `SyntaxError` for bad JSON and a `ZodError` for everything else, into a handler that caught neither; the thread showed "I hit an error: ZodError: [." and it logged as a run that recovered. The parse now has one throw shape, `__copilotkit_messages__` is no longer required by a schema that never reads it, and the handler reports the interrupt it could not render and says so in words a person can act on. `message.actor.kind` is read with `?.` like every other actor read here, and the filter admits `human` alone — the set `PERSONAL_KINDS` admits — so `system`, `unknown`, and an ingress carrying no actor no longer drive a run. `isSubscribed()` and `subscribe()` are guarded like their neighbour `getMessages()`: which tool a run offers must not decide whether a person is answered at all. `ConnectAccount`'s click had the same shape and the same silence, in the one card whose docstring is about exactly that failure. It now tells the clicker privately, on the same no-DM path as the link it would have delivered. Tests: the two channel-level approver fixtures forced their own result with `approver: "intelligence:U1"`, matching the FakeAdapter's transport identity. Production stamps the originating provider per delivery (`InteractionEvent.platform`) and the Channel prefers it, so the fixtures now say `slack:U1` and stamp `platform: "slack"` on the click — and a cross-platform case makes the check mutation-sensitive at that level. The restart fixture is stringified as `ag_ui_langgraph` sends it, names an approver, and asserts it survives the restart. New: malformed and platform-less approvers, both closures of the one-answer guard, the `ConnectAccount` re-registration nobody covered, and the fail-safe styling. `confirm-write-approver.test.tsx` asserts its button count instead of indexing blind, and holds its mock context to the real signatures with `satisfies` in place of `as never`. Call sites of every changed symbol: - `resumeOrShowFailure(…, reopen)` -> `resumeOrShowFailure(…)`: `confirm-write.tsx` `answer()`, the only caller. - `ConfirmWriteProps.effect: string` -> `ConfirmWriteEffect`: set from `channel.tsx` `postConfirmWriteCard`; read in `ConfirmWrite`. - new `CONFIRM_WRITE_EFFECTS` / `ConfirmWriteEffect`: `interrupt.ts` `effectSchema`, `human-in-the-loop/index.ts`, the card's own tests. - new `postConfirmWriteCard`, `isFromAPerson`, `isSubscribedSafely`, `APPROVAL_CARD_FAILED`: `channel.tsx` only, all private to it. - `parseConfirmWriteInterrupt`: unchanged signature; called from `channel.tsx` and `interrupt.test.ts`. - test helper `buttonHandlers` -> `cardButtons`: file-local, all 11 call sites in `confirm-write-approver.test.tsx`. - `confirmWriteEnvelope(action, detail)` -> `(action, detail, extraArgs)`: file-local, 4 call sites in `channel.test.ts`, all optional. Co-Authored-By: Claude Opus 5 (1M context) --- app/channel.test.ts | 308 +++++++++++++++++- app/channel.tsx | 135 ++++++-- .../__tests__/confirm-write-approver.test.tsx | 188 +++++++---- .../__tests__/confirm-write.test.tsx | 231 ++++++++++++- .../__tests__/connect-account.test.tsx | 93 ++++++ app/human-in-the-loop/confirm-write.tsx | 119 +++++-- app/human-in-the-loop/connect-account.tsx | 34 +- app/human-in-the-loop/index.ts | 6 +- app/interrupt.test.ts | 54 +++ app/interrupt.ts | 62 +++- 10 files changed, 1097 insertions(+), 133 deletions(-) create mode 100644 app/human-in-the-loop/__tests__/connect-account.test.tsx diff --git a/app/channel.test.ts b/app/channel.test.ts index 13bb0cf..5af4ee5 100644 --- a/app/channel.test.ts +++ b/app/channel.test.ts @@ -107,11 +107,12 @@ const channels: Channel[] = []; function confirmWriteEnvelope( action = "Create Linear issue", detail: string | null = "CPK-9: Checkout 500s", + extraArgs: Record = {}, ) { return { __copilotkit_interrupt_value__: { action: "confirm_write", - args: { action, detail }, + args: { action, detail, ...extraArgs }, }, __copilotkit_messages__: [ { @@ -151,6 +152,27 @@ function findButton( return undefined; } +/** The Connect button carrying one toolkit, anywhere in a posted card. */ +function findButtonByToolkit( + nodes: ChannelNode[], + toolkit: string, +): ChannelNode | undefined { + for (const node of nodes) { + if ( + node.type === "button" && + (node.props.value as { toolkit?: string } | undefined)?.toolkit === toolkit + ) { + return node; + } + const children = node.props.children; + if (Array.isArray(children)) { + const found = findButtonByToolkit(children as ChannelNode[], toolkit); + if (found) return found; + } + } + return undefined; +} + function findIncidentButton( nodes: ChannelNode[], action: "ack" | "escalate", @@ -454,7 +476,11 @@ describe("createOpenTagChannel", () => { expect(toolNames(agent.calls[4])).toContain(unsubscribeThreadTool.name); }); - it.each(["bot", "app"] as const)( + // `composio_tools.state.PERSONAL_KINDS` admits `human` and nothing else, on + // the grounds that `ProviderActor.kind` is the provider's own untrusted word + // for what sent a message. A surface-side filter that stops at `bot`/`app` + // hands the other two a turn the agent would never have granted an identity. + it.each(["bot", "app", "system", "unknown"] as const)( "ignores %s-authored messages in a subscribed thread", async (actorKind) => { const { adapter, agent, channel } = makeChannel(); @@ -866,6 +892,7 @@ describe("createOpenTagChannel", () => { replyTarget: {}, userText: "save it", platform: "slack", + actor: { id: "U1", kind: "human" }, }); expect(adapter.posted).toHaveLength(1); @@ -933,7 +960,7 @@ describe("createOpenTagChannel", () => { // connected account. const { adapter } = await postConfirmWrite({ action: "Send email (Gmail)", - approver: "intelligence:U1", + approver: "slack:U1", effect: "write", }); @@ -941,6 +968,11 @@ describe("createOpenTagChannel", () => { id: confirmActionId(adapter), conversationKey: "c1", replyTarget: {}, + // The Intelligence adapter is the transport; the provider that delivered + // the click is stamped per delivery and is what the Channel reports as + // `interaction.platform`. Leaving it off makes the click look like it + // came from the transport itself, which is a surface nobody clicks on. + platform: "slack", messageRef: { id: "msg-1" }, actor: { id: "U2", kind: "human", name: "Someone else" }, value: { confirmed: true }, @@ -955,7 +987,7 @@ describe("createOpenTagChannel", () => { it("lets the named approver answer the posted card", async () => { const { adapter } = await postConfirmWrite({ action: "Send email (Gmail)", - approver: "intelligence:U1", + approver: "slack:U1", effect: "write", }); @@ -963,6 +995,11 @@ describe("createOpenTagChannel", () => { id: confirmActionId(adapter), conversationKey: "c1", replyTarget: {}, + // The Intelligence adapter is the transport; the provider that delivered + // the click is stamped per delivery and is what the Channel reports as + // `interaction.platform`. Leaving it off makes the click look like it + // came from the transport itself, which is a surface nobody clicks on. + platform: "slack", messageRef: { id: "msg-1" }, actor: { id: "U1", kind: "human", name: "The owner" }, value: { confirmed: true }, @@ -973,6 +1010,32 @@ describe("createOpenTagChannel", () => { expect(adapter.ephemeralPosts).toHaveLength(0); }); + it("refuses the same id arriving from a platform the approver does not name", async () => { + // A provider id is unique only within its provider. `teams:U1` and the + // `U1` who clicked from Slack are two people, and the id alone cannot tell + // them apart — which is the whole reason the approver carries a platform. + const { adapter } = await postConfirmWrite({ + action: "Send email (Gmail)", + approver: "teams:U1", + effect: "destructive", + }); + + await adapter.getSink().onInteraction({ + id: confirmActionId(adapter), + conversationKey: "c1", + replyTarget: {}, + platform: "slack", + messageRef: { id: "msg-1" }, + actor: { id: "U1", kind: "human", name: "A different U1" }, + value: { confirmed: true }, + }); + + expect(adapter.updated).toHaveLength(0); + expect(JSON.stringify(adapter.ephemeralPosts)).toMatch( + /only they can approve/i, + ); + }); + it("carries the retry context from the interrupt onto the posted card", async () => { const { adapter } = await postConfirmWrite({ action: "Save project", @@ -1019,8 +1082,14 @@ describe("createOpenTagChannel", () => { actor: { id: "U1", kind: "human" }, }); - expect(JSON.stringify(adapter.posted)).toMatch(/error/i); - expect(JSON.stringify(adapter.posted)).not.toContain("Injected write"); + const posted = JSON.stringify(adapter.posted); + expect(posted).toMatch(/could not show the approval card/i); + expect(posted).not.toContain("Injected write"); + const logged = JSON.stringify(consoleError.mock.calls); + expect(logged).toContain("confirm_write_interrupt"); + // Reported as an interrupt that could not be rendered, not as a run that + // recovered — the graph is still paused on a question nobody was asked. + expect(logged).not.toContain("posted_user_facing_error"); consoleError.mockRestore(); }); @@ -1034,7 +1103,16 @@ describe("createOpenTagChannel", () => { event: { type: EventType.CUSTOM, name: "on_interrupt", - value: confirmWriteEnvelope("Create Linear issue", "CPK-9"), + // Stringified, as `ag_ui_langgraph` sends it, and naming an + // approver: the point of this test is that a click served by + // re-rendering the card from the store is served with the props + // the card was posted with, the approver among them. + value: JSON.stringify( + confirmWriteEnvelope("Create Linear issue", "CPK-9", { + approver: "slack:U1", + effect: "destructive", + }), + ), }, } as never); }, @@ -1070,7 +1148,27 @@ describe("createOpenTagChannel", () => { id: actionId!, conversationKey: "c1", replyTarget: {}, + platform: "slack", + messageRef: { id: "msg-1" }, + actor: { id: "U2", kind: "human", name: "Someone else" }, + value: { confirmed: true }, + }); + + // A card re-rendered from the store that forgot whose call it was would + // let this through, and spend the first person's connected account. + expect(secondAdapter.updated).toHaveLength(0); + expect(secondAgent.calls).toHaveLength(0); + expect(JSON.stringify(secondAdapter.ephemeralPosts)).toMatch( + /only they can approve/i, + ); + + await secondAdapter.getSink().onInteraction({ + id: actionId!, + conversationKey: "c1", + replyTarget: {}, + platform: "slack", messageRef: { id: "msg-1" }, + actor: { id: "U1", kind: "human", name: "The owner" }, value: { confirmed: true }, }); @@ -1079,6 +1177,68 @@ describe("createOpenTagChannel", () => { expect(secondAgent.calls).toHaveLength(1); }); + it("re-registers the Connect button when a new Channel uses the same store", async () => { + // `ConnectAccount` is in the component list for exactly this: the button is + // posted publicly and pressed minutes later, by several different people, + // and a click after a restart is served by re-rendering the named component + // from that list. Unregistered, the dispatch raises an expired-action error + // the Channel swallows — the person presses it and nothing happens at all. + const sharedState = new MemoryStore(); + const firstAdapter = new FakeAdapter({ platform: "intelligence" }); + firstAdapter.stateStore = sharedState; + const firstAgent = new FakeAgent([ + (subscriber) => { + subscriber.onToolCallEndEvent?.({ + event: { toolCallId: "connect-app-1" }, + toolCallName: "connect_app", + toolCallArgs: { toolkit: "gmail" }, + } as never); + subscriber.onRunFinishedEvent?.({ event: {} } as never); + }, + ]); + const firstChannel = createOpenTagChannel("opentag", firstAgent); + firstChannel.ɵruntime.addAdapter(firstAdapter); + channels.push(firstChannel); + await firstChannel.ɵruntime.start(); + await firstAdapter.getSink().onTurn({ + conversationKey: "connect-thread", + replyTarget: {}, + userText: "connect my gmail", + platform: "slack", + actor: { id: "U1", kind: "human" }, + }); + + const connectButton = findButtonByToolkit(firstAdapter.posted[0]!, "gmail"); + const actionId = (connectButton?.props.onClick as { id?: string })?.id; + expect(actionId).toMatch(/^ck:/); + await firstChannel.ɵruntime.stop(); + + const secondAdapter = new FakeAdapter({ platform: "intelligence" }); + secondAdapter.stateStore = sharedState; + const secondChannel = createOpenTagChannel("opentag", new FakeAgent()); + secondChannel.ɵruntime.addAdapter(secondAdapter); + channels.push(secondChannel); + await secondChannel.ɵruntime.start(); + // The click handler reads the environment before it reads the clicker. + vi.stubEnv("AGENT_URL", "http://agent.test"); + vi.stubEnv("INTELLIGENCE_API_KEY", "test-key"); + // Clicked by nobody the surface could name, so the handler answers from its + // own first guard and no connect link is minted or requested. + await secondAdapter.getSink().onInteraction({ + id: actionId!, + conversationKey: "connect-thread", + replyTarget: {}, + platform: "slack", + messageRef: { id: "connect-message" }, + value: { toolkit: "gmail" }, + }); + + expect(JSON.stringify(secondAdapter.ephemeralPosts)).toMatch( + /could not tell who clicked/i, + ); + vi.unstubAllEnvs(); + }); + it("re-registers incident actions when a new Channel uses the same store", async () => { const sharedState = new MemoryStore(); const firstAdapter = new FakeAdapter({ platform: "intelligence" }); @@ -1127,6 +1287,7 @@ describe("createOpenTagChannel", () => { id: actionId!, conversationKey: "incident-thread", replyTarget: {}, + platform: "slack", messageRef: { id: "incident-message" }, actor: { id: "U2", kind: "human", name: "Ada" }, value: { action: "ack", id: "INC-42" }, @@ -1178,3 +1339,136 @@ function confirmActionId(adapter: FakeAdapter): string { expect(id).toMatch(/^ck:/); return id!; } + +describe("createOpenTagChannel error paths", () => { + it("ignores a turn the platform could not attribute to anybody", async () => { + // An ingress with no actor is normalized to `{ id: "", kind: "unknown" }`. + // Running on it is running on input nobody can be held to — and the card + // that gates the resulting writes names no approver, so anyone can answer. + const { adapter, agent, channel } = makeChannel(); + + await channel.ɵruntime.start(); + await adapter.getSink().onTurn({ + conversationKey: "anonymous-thread", + replyTarget: {}, + userText: "@Kite do the thing", + platform: "slack", + operation: { + kind: "created", + logicalMessageId: "m1", + revisionId: "m1", + mentioned: true, + }, + }); + + expect((agent as CapturingAgent).calls).toHaveLength(0); + }); + + it("answers the mention when the subscription lookup fails", async () => { + const consoleError = vi + .spyOn(console, "error") + .mockImplementation(() => undefined); + const { adapter, agent, channel, stateStore } = makeChannel(); + vi.spyOn(stateStore.kv, "get").mockRejectedValue( + new Error("state store unavailable"), + ); + + await channel.ɵruntime.start(); + await adapter.getSink().onTurn({ + conversationKey: "unreadable-subscription", + replyTarget: {}, + userText: "@Kite are you there", + platform: "slack", + actor: { id: "U1", kind: "human" }, + operation: { + kind: "created", + logicalMessageId: "m1", + revisionId: "m1", + mentioned: true, + }, + }); + + // Whether the thread is subscribed decides which tool the run offers, not + // whether the person gets an answer. Dropping the mention because a lookup + // failed is silence the user has no way to tell from being ignored. + expect((agent as CapturingAgent).calls).toHaveLength(1); + expect(JSON.stringify(consoleError.mock.calls)).toContain( + "read_thread_subscription", + ); + consoleError.mockRestore(); + }); + + it("answers the mention when recording the subscription fails", async () => { + const consoleError = vi + .spyOn(console, "error") + .mockImplementation(() => undefined); + const { adapter, agent, channel, stateStore } = makeChannel(); + vi.spyOn(stateStore.kv, "set").mockRejectedValue( + new Error("state store unavailable"), + ); + + await channel.ɵruntime.start(); + await adapter.getSink().onTurn({ + conversationKey: "unwritable-subscription", + replyTarget: {}, + userText: "@Kite follow this thread", + platform: "slack", + actor: { id: "U1", kind: "human" }, + operation: { + kind: "created", + logicalMessageId: "m1", + revisionId: "m1", + mentioned: true, + }, + }); + + expect((agent as CapturingAgent).calls).toHaveLength(1); + expect(JSON.stringify(consoleError.mock.calls)).toContain( + "record_thread_subscription", + ); + consoleError.mockRestore(); + }); + + it("says the card could not be shown, rather than quoting a ZodError", async () => { + const consoleError = vi + .spyOn(console, "error") + .mockImplementation(() => undefined); + const agent = new FakeAgent([ + (subscriber) => { + subscriber.onCustomEvent?.({ + event: { + type: EventType.CUSTOM, + name: "on_interrupt", + value: "{broken", + }, + } as never); + }, + ]); + const { adapter, channel } = makeChannel({ agent }); + + await channel.ɵruntime.start(); + await adapter.getSink().onTurn({ + conversationKey: "c1", + replyTarget: {}, + userText: "file this", + platform: "slack", + actor: { id: "U1", kind: "human" }, + operation: { + kind: "created", + logicalMessageId: "m1", + revisionId: "m1", + mentioned: true, + }, + }); + + const posted = JSON.stringify(adapter.posted); + expect(posted).toMatch(/approval/i); + // A parser's own vocabulary is not a message to a person, and it is what + // the thread showed: "I hit an error: ZodError: [.". + expect(posted).not.toMatch(/ZodError|SyntaxError/); + expect(JSON.stringify(consoleError.mock.calls)).toContain( + "confirm_write_interrupt", + ); + consoleError.mockRestore(); + }); +}); diff --git a/app/channel.tsx b/app/channel.tsx index 58dfba5..2eea7de 100644 --- a/app/channel.tsx +++ b/app/channel.tsx @@ -4,6 +4,8 @@ import { type Channel, type ChannelTool, type CreateChannelOptions, + type ProviderActor, + type Thread, } from "@copilotkit/channels"; import { managedRunInput, @@ -30,6 +32,32 @@ import { type ChannelAgent = NonNullable; +/** + * Whether a message is a person asking for something. + * + * `ProviderActor.kind` is the provider's own word for what sent a message, and + * the Channels SDK documents it as untrusted metadata rather than + * authorization — which is why it is read here as a filter and never as a + * grant. `human` and nothing else, the same set the agent's + * `composio_tools.state.PERSONAL_KINDS` admits, so both ends of the wire agree + * about who is speaking: `bot` and `app` are the reply loop, `system` is the + * platform talking about the channel rather than into it, and `unknown` is a + * message the surface could not attribute to anybody — which is also what an + * ingress carrying no actor at all is normalized to. + * + * Optional-chained like every other read of `actor` in this app. The Channel + * normalizes one in, so this only ever fires for something that bypassed it — + * and that should be refused, not turned into a `TypeError` where a decision + * belongs. + */ +function isFromAPerson(message: { actor?: ProviderActor }): boolean { + return message.actor?.kind === "human"; +} + +/** What the thread is told when its approval card could not be rendered. */ +const APPROVAL_CARD_FAILED = + "⚠️ I could not show the approval card for that action, so nothing has been changed. Please ask again."; + /** Build the managed OpenTag Channel; Intelligence owns its platform adapters. */ export function createOpenTagChannel( name: string, @@ -102,10 +130,32 @@ export function createOpenTagChannel( } }; + /** + * Whether this thread is subscribed, and `false` when the store cannot say. + * + * The answer only decides which subscription tool the run offers, so a store + * that is briefly unreadable must not be what stops a person being answered. + * Its unguarded neighbour threw straight out of the handler and dropped the + * mention with nothing said and nothing logged. + */ + const isSubscribedSafely = async ( + thread: MessageHandlerInput["thread"], + ): Promise => { + try { + return await thread.isSubscribed(); + } catch (error) { + reportRecoverableError(error, { + operation: "read_thread_subscription", + recovery: "treat_as_unsubscribed", + }); + return false; + } + }; + channel.onMention(async ({ thread, message }) => { - if (message.actor.kind === "bot" || message.actor.kind === "app") return; + if (!isFromAPerson(message)) return; - if (await thread.isSubscribed()) { + if (await isSubscribedSafely(thread)) { await runAgentSafely({ thread, message }, [unsubscribeThreadTool]); return; } @@ -122,7 +172,17 @@ export function createOpenTagChannel( } if (isNewConversation) { - await thread.subscribe(); + try { + await thread.subscribe(); + } catch (error) { + // Following the thread is an affordance for later turns. This turn is + // an answered mention either way, and a failed write here used to + // throw past the run that had not happened yet. + reportRecoverableError(error, { + operation: "record_thread_subscription", + recovery: "answered_without_subscribing", + }); + } await runAgentSafely({ thread, message }, [unsubscribeThreadTool]); return; } @@ -131,9 +191,9 @@ export function createOpenTagChannel( }); channel.onMessage(async ({ thread, message }) => { - if (message.actor.kind === "bot" || message.actor.kind === "app") return; + if (!isFromAPerson(message)) return; - if (await thread.isSubscribed()) { + if (await isSubscribedSafely(thread)) { await runAgentSafely({ thread, message }, [unsubscribeThreadTool]); } }); @@ -141,21 +201,27 @@ export function createOpenTagChannel( channel.onModalSubmit(FILE_ISSUE_CALLBACK, fileIssueSubmit); channel.onInterrupt("on_interrupt", async ({ payload, thread }) => { - const { args } = parseConfirmWriteInterrupt(payload); - await thread.post( - , - ); + try { + await postConfirmWriteCard(thread, payload); + } catch (error) { + // The only handler here that had no guard, and the one whose failure is + // least visible: `parseConfirmWriteInterrupt` throws on a payload it + // cannot read, the graph stays paused on a question nobody was asked, + // and the thread showed the parser's own words — "I hit an error: + // ZodError: [." — logged as a run that recovered. + reportRecoverableError(error, { + operation: "confirm_write_interrupt", + recovery: "posted_card_failure_notice", + }); + try { + await thread.post(APPROVAL_CARD_FAILED); + } catch (postError) { + reportRecoverableError(postError, { + operation: "confirm_write_interrupt_notice", + recovery: "none_the_thread_shows_nothing", + }); + } + } }); channel.onThreadStarted(async ({ thread, user }) => { @@ -186,3 +252,32 @@ export function createOpenTagChannel( return channel; } + +/** + * Post the approval card this interrupt is asking for. + * + * Extracted so the handler above is a guard and nothing else, and so both ways + * this can fail — an unreadable payload and a thread that will not take the + * card — are caught in one place rather than one of them being caught and the + * other not. + */ +async function postConfirmWriteCard( + thread: Pick, + payload: unknown, +): Promise { + const { args } = parseConfirmWriteInterrupt(payload); + await thread.post( + , + ); +} diff --git a/app/human-in-the-loop/__tests__/confirm-write-approver.test.tsx b/app/human-in-the-loop/__tests__/confirm-write-approver.test.tsx index b765f64..78e07f2 100644 --- a/app/human-in-the-loop/__tests__/confirm-write-approver.test.tsx +++ b/app/human-in-the-loop/__tests__/confirm-write-approver.test.tsx @@ -7,11 +7,27 @@ * enforced here. */ import { describe, expect, it, vi } from "vitest"; +import type { + ClickHandler, + EphemeralResult, + InteractionContext, + MessageRef, + Renderable, +} from "@copilotkit/channels"; import { ConfirmWrite } from "../confirm-write.js"; -/** Walk the rendered tree and collect every button's onClick. */ -function buttonHandlers(node: unknown): Array<(ctx: unknown) => unknown> { - const found: Array<(ctx: unknown) => unknown> = []; +/** + * The card's buttons, as click handlers: confirm first, decline second. + * + * The count is asserted rather than assumed. Indexing positionally into a list + * whose length nobody checks is how a test goes on passing while pressing + * something else — or, once the buttons are gone, nothing at all. + */ +function cardButtons(node: unknown): { + confirm: ClickHandler; + decline: ClickHandler; +} { + const found: ClickHandler[] = []; const visit = (value: unknown): void => { if (Array.isArray(value)) { value.forEach(visit); @@ -24,47 +40,77 @@ function buttonHandlers(node: unknown): Array<(ctx: unknown) => unknown> { }; const onClick = element.props?.onClick; if (typeof onClick === "function") { - found.push(onClick as (ctx: unknown) => unknown); + found.push(onClick as ClickHandler); } if (element.props?.children) visit(element.props.children); if (element.children) visit(element.children); }; visit(node); - return found; + expect(found).toHaveLength(2); + return { confirm: found[0]!, decline: found[1]! }; } +/** + * The part of an interaction a `ConfirmWrite` click reads. + * + * Narrowed on purpose, and checked with `satisfies` rather than cast away with + * `as never`: the mock's method signatures are then held to the real ones, so a + * fake that resolves to the wrong shape — the `postEphemeral` that answers + * `null` on a surface with no ephemeral message, say — cannot quietly drift out + * of step with the interface the card is written against. + */ +type ClickContext = Pick< + InteractionContext, + "actor" | "platform" | "message" +> & { + thread: Pick< + InteractionContext["thread"], + "update" | "resume" | "post" | "postEphemeral" + >; +}; + function interaction( actorId: string, overrides: { platform?: string; - postEphemeral?: ( - user: unknown, - ui: unknown, - options: { fallbackToDM: boolean }, - ) => Promise; + postEphemeral?: ClickContext["thread"]["postEphemeral"]; } = {}, ) { - const update = vi.fn(async () => undefined); - const resume = vi.fn(async () => undefined); - const post = vi.fn(async () => ({ id: "m2" })); - const postEphemeral = vi.fn( + const update = vi.fn( + async (_ref: MessageRef, _ui: Renderable): Promise => ({ + id: "m1", + }), + ); + const resume = vi.fn( + async (_value: unknown): Promise => undefined, + ); + const post = vi.fn(async (_ui: Renderable): Promise => ({ + id: "m2", + })); + const postEphemeral = vi.fn( overrides.postEphemeral ?? - (async ( - _user: unknown, - _ui: unknown, - _options: { fallbackToDM: boolean }, - ) => ({ ok: true, usedFallback: false })), + (async (): Promise => ({ + ok: true, + usedFallback: false, + })), ); - return { - ctx: { - actor: { id: actorId, kind: "human" }, - platform: overrides.platform ?? "slack", - thread: { update, resume, postEphemeral, post }, - message: { ref: "m1" }, - action: { id: "a1" }, - values: {}, + const actor = { id: actorId, kind: "human" } as const; + const platform = overrides.platform ?? "slack"; + const ctx = { + actor, + platform, + thread: { update, resume, postEphemeral, post }, + message: { + text: "", user: null, - } as never, + actor, + ref: { id: "m1" }, + platform, + }, + } satisfies ClickContext; + + return { + ctx: ctx as unknown as InteractionContext, update, resume, post, @@ -74,25 +120,24 @@ function interaction( describe("ConfirmWrite approver", () => { it("lets the named person answer", async () => { - const handlers = buttonHandlers( + const { confirm } = cardButtons( ConfirmWrite({ action: "Gmail send email", approver: "slack:U1" }), ); - expect(handlers.length).toBeGreaterThan(0); const { ctx, update, postEphemeral } = interaction("U1"); - await handlers[0]!(ctx); + await confirm(ctx); expect(update).toHaveBeenCalled(); expect(postEphemeral).not.toHaveBeenCalled(); }); it("refuses anybody else, and leaves the card for the right person", async () => { - const handlers = buttonHandlers( + const { confirm } = cardButtons( ConfirmWrite({ action: "Gmail send email", approver: "slack:U1" }), ); const { ctx, update, resume, postEphemeral } = interaction("U2"); - await handlers[0]!(ctx); + await confirm(ctx); expect(update).not.toHaveBeenCalled(); expect(resume).not.toHaveBeenCalled(); @@ -105,22 +150,24 @@ describe("ConfirmWrite approver", () => { }); it("refuses the decline button too, not only approve", async () => { - const handlers = buttonHandlers( + const { decline } = cardButtons( ConfirmWrite({ action: "Gmail send email", approver: "slack:U1" }), ); const { ctx, update, postEphemeral } = interaction("U2"); - await handlers[handlers.length - 1]!(ctx); + await decline(ctx); expect(update).not.toHaveBeenCalled(); expect(postEphemeral).toHaveBeenCalledTimes(1); }); it("lets anyone answer a workspace action, which names no approver", async () => { - const handlers = buttonHandlers(ConfirmWrite({ action: "Create issue" })); + const { confirm } = cardButtons( + ConfirmWrite({ action: "Create issue" }), + ); const { ctx, update, postEphemeral } = interaction("U2"); - await handlers[0]!(ctx); + await confirm(ctx); expect(update).toHaveBeenCalled(); expect(postEphemeral).not.toHaveBeenCalled(); @@ -131,14 +178,14 @@ describe("ConfirmWrite approver", () => { // message — the managed adapter reports exactly that. Ignoring the answer // makes the refusal invisible: the person clicks, nothing happens, and the // card sits there looking unclicked. - const handlers = buttonHandlers( + const { confirm } = cardButtons( ConfirmWrite({ action: "Gmail send email", approver: "slack:U1" }), ); const { ctx, resume, post, postEphemeral } = interaction("U2", { postEphemeral: async () => null, }); - await handlers[0]!(ctx); + await confirm(ctx); expect(postEphemeral).toHaveBeenCalledTimes(1); expect(post).toHaveBeenCalledTimes(1); @@ -151,7 +198,7 @@ describe("ConfirmWrite approver", () => { const consoleError = vi .spyOn(console, "error") .mockImplementation(() => undefined); - const handlers = buttonHandlers( + const { confirm } = cardButtons( ConfirmWrite({ action: "Gmail send email", approver: "slack:U1" }), ); const { ctx, update, resume, post } = interaction("U2", { @@ -160,7 +207,7 @@ describe("ConfirmWrite approver", () => { }, }); - await handlers[0]!(ctx); + await confirm(ctx); expect(update).not.toHaveBeenCalled(); expect(resume).not.toHaveBeenCalled(); @@ -168,53 +215,72 @@ describe("ConfirmWrite approver", () => { consoleError.mockRestore(); }); - it("lets the person answer when the agent could not name their platform", async () => { - // `unknown:` is what the agent writes when the turn carried no platform. - // No surface can ever produce that prefix, so a card naming it is a card - // nobody can answer — and the graph stays paused for good. - const handlers = buttonHandlers( + it("has no platform it waves through, not even `unknown`", async () => { + // `composio_tools.state.KNOWN_PLATFORMS` is closed, and `_named_identity` + // refuses anything outside it, so `actor_key` cannot spell an approver + // `unknown:`. A prefix this card matched on trust would be a platform check + // that any producer could opt out of by naming a platform nobody serves. + const { confirm } = cardButtons( ConfirmWrite({ action: "Gmail send email", approver: "unknown:U1" }), ); const { ctx, update, resume, postEphemeral } = interaction("U1"); - await handlers[0]!(ctx); + await confirm(ctx); - expect(update).toHaveBeenCalled(); - expect(resume).toHaveBeenCalledWith({ confirmed: true }); - expect(postEphemeral).not.toHaveBeenCalled(); + expect(update).not.toHaveBeenCalled(); + expect(resume).not.toHaveBeenCalled(); + expect(postEphemeral).toHaveBeenCalledTimes(1); }); - it("still refuses somebody else when the platform is unknown", async () => { - const handlers = buttonHandlers( - ConfirmWrite({ action: "Gmail send email", approver: "unknown:U1" }), + it("refuses an approver carrying no platform at all", async () => { + // Not reachable from the agent — `actor_key` writes `platform:id` or + // nothing — which is exactly why it is asserted here rather than assumed. + // Read as a bare id, `U1` would match its own id and let this card be + // answered by whoever shares it on any surface. + const { confirm } = cardButtons( + ConfirmWrite({ action: "Gmail send email", approver: "U1" }), ); - const { ctx, update, postEphemeral } = interaction("U2"); + const { ctx, update, resume, postEphemeral } = interaction("U1"); - await handlers[0]!(ctx); + await confirm(ctx); expect(update).not.toHaveBeenCalled(); + expect(resume).not.toHaveBeenCalled(); + expect(postEphemeral).toHaveBeenCalledTimes(1); + }); + + it("refuses an approver whose id half is empty", async () => { + const { confirm } = cardButtons( + ConfirmWrite({ action: "Gmail send email", approver: "slack:" }), + ); + const { ctx, update, resume, postEphemeral } = interaction("U1"); + + await confirm(ctx); + + expect(update).not.toHaveBeenCalled(); + expect(resume).not.toHaveBeenCalled(); expect(postEphemeral).toHaveBeenCalledTimes(1); }); it("refuses a click nobody can be identified with", async () => { - const handlers = buttonHandlers( + const { confirm } = cardButtons( ConfirmWrite({ action: "Gmail send email", approver: "slack:U1" }), ); const { ctx, update, resume } = interaction(""); - await handlers[0]!(ctx); + await confirm(ctx); expect(update).not.toHaveBeenCalled(); expect(resume).not.toHaveBeenCalled(); }); it("does not match a person on another platform who shares an id", async () => { - const handlers = buttonHandlers( + const { confirm } = cardButtons( ConfirmWrite({ action: "Gmail send email", approver: "teams:U1" }), ); const { ctx, update, postEphemeral } = interaction("U1"); - await handlers[0]!(ctx); + await confirm(ctx); expect(update).not.toHaveBeenCalled(); expect(postEphemeral).toHaveBeenCalledTimes(1); @@ -224,14 +290,14 @@ describe("ConfirmWrite approver", () => { // The approver string is built by `actor_key` in the agent, which lowercases // the platform. A surface reporting "Slack" would otherwise never match the // `slack:U1` the card names, and the one person entitled to answer could not. - const handlers = buttonHandlers( + const { confirm } = cardButtons( ConfirmWrite({ action: "Gmail send email", approver: "slack:U1" }), ); const { ctx, update, postEphemeral } = interaction("U1", { platform: "Slack", }); - await handlers[0]!(ctx); + await confirm(ctx); expect(update).toHaveBeenCalled(); expect(postEphemeral).not.toHaveBeenCalled(); diff --git a/app/human-in-the-loop/__tests__/confirm-write.test.tsx b/app/human-in-the-loop/__tests__/confirm-write.test.tsx index 1c7c645..289ede7 100644 --- a/app/human-in-the-loop/__tests__/confirm-write.test.tsx +++ b/app/human-in-the-loop/__tests__/confirm-write.test.tsx @@ -7,7 +7,10 @@ import { } from "@copilotkit/channels"; import { renderSlackMessage } from "@copilotkit/channels/slack"; import { renderAdaptiveCard } from "@copilotkit/channels/teams"; -import { ConfirmWrite } from "../confirm-write.js"; +import { + ConfirmWrite, + type ConfirmWriteEffect, +} from "../confirm-write.js"; /** Children of an IR node as an array (empty if none). */ function childNodes(node: ChannelNode): ChannelNode[] { @@ -550,7 +553,7 @@ describe("ConfirmWrite", () => { consoleError.mockRestore(); }); - it("replaces the optimistic card with a retry state when resume fails", async () => { + it("replaces the optimistic card with an unknown outcome when resume fails", async () => { const ir = renderToIR( , ); @@ -578,10 +581,13 @@ describe("ConfirmWrite", () => { renderToIR(failedRenderable), ); expect(accent).toBe("#EB5757"); - expect(JSON.stringify(blocks)).toMatch(/couldn.t resume|retry/i); + expect(JSON.stringify(blocks)).toMatch(/cannot say whether it ran/i); }); - it("surfaces both resume and retry-card failures", async () => { + it("surfaces both resume and correction-card failures", async () => { + const consoleError = vi + .spyOn(console, "error") + .mockImplementation(() => undefined); const ir = renderToIR(); const create = buttonByText(ir, "Create"); const resumeFailure = new Error("resume unavailable"); @@ -610,5 +616,222 @@ describe("ConfirmWrite", () => { resumeFailure, updateFailure, ]); + consoleError.mockRestore(); + }); +}); + +/** + * The agent fails safe: `EffectMap.effect_for` answers `destructive` for a slug + * it could not classify, and for one whose lookup failed. A card that renders + * anything it does not recognise as neutral inverts that decision on the far + * side of the wire — the one place where the person deciding can see it. + */ +describe("ConfirmWrite effect fail-safe", () => { + const confirmStyle = (node: Parameters[0]) => { + const { blocks } = renderSlackMessage(renderToIR(node)); + const actions = blocks.find((b) => b.type === "actions") as + | { elements: { style?: string }[] } + | undefined; + return actions?.elements[0]?.style; + }; + + it("treats an unclassified action as destructive, not as safe", () => { + // A verb the card's own list does not know, and no classification at all. + // Neutral here says "this is fine" about an action nobody has vouched for. + expect(confirmStyle()).toBe( + "danger", + ); + }); + + it("treats an effect outside the agent's vocabulary as destructive", () => { + expect( + confirmStyle( + , + ), + ).toBe("danger"); + }); + + it("still renders a classified write neutrally", () => { + expect( + confirmStyle(), + ).toBe("primary"); + }); +}); + +/** + * What happens after `thread.resume` throws. + * + * The failure is not evidence that nothing ran: `resume` fails on the way out + * as readily as on the way in, and a destructive write whose approval landed + * before the connection dropped has already happened. + */ +describe("ConfirmWrite after a failed resume", () => { + const failingResumeCtx = () => { + const update = vi.fn(async () => ({ id: "m1" })); + const failure = new Error("resume unavailable"); + const resume = vi.fn(async () => { + throw failure; + }); + return { + failure, + update, + resume, + ctx: { + thread: { update, resume }, + message: { ref: { id: "m1" } }, + } as unknown as InteractionContext, + }; + }; + + it("does not resume twice when the first resume may already have landed", async () => { + const ir = renderToIR(); + const create = buttonByText(ir, "Delete"); + const { ctx, resume, failure } = failingResumeCtx(); + + await expect((create.props.onClick as ClickHandler)(ctx)).rejects.toBe( + failure, + ); + await expect((create.props.onClick as ClickHandler)(ctx)).resolves.toBe( + undefined, + ); + + // One press, one answer. The card is already replaced by a button-less one, + // so a second `resume` cannot be a retry of anything — it is the same + // approval applied twice. + expect(resume).toHaveBeenCalledTimes(1); + }); + + it("does not let a failed approve be answered again as a decline", async () => { + const ir = renderToIR(); + const create = buttonByText(ir, "Delete"); + const cancel = buttonByText(ir, "Cancel"); + const { ctx, resume, failure } = failingResumeCtx(); + + await expect((create.props.onClick as ClickHandler)(ctx)).rejects.toBe( + failure, + ); + await (cancel.props.onClick as ClickHandler)(ctx); + + expect(resume).toHaveBeenCalledTimes(1); + expect(resume).toHaveBeenCalledWith({ confirmed: true }); + }); + + it("does not claim the write never ran, and does not invite a retry", async () => { + const ir = renderToIR(); + const create = buttonByText(ir, "Delete"); + const { ctx, update, failure } = failingResumeCtx(); + + await expect((create.props.onClick as ClickHandler)(ctx)).rejects.toBe( + failure, + ); + + const [, failedRenderable] = update.mock.calls[1] as unknown as [ + { id: string }, + Parameters[0], + ]; + const { blocks } = renderSlackMessage(renderToIR(failedRenderable)); + const text = JSON.stringify(blocks); + + // The approval may have been applied before the failure. Saying it was not + // is the one thing this card must never do. + expect(text).toMatch(/may already have been applied/i); + // And the card it replaces has no buttons, so "retry" points at nothing. + expect(text).not.toMatch(/retry/i); + }); + + it("reports the receipt it could not correct", async () => { + const consoleError = vi + .spyOn(console, "error") + .mockImplementation(() => undefined); + const ir = renderToIR(); + const create = buttonByText(ir, "Delete"); + const updateFailure = new Error("retry card unavailable"); + const update = vi + .fn() + .mockResolvedValueOnce({ id: "m1" }) + .mockRejectedValueOnce(updateFailure); + const resume = vi.fn(async () => { + throw new Error("resume unavailable"); + }); + const ctx = { + thread: { update, resume }, + message: { ref: { id: "m1" } }, + } as unknown as InteractionContext; + + await expect( + (create.props.onClick as ClickHandler)(ctx), + ).rejects.toBeInstanceOf(AggregateError); + + // The thread is left showing "✅ Approved" for a write nobody can vouch + // for. Throwing alone leaves no trace naming that card. + expect(JSON.stringify(consoleError.mock.calls)).toContain( + "confirm_write_outcome_unknown", + ); + consoleError.mockRestore(); + }); +}); + +/** + * One card, one answer. + * + * The guard lives in the closure both buttons share, so it has to be pressed + * from both to be tested at all: a suite that only ever presses the same button + * twice cannot tell a shared flag from two independent ones. + */ +describe("ConfirmWrite one-answer guard", () => { + const clicked = () => { + const update = vi.fn(async () => ({ id: "m1" })); + const resume = vi.fn(async () => ({ id: "m2" })); + return { + update, + resume, + ctx: { + thread: { update, resume }, + message: { ref: { id: "m1" } }, + } as unknown as InteractionContext, + }; + }; + + it("resumes once when the same button is pressed twice", async () => { + const ir = renderToIR(); + const create = buttonByText(ir, "Create"); + const { ctx, resume, update } = clicked(); + + await (create.props.onClick as ClickHandler)(ctx); + await (create.props.onClick as ClickHandler)(ctx); + + expect(resume).toHaveBeenCalledTimes(1); + expect(update).toHaveBeenCalledTimes(1); + }); + + it("resumes once when an approve is followed by a decline", async () => { + const ir = renderToIR(); + const create = buttonByText(ir, "Create"); + const cancel = buttonByText(ir, "Cancel"); + const { ctx, resume } = clicked(); + + await (create.props.onClick as ClickHandler)(ctx); + await (cancel.props.onClick as ClickHandler)(ctx); + + // Both buttons close over one flag. Two flags would let the second press + // resume a graph that is no longer paused — with the opposite answer. + expect(resume).toHaveBeenCalledTimes(1); + expect(resume).toHaveBeenCalledWith({ confirmed: true }); + }); + + it("resumes once when a decline is followed by an approve", async () => { + const ir = renderToIR(); + const create = buttonByText(ir, "Create"); + const cancel = buttonByText(ir, "Cancel"); + const { ctx, resume } = clicked(); + + await (cancel.props.onClick as ClickHandler)(ctx); + await (create.props.onClick as ClickHandler)(ctx); + + expect(resume).toHaveBeenCalledTimes(1); + expect(resume).toHaveBeenCalledWith({ confirmed: false }); }); }); diff --git a/app/human-in-the-loop/__tests__/connect-account.test.tsx b/app/human-in-the-loop/__tests__/connect-account.test.tsx new file mode 100644 index 0000000..04bf1ce --- /dev/null +++ b/app/human-in-the-loop/__tests__/connect-account.test.tsx @@ -0,0 +1,93 @@ +/** + * The Connect button's click path. + * + * The card is posted publicly and pressed minutes later, so its handler is + * re-derived rather than remembered — and a throw on that path is the failure + * this whole card was shaped to avoid: the person presses it and nothing + * happens, with nothing anywhere to explain it. + */ +import { describe, expect, it, vi } from "vitest"; +import type { + ClickHandler, + EphemeralResult, + InteractionContext, + MessageRef, + ProviderActor, + Renderable, +} from "@copilotkit/channels"; +import { ConnectAccount } from "../connect-account.js"; + +/** The card's single button, as a click handler. */ +function connectButton(node: unknown): ClickHandler { + const found: ClickHandler[] = []; + const visit = (value: unknown): void => { + if (Array.isArray(value)) { + value.forEach(visit); + return; + } + if (!value || typeof value !== "object") return; + const element = value as { + props?: Record; + children?: unknown; + }; + if (typeof element.props?.onClick === "function") { + found.push(element.props.onClick as ClickHandler); + } + if (element.props?.children) visit(element.props.children); + if (element.children) visit(element.children); + }; + visit(node); + expect(found).toHaveLength(1); + return found[0]!; +} + +function interaction(actor: ProviderActor | undefined) { + const postEphemeral = vi.fn( + async ( + _user: ProviderActor | string, + _ui: Renderable, + _opts: { fallbackToDM: boolean }, + ): Promise => ({ ok: true, usedFallback: false }), + ); + const post = vi.fn(async (_ui: Renderable): Promise => ({ + id: "m2", + })); + return { + ctx: { + actor, + platform: "slack", + thread: { postEphemeral, post }, + message: { ref: { id: "m1" } }, + } as unknown as InteractionContext, + postEphemeral, + }; +} + +describe("ConnectAccount", () => { + it("tells the clicker when the connection could not even be started", async () => { + const consoleError = vi + .spyOn(console, "error") + .mockImplementation(() => undefined); + // `handleConnectClick` reads the environment before anything else, so an + // incomplete deployment throws out of the click. Unguarded, that throw is + // the dead button this card's whole design exists to prevent. + vi.stubEnv("AGENT_URL", ""); + const press = connectButton(ConnectAccount({ toolkit: "gmail" })); + const { ctx, postEphemeral } = interaction({ id: "U1", kind: "human" }); + + await press(ctx); + + expect(postEphemeral).toHaveBeenCalledTimes(1); + expect(JSON.stringify(postEphemeral.mock.calls[0])).toMatch( + /could not start/i, + ); + // Private, and never to a DM: a failed connect says nothing secret, but the + // path it shares with the link must not learn to follow people around. + expect(postEphemeral.mock.calls[0]![2]).toEqual({ fallbackToDM: false }); + expect(JSON.stringify(consoleError.mock.calls)).toContain( + "connect_account_click", + ); + vi.unstubAllEnvs(); + consoleError.mockRestore(); + }); +}); diff --git a/app/human-in-the-loop/confirm-write.tsx b/app/human-in-the-loop/confirm-write.tsx index d2be22e..836dd57 100644 --- a/app/human-in-the-loop/confirm-write.tsx +++ b/app/human-in-the-loop/confirm-write.tsx @@ -33,6 +33,39 @@ export interface ConfirmWriteField { value: string; } +/** + * Everything the agent can say an action does. Closed on purpose: these are the + * three literals `composio_tools.classify` defines, and nothing else crosses + * the wire. Naming them here rather than accepting any string is what lets the + * card treat a word it does not recognise as the dangerous reading instead of + * silently sorting it with the safe ones. + * + * `read` is unreachable on this card — a read is never gated, so no card is + * posted for one — and `write` is unreachable from the Composio path, whose + * tags cannot express a write that is not destructive. Both stay in the + * vocabulary because the MCP interceptor classifies by `readOnlyHint` metadata + * instead, and because a schema's job here is to reject a typo, not to prove + * which of its members production happens to use this month. + */ +export const CONFIRM_WRITE_EFFECTS = ["read", "write", "destructive"] as const; + +export type ConfirmWriteEffect = (typeof CONFIRM_WRITE_EFFECTS)[number]; + +/** + * The effects that may render neutrally. Everything else does not, and that + * includes both a missing classification and one this card cannot read. + * + * The agent decides the same way: `EffectMap.effect_for` answers `destructive` + * for a slug whose lookup failed and for one carrying no behaviour tag, on the + * grounds that an unclassified tool and a dangerous one are indistinguishable + * from here. Rendering the unclassified case neutral would invert that at the + * one point where the person deciding can see it. + */ +const NEUTRAL_EFFECTS: ReadonlySet = new Set([ + "read", + "write", +]); + interface ConfirmWriteProps { /** Short imperative title of the write, e.g. 'Create Linear issue'. */ action: string; @@ -64,42 +97,60 @@ interface ConfirmWriteProps { /** Why the previous attempt failed, quoted from the tool that rejected it. */ previousError?: string; /** - * What the agent classified the action as — `read`, `write` or - * `destructive`. The agent looked the tool up; this card can only read the - * action's leading word, and for a Composio action that word is the name of - * the app. Absent for the MCP interceptor, which carries no classification - * onto the card and leaves the verb as the only signal. + * What the agent classified the action as. The agent looked the tool up; this + * card can only read the action's leading word, and for a Composio action + * that word is the name of the app. + * + * Absent for the MCP interceptor, which carries no classification onto the + * card. Absent is not `write`: the interceptor gates every tool it cannot + * prove read-only, so a card with no effect is an action nobody vouched for, + * and it is styled accordingly. */ - effect?: string; + effect?: ConfirmWriteEffect; } +/** + * Send the answer, and say what is true when sending it fails. + * + * A `resume` that throws is not evidence that nothing happened. It fails on the + * way out as readily as on the way in, so an approval whose request reached the + * graph before the connection dropped has already been applied — and the write + * with it. The card therefore reports an unknown outcome rather than a paused + * one, and the answer is not re-armed: the same approval sent twice is a second + * destructive write, not a retry, and the card this replaces has no buttons + * left to retry from anyway. + */ async function resumeOrShowFailure( thread: InteractionContext["thread"], messageRef: InteractionContext["message"]["ref"], action: string, confirmed: boolean, - reopen: () => void, ): Promise { try { await thread.resume({ confirmed }); } catch (error) { - // The decision never landed, so the card is answerable again. Holding it - // shut would make the retry this very message asks for impossible. - reopen(); try { await thread.update( messageRef, -
{`⚠️ ${action} paused`}
+
{`⚠️ ${action} — outcome unknown`}
- {"I couldn't resume the agent. Please retry the action."} + {"I lost contact with the agent after sending your answer, so I cannot say whether it ran. It may already have been applied — check before asking again."}
, ); } catch (updateError) { + // Both the answer and the correction failed, so the thread is left + // showing the optimistic receipt — "✅ Approved" — for a write nobody can + // vouch for. Throwing says the click failed; it does not say that, and a + // wrong receipt nobody logged is a wrong receipt nobody can find. + reportRecoverableError(updateError, { + operation: "confirm_write_outcome_unknown", + recovery: "none_receipt_overstates_the_outcome", + }); throw new AggregateError( [error, updateError], - `Failed to resume "${action}" and show its retry state`, + `Failed to resume "${action}" and correct its receipt`, ); } throw error; @@ -185,11 +236,12 @@ const WRONG_APPROVER_NOTICE = ( * The agent writes `platform:id`. Both halves must agree, because a provider id * is unique only within its provider and one deployment can serve two. * - * The exception is `unknown`, which is what the agent writes when the turn - * carried no platform at all. No surface can produce that prefix, so treating - * it as a platform to match would make the card unanswerable by anybody — the - * right person included — and leave the graph paused for good. The id is then - * the only thing both sides know, and it is what gets compared. + * There is no exception, and none is needed. `composio_tools.state` keeps a + * closed `KNOWN_PLATFORMS`, and `_named_identity` refuses anything outside it, + * so `actor_key` writes `slack:` or `teams:` or names nobody at all — never + * `unknown:`, and never a bare id. Anything else reaching here is a shape this + * side of the wire cannot account for, and a platform check with a prefix it + * waves through is a platform check any producer can opt out of. */ function isNamedApprover( interaction: InteractionContext, @@ -198,22 +250,28 @@ function isNamedApprover( const clickedBy = (interaction.actor?.id ?? "").trim(); // Nobody verified pressed this. Refusing costs a click; accepting spends // somebody's account on an unattributed press. + // + // Half of a pair with the `!namedId` check below, and each is redundant while + // the other stands: two empty strings only compare equal when both sides are + // empty. Deleting either leaves the suite green and the behaviour intact — + // and leaves the remaining one load-bearing on its own, which is why both + // stay. The equality is what must never be the whole of the test. if (!clickedBy) return false; const separator = approver.indexOf(":"); + // No separator, no platform half. Read as a bare id it would match on the id + // alone, which is the whole of what this function exists to refuse. if (separator === -1) return false; const namedPlatform = approver.slice(0, separator).trim(); const namedId = approver.slice(separator + 1).trim(); + // See the `!clickedBy` note above: `!namedId` is the other half of that pair. if (!namedId || namedId !== clickedBy) return false; // `composio_tools.state.actor_key` lowercases the platform before it writes // `approver`, so the surface's spelling has to be folded the same way. // Comparing raw, a surface reporting "Slack" missed `slack:U1` — and the only // person entitled to answer the card was the one person refused by it. - return ( - namedPlatform === "unknown" || - namedPlatform === (interaction.platform ?? "").trim().toLowerCase() - ); + return namedPlatform === (interaction.platform ?? "").trim().toLowerCase(); } /** @@ -290,13 +348,14 @@ export function ConfirmWrite({ const verb = verbOf(action); const label = confirmLabel(verb); - // Either signal is enough, and neither can talk the other down. The agent - // looked the tool up, so its classification is the better evidence; the verb - // still counts because an agent that carries no classification — the MCP - // interceptor — leaves the word as the only thing there is to read. Never - // from `label`: a relabelled destructive action is still destructive. + // Either signal is enough, and neither can talk the other down. Only an + // effect the card recognises as harmless buys a neutral button, so an absent + // or unreadable classification is styled as destructive rather than assumed + // safe; the verb still counts on top, because the MCP interceptor sends no + // classification and the word is then the only extra thing there is to read. + // Never from `label`: a relabelled destructive action is still destructive. const destructive = - effect === "destructive" || DESTRUCTIVE.has(verb.toLowerCase()); + !NEUTRAL_EFFECTS.has(effect ?? "") || DESTRUCTIVE.has(verb.toLowerCase()); // One decision per card. Both buttons close over this, so a double press — // or an approve followed a moment later by a cancel — resolves the interrupt @@ -326,9 +385,7 @@ export function ConfirmWrite({ recovery: "resumed_the_agent_anyway", }); } - await resumeOrShowFailure(thread, message.ref, action, confirmed, () => { - answered = false; - }); + await resumeOrShowFailure(thread, message.ref, action, confirmed); }; return ( diff --git a/app/human-in-the-loop/connect-account.tsx b/app/human-in-the-loop/connect-account.tsx index d261029..164a3c7 100644 --- a/app/human-in-the-loop/connect-account.tsx +++ b/app/human-in-the-loop/connect-account.tsx @@ -14,6 +14,7 @@ import { Section, } from "@copilotkit/channels"; import type { InteractionContext } from "@copilotkit/channels"; +import { reportRecoverableError } from "../channel-helpers.js"; /** What the button carries. The toolkit only — never an id, never a link. */ export type ConnectRequest = { toolkit: string }; @@ -38,8 +39,37 @@ async function connect( interaction: InteractionContext, toolkit: string, ) { - const { handleConnectClick } = await import("../tools/connect-click.js"); - await handleConnectClick(toolkit, interaction); + try { + const { handleConnectClick } = await import("../tools/connect-click.js"); + await handleConnectClick(toolkit, interaction); + } catch (error) { + // The one outcome this card is shaped to avoid. `handleConnectClick` + // reports the failures it can name — a request that came back refused, a + // clicker it could not identify — by telling that person. Everything it + // cannot name, an incomplete deployment among them, threw straight out of + // the click, and a swallowed throw here is a button that does nothing and + // says nothing, minutes after the person was told to press it. + reportRecoverableError(error, { + operation: "connect_account_click", + recovery: "told_the_clicker_privately", + }); + try { + await interaction.thread.postEphemeral( + interaction.actor ?? "unknown", + , + // Never a DM, matching the link this would have delivered: the private + // path for a connect flow does not follow people out of the thread. + { fallbackToDM: false }, + ); + } catch (tellError) { + reportRecoverableError(tellError, { + operation: "connect_account_click_notice", + recovery: "none_the_clicker_was_not_told", + }); + } + } } export function ConnectAccount({ toolkit }: { toolkit: string }) { diff --git a/app/human-in-the-loop/index.ts b/app/human-in-the-loop/index.ts index c25d99a..226c874 100644 --- a/app/human-in-the-loop/index.ts +++ b/app/human-in-the-loop/index.ts @@ -6,7 +6,11 @@ * The backend MCP write interceptor emits `confirm_write`. Its `on_interrupt` * event posts `ConfirmWrite`; the card's buttons call `thread.resume(...)`. */ -export { ConfirmWrite } from "./confirm-write.js"; +export { ConfirmWrite, CONFIRM_WRITE_EFFECTS } from "./confirm-write.js"; +export type { + ConfirmWriteEffect, + ConfirmWriteField, +} from "./confirm-write.js"; export { ConnectAccount, ConnectFailed, diff --git a/app/interrupt.test.ts b/app/interrupt.test.ts index 87d6492..5de100e 100644 --- a/app/interrupt.test.ts +++ b/app/interrupt.test.ts @@ -1,6 +1,7 @@ import { EventType } from "@ag-ui/client"; import { createRunRenderer } from "@copilotkit/channels/slack/render"; import { describe, expect, it, vi } from "vitest"; +import { ZodError } from "zod"; import { parseConfirmWriteInterrupt } from "./interrupt.js"; const realEnvelope = { @@ -218,3 +219,56 @@ describe("parseConfirmWriteInterrupt approver", () => { expect(args.action).toBe("Create issue"); }); }); + +describe("parseConfirmWriteInterrupt fail-safe", () => { + it("reads the three effects the agent classifies", () => { + for (const effect of ["read", "write", "destructive"] as const) { + expect( + parseConfirmWriteInterrupt( + interruptPayload("confirm_write", { action: "Do it", effect }), + ).args.effect, + ).toBe(effect); + } + }); + + it("reads an effect outside that vocabulary as destructive", () => { + // `EffectMap.effect_for` answers `destructive` for anything it cannot + // classify. A word this schema does not know is the same situation one hop + // later, and the card must not be handed a value it will render neutral. + expect( + parseConfirmWriteInterrupt( + interruptPayload("confirm_write", { + action: "Do it", + effect: "purge", + }), + ).args.effect, + ).toBe("destructive"); + }); + + it("throws one shape for bad JSON, not two for the same contract", () => { + // The renderer's contract is a ZodError. A raw SyntaxError from an + // unguarded `JSON.parse` is a second throw shape for the same failure, and + // the handler that has to tell them apart cannot. + let thrown: unknown; + try { + parseConfirmWriteInterrupt("{broken"); + } catch (error) { + thrown = error; + } + expect(thrown).toBeInstanceOf(ZodError); + expect(thrown).not.toBeInstanceOf(SyntaxError); + }); + + it("posts the card when the agent sends no message history", () => { + // `__copilotkit_messages__` is never read here. Requiring it means a + // producer that omits it kills the card, and the graph waits on a question + // nobody was asked. + const { args } = parseConfirmWriteInterrupt({ + __copilotkit_interrupt_value__: { + action: "confirm_write", + args: { action: "Create issue" }, + }, + }); + expect(args.action).toBe("Create issue"); + }); +}); diff --git a/app/interrupt.ts b/app/interrupt.ts index ee604ff..cbabfa8 100644 --- a/app/interrupt.ts +++ b/app/interrupt.ts @@ -1,4 +1,28 @@ import { z } from "zod"; +import { + CONFIRM_WRITE_EFFECTS, + type ConfirmWriteEffect, +} from "./human-in-the-loop/confirm-write.js"; + +/** What an unreadable classification is treated as, on both sides of the wire. */ +const DANGEROUS_READING: ConfirmWriteEffect = "destructive"; + +/** + * The classification the agent sends, or the dangerous reading when it sends + * something this side does not know. + * + * `.catch` rather than a bare enum, because the two ways of being strict here + * both fail in the wrong direction. Accepting any string lets an unreadable + * word render as a harmless one — the agent's fail-safe inverted on this side + * of the wire. Throwing on it kills the whole card, and a graph paused on a + * question nobody was asked is worse than one asked in red. Falling back to + * `destructive` is the same answer `EffectMap.effect_for` gives when it cannot + * classify a slug. + */ +const effectSchema = z + .enum(CONFIRM_WRITE_EFFECTS) + .nullish() + .catch(DANGEROUS_READING); const confirmWriteInterruptSchema = z.object({ __copilotkit_interrupt_value__: z.object({ @@ -35,17 +59,41 @@ const confirmWriteInterruptSchema = z.object({ */ approver: z.string().min(1).nullish(), /** `read`, `write`, or `destructive` — what the agent classified it as. */ - effect: z.string().nullish(), + effect: effectSchema, }), }), - __copilotkit_messages__: z.array(z.unknown()), + // `__copilotkit_messages__` is deliberately absent. The envelope carries the + // run's message history and nothing here reads it, so requiring it only gave + // a producer that omits it a way to kill the card. Unknown keys pass. }); -function normalize(payload: unknown): unknown { - return typeof payload === "string" ? JSON.parse(payload) : payload; -} +/** + * The envelope as an object, whichever way it arrived. + * + * The JSON parse is folded into the schema rather than run ahead of it so this + * module has exactly one throw shape. An unguarded `JSON.parse` threw a raw + * `SyntaxError` for a truncated payload and a `ZodError` for a structurally + * wrong one — the same failure, in two shapes, for the handler that has to + * report it. + */ +const envelopeSchema = z + .unknown() + .transform((payload, ctx) => { + if (typeof payload !== "string") return payload; + try { + return JSON.parse(payload) as unknown; + } catch (error) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `interrupt payload is not JSON: ${ + error instanceof Error ? error.message : String(error) + }`, + }); + return z.NEVER; + } + }) + .pipe(confirmWriteInterruptSchema); export function parseConfirmWriteInterrupt(payload: unknown) { - return confirmWriteInterruptSchema.parse(normalize(payload)) - .__copilotkit_interrupt_value__; + return envelopeSchema.parse(payload).__copilotkit_interrupt_value__; } From 2690d3e5f314f303a89c2a7c9eb3a65389c01fbb Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 2 Sep 2026 20:48:57 +0200 Subject: [PATCH 19/23] fix(deploy): let an existing AWS deployment upgrade without new configuration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ECS resolves every declared secret field when a task starts and fails the task when one is missing. COMPOSIO_API_KEY, SLACK_BOT_TOKEN and SLACK_APP_TOKEN were added to the task definition unconditionally, and none of them is in the secret this repository has documented — so an existing deployment did not get a deploy-time error it could read and fix, it stopped starting tasks the moment it took the upgrade. Each of the three is now declared only when the context that gives it a purpose is set, so an upgrade asks for nothing new and turning Composio on is one deliberate step that adds the field and sets the context together. AGENT_AUTH_HEADER stays mandatory: the documented secret has required it since before the agent read it. Also here: - The agent container never received INTELLIGENCE_CHANNEL_NAME, which it reads as the default Composio workspace user id. A deployment that renamed its channel ran the team's shared connections under the literal `open-tag`. - AGENT_AUTH_HEADER is one constant referenced from both secret lists rather than the same string typed twice, and both halves in `.railway/railway.ts` now point at each other. - `composio>=0.9.0` admitted releases with no `.sessions` at all; 0.17.0 is the first that has it, verified against the published wheels. `daytona` and `langchain-daytona` had no floor at all and now carry the ones the lock resolves. - `agent/pyproject.toml` had no `[build-system]`, so uv treated the project as virtual, never built it, and every `[tool.setuptools]` line described a wheel nothing produced. `agent/uv.lock` is regenerated for it: one line, `virtual` to `editable`, no package version moves. And the assertions that let all of it through: - `opentag-stack.test.ts` compared environment names only. Flipping the CORS default, pointing PLAYWRIGHT_BROWSERS_PATH at a directory the image does not have and moving the runtime PORT off the port its own health check probes all passed. Compared as name and value now. - The secret sets are asserted as whole sets per container and per context, so a credential on the wrong service is a failure. - `test_packaging.py` never looked at `COPY agent/*.py ./`. Deleting it left an image with no `main.py` — the file its own CMD runs — and passed. - `runtime_packages()` excluded `tests` at depth one only, so a nested `composio_tools/tests/` would have made the suite demand a test package ship in the wheel. - `railway.test.ts` typed `deploy` as start command and health-check path, so deleting the restart policy from both services passed. It also shelled out to a second Node that recompiled the config, 0.3s to 6.2s depending on load, which is the flake that straddled the default timeout; it evaluates in this process now, so there is no second compile to lose the race. Call sites for what changed: - SHARED_AUTH_SECRET_KEY (new, module-private): AGENT_SECRET_KEYS and RUNTIME_SECRET_KEYS, both in deployment/aws/lib/opentag-stack.ts. - COMPOSIO_AGENT_SECRET_KEYS, PERSONAL_CONNECT_RUNTIME_SECRET_KEYS (new, module-private): one container `secrets` block each, same file. - AGENT_SECRET_KEYS, RUNTIME_SECRET_KEYS (contents changed): one use each, same file; nothing outside it reads either. - environmentNames -> environmentValues in deployment/aws/test/opentag-stack.test.ts: four call sites, all in that file; no remaining reference to the old name. - package_names, lower_bound, COMPOSIO_SESSIONS_FLOOR, REQUIRED_FLOORS (new) and runtime_packages (body changed) in agent/tests/test_packaging.py: all used only within that file. - railwayGraphReport, evaluateRailwayGraph, RailwayResource and RailwayVariable removed from app/railway.test.ts; railwayGraph, serviceNamed and RESILIENCE added; variableNames now takes a ServiceNode. All private to that file, no remaining reference to the removed names. Co-Authored-By: Claude Opus 5 (1M context) --- .railway/railway.ts | 6 + agent/pyproject.toml | 21 +- agent/tests/test_packaging.py | 145 +++++++++++++- agent/uv.lock | 8 +- app/railway.test.ts | 171 +++++++++-------- deployment/aws/lib/opentag-stack.ts | 89 +++++++-- deployment/aws/test/opentag-stack.test.ts | 222 +++++++++++++++++----- 7 files changed, 507 insertions(+), 155 deletions(-) diff --git a/.railway/railway.ts b/.railway/railway.ts index 60e8ab0..98be2ca 100644 --- a/.railway/railway.ts +++ b/.railway/railway.ts @@ -50,7 +50,12 @@ export default defineRailway(() => { COMPOSIO_APPROVALS: preserve(), COMPOSIO_WORKSPACE_USER_ID: preserve(), COMPOSIO_AUTH_CONFIGS: preserve(), + // The agent side of the shared secret the runtime presents; see the + // runtime's copy below. Both services have to hold the same value or + // every request the runtime makes comes back 401. AGENT_AUTH_HEADER: preserve(), + // Read by the agent as the default Composio workspace user id, and by the + // runtime as the Channel to attach to. Both, and the same value. INTELLIGENCE_CHANNEL_NAME: "open-tag", }, }); @@ -82,6 +87,7 @@ export default defineRailway(() => { "wss://realtime.intelligence.copilotkit.ai", INTELLIGENCE_LEARNING_CONTAINER_ID: preserve(), INTELLIGENCE_CHANNEL_NAME: "open-tag", + // The runtime side of the pair the agent declares above. AGENT_AUTH_HEADER: preserve(), // Only so a Composio connect link can reach one person privately; the // managed adapter cannot post a message only one person sees. diff --git a/agent/pyproject.toml b/agent/pyproject.toml index 187c328..3eda9b2 100644 --- a/agent/pyproject.toml +++ b/agent/pyproject.toml @@ -5,7 +5,11 @@ description = "OpenTag general-purpose team knowledge-work agent — CopilotKit requires-python = ">=3.12" dependencies = [ "ag-ui-langgraph>=0.0.23", - "composio>=0.9.0", + # 0.17.0 is the first release whose client exposes `.sessions`, and everything + # in `composio_tools/sessions.py` goes through it. Below that the SDK offers + # `tool_router` and no alias, so a lower resolution installs, imports, and + # raises on the first turn that touches a toolkit. + "composio>=0.17.0", "copilotkit>=0.1.76", "deepagents>=0.6.12", "fastapi>=0.115.14", @@ -17,8 +21,11 @@ dependencies = [ "pyjwt[crypto]>=2.10.1", "tavily-python>=0.3.0", "uvicorn[standard]>=0.40.0", - "daytona", - "langchain-daytona", + # Floors, not bare names: a bare requirement resolves to whatever the index + # offers on the day the image is built, and the lockfile hides that until + # somebody regenerates it. These are the releases the lock resolves today. + "daytona>=0.204.0", + "langchain-daytona>=0.0.7", ] [dependency-groups] @@ -41,3 +48,11 @@ coding = ["skills/*/SKILL.md"] [tool.pytest.ini_options] pythonpath = ["."] + +# Without this table uv treats the project as virtual: it is never built, and +# every `[tool.setuptools]` line above describes a wheel that nothing produces. +# The agent image's second `uv sync --frozen --no-dev`, the one that runs after +# the source COPYs, is the step that builds it. +[build-system] +requires = ["setuptools>=77"] +build-backend = "setuptools.build_meta" diff --git a/agent/tests/test_packaging.py b/agent/tests/test_packaging.py index a02f6ff..27fe7b8 100644 --- a/agent/tests/test_packaging.py +++ b/agent/tests/test_packaging.py @@ -1,5 +1,6 @@ import re import tomllib +from collections.abc import Iterable from pathlib import Path AGENT_ROOT = Path(__file__).resolve().parent.parent @@ -24,6 +25,19 @@ KNOWN_MODULES = frozenset({"agent", "agent_auth", "main"}) KNOWN_PACKAGE_ROOTS = frozenset({"coding", "composio_tools", "prompts"}) +#: The first `composio` release whose client exposes `.sessions`. Everything in +#: `composio_tools/sessions.py` goes through it, and below this release the SDK +#: offers `tool_router` and no alias — so a resolver that picked a lower version +#: satisfies the floor, installs, imports, and raises `AttributeError` on the +#: first turn that touches a toolkit. Verified against the published wheels: +#: 0.16.0 has no `def sessions`, 0.17.0 does, and 0.17.0 already accepts the +#: `sandbox` and `manage_connections` arguments this repository passes. +COMPOSIO_SESSIONS_FLOOR = (0, 17, 0) + +#: Floors that are not a matter of taste: the earliest release carrying an API +#: this repository actually calls. +REQUIRED_FLOORS = {"composio": COMPOSIO_SESSIONS_FLOOR} + def runtime_modules() -> set[str]: """Every top-level module on disk that the wheel has to carry.""" @@ -47,6 +61,26 @@ def package_roots() -> set[str]: return roots +def package_names(init_paths: Iterable[Path], root: Path) -> set[str]: + """ + The dotted names of the packages `init_paths` describe, minus what never ships. + + Split out and given its root so the exclusion can be tested at a depth the + checkout does not currently have. `NOT_SHIPPED_PACKAGES` is matched against + every path segment rather than only the first: the version that looked at + the top-level name alone let a `composio_tools/tests/__init__.py` through, + and this file would then have demanded that a test package be listed in the + wheel — a derived assertion arguing for the opposite of what it exists for. + """ + names = set() + for path in init_paths: + parts = path.parent.relative_to(root).parts + if NOT_SHIPPED_PACKAGES.intersection(parts): + continue + names.add(".".join(parts)) + return names + + def runtime_packages() -> set[str]: """ Every package setuptools has to be named, nested ones included. @@ -55,12 +89,25 @@ def runtime_packages() -> set[str]: `composio_tools` does not carry `composio_tools.adapters`, which then imports fine from a source checkout and is missing from the wheel. A depth-one scan is that exact failure, so this one goes all the way down. + + Down from the package roots, not from the agent directory: a build leaves + `build/lib//__init__.py` behind, and a sweep of the whole tree + would then ask setuptools to package its own output. """ - return { - ".".join(path.parent.relative_to(AGENT_ROOT).parts) - for root in package_roots() - for path in (AGENT_ROOT / root).rglob("__init__.py") - } + return package_names( + ( + path + for root in package_roots() + for path in (AGENT_ROOT / root).rglob("__init__.py") + ), + AGENT_ROOT, + ) + + +def lower_bound(requirement: str) -> tuple[int, ...] | None: + """The `>=` floor in a requirement, as a comparable tuple, or `None`.""" + match = re.search(r">=\s*(\d+(?:\.\d+)*)", requirement) + return tuple(int(part) for part in match.group(1).split(".")) if match else None def declared_dependencies() -> dict[str, str]: @@ -84,6 +131,47 @@ def test_wheel_includes_every_runtime_module(): assert set(project["tool"]["setuptools"]["packages"]) == runtime_packages() +def test_nested_test_packages_never_reach_the_wheel(): + # At a depth the checkout does not currently have, which is the whole point: + # the exclusion used to read the first path segment only, so the day someone + # adds `composio_tools/tests/` this file starts demanding the test package + # ship in the wheel. + root = Path("/agent") + + assert package_names( + [ + root / "composio_tools" / "__init__.py", + root / "composio_tools" / "adapters" / "__init__.py", + root / "composio_tools" / "tests" / "__init__.py", + root / "composio_tools" / "tests" / "fixtures" / "__init__.py", + root / "tests" / "__init__.py", + root / ".venv" / "lib" / "site-packages" / "anything" / "__init__.py", + ], + root, + ) == {"composio_tools", "composio_tools.adapters"} + + +def test_agent_image_copies_every_runtime_module(): + # Deleting `COPY agent/*.py ./` leaves an image with no `main.py`, which is + # the file its own CMD runs: the container cannot boot at all. The package + # assertion below never looked at it, so that deletion passed the suite. + # + # Each COPY's source is expanded against the checkout rather than compared + # as text, so the assertion holds however the line is written — one glob or + # seven explicit paths — and fails when it stops covering a module. + dockerfile = ( + REPO_ROOT / "deployment" / "docker" / "agent.Dockerfile" + ).read_text(encoding="utf-8") + + copied = set() + for source, target in re.findall(r"^COPY agent/(\S+) (\S+)$", dockerfile, re.M): + if not source.endswith(".py") or target not in ("./", "."): + continue + copied |= {path.stem for path in AGENT_ROOT.glob(source)} + + assert copied - NOT_SHIPPED_MODULES == runtime_modules() + + def test_agent_image_copies_every_runtime_package(): # The image copies packages one line at a time, so a new package imports # fine locally and crashes the container on first import. Derived from disk @@ -109,8 +197,53 @@ def test_coding_dependencies_are_declared(): # Whole names, not prefixes: `dep.startswith("httpx")` was satisfied by # `httpx-sse`, a different distribution that does not provide `httpx`. - assert {"daytona", "langchain-daytona", "httpx", "pyjwt"} <= set(declared) + # `composio` is in the list because the agent imports it unconditionally + # from `composio_tools/sessions.py`, and nothing here asserted it was + # declared at all. + assert {"composio", "daytona", "langchain-daytona", "httpx", "pyjwt"} <= set( + declared + ) # And the extra, not merely the distribution: the coder signs GitHub App # tokens with `cryptography`, which only the `crypto` extra pulls in. assert "[crypto]" in declared["pyjwt"] + + +def test_every_dependency_declares_a_lower_bound(): + # A bare `daytona` resolves to whatever the index offers on the day the + # image is built, including a release that renamed the API underneath us, + # and the lockfile hides that until someone regenerates it. A floor is the + # only part of this that survives a re-resolve. + unbounded = sorted( + requirement + for requirement in declared_dependencies().values() + if lower_bound(requirement) is None + ) + + assert unbounded == [] + + +def test_pinned_apis_declare_a_floor_that_has_them(): + declared = declared_dependencies() + + for name, floor in REQUIRED_FLOORS.items(): + assert lower_bound(declared[name]) >= floor, ( + f"{declared[name]} admits a release without the API this repo calls" + ) + + +def test_the_project_is_actually_built(): + # Without `[build-system]` the whole `[tool.setuptools]` table above is + # inert: uv treats the project as virtual, never builds it, and the wheel + # the assertions in this file describe is never produced by anything. The + # image's `uv sync --frozen --no-dev` after the source COPYs is the step + # that builds it, and it only builds a project that names a backend. + project = tomllib.loads((AGENT_ROOT / "pyproject.toml").read_text()) + + assert project["build-system"]["build-backend"] == "setuptools.build_meta" + # And the backend the `[tool.setuptools]` config is written for has to be + # in the build requirements, or the build reaches for whatever is around. + assert any( + requirement.startswith("setuptools") + for requirement in project["build-system"]["requires"] + ) diff --git a/agent/uv.lock b/agent/uv.lock index 0db8c39..57faa3f 100644 --- a/agent/uv.lock +++ b/agent/uv.lock @@ -1517,7 +1517,7 @@ wheels = [ [[package]] name = "opentag-agent" version = "0.1.0" -source = { virtual = "." } +source = { editable = "." } dependencies = [ { name = "ag-ui-langgraph" }, { name = "composio" }, @@ -1544,14 +1544,14 @@ dev = [ [package.metadata] requires-dist = [ { name = "ag-ui-langgraph", specifier = ">=0.0.23" }, - { name = "composio", specifier = ">=0.9.0" }, + { name = "composio", specifier = ">=0.17.0" }, { name = "copilotkit", specifier = ">=0.1.76" }, - { name = "daytona" }, + { name = "daytona", specifier = ">=0.204.0" }, { name = "deepagents", specifier = ">=0.6.12" }, { name = "fastapi", specifier = ">=0.115.14" }, { name = "httpx", specifier = ">=0.27.0" }, { name = "langchain", specifier = ">=1.2.4" }, - { name = "langchain-daytona" }, + { name = "langchain-daytona", specifier = ">=0.0.7" }, { name = "langchain-mcp-adapters", specifier = ">=0.3.0" }, { name = "langchain-openai", specifier = ">=1.1.7" }, { name = "pyjwt", extras = ["crypto"], specifier = ">=2.10.1" }, diff --git a/app/railway.test.ts b/app/railway.test.ts index 84a7ef0..d6c9cbe 100644 --- a/app/railway.test.ts +++ b/app/railway.test.ts @@ -1,74 +1,49 @@ -import { execFileSync } from "node:child_process"; -import { join } from "node:path"; -import { fileURLToPath } from "node:url"; +import { + createRailwayContext, + project, + projectDefinitionToGraph, + validateGraph, + type RailwayGraph, + type ServiceNode, +} from "railway/iac"; import { describe, expect, it } from "vitest"; +import railwayProgram from "../.railway/railway.js"; -// Resolved from this file rather than from `process.cwd()`. The bin path was -// relative, so the run worked only because vitest happens to start at the -// repository root, and failed outright when it started anywhere else. -const repositoryRoot = fileURLToPath(new URL("..", import.meta.url)); -const railwayBin = join( - repositoryRoot, - "node_modules", - "railway", - "dist", - "iac", - "bin.js", -); - -interface RailwayVariable { - type: "literal" | "preserve"; - value?: string; -} - -interface RailwayResource { - name: string; - source?: { - repo?: string; - branch?: string; - rootDirectory?: string; - }; - build?: { - builder?: string; - buildCommand?: string; - watchPatterns?: string[] | null; - }; - deploy?: { - startCommand?: string; - healthcheckPath?: string; - }; - variables?: Record; +/** + * The compiled deployment graph. + * + * Evaluated in this process rather than by shelling out to `railway`'s bin. + * The subprocess booted a second Node, loaded the whole `railway` bundle again + * and re-compiled the config through `tsx` — between 0.3s and 6.2s depending on + * what else the machine was doing, which straddles vitest's default timeout and + * went red on an unmodified config. Raising the timeout only moves the number + * the flake has to beat; removing the second process removes the variance. This + * is the same sequence the bin runs (`resolveDefinition` then + * `projectDefinitionToGraph`), against the compiler vitest has already warmed. + */ +async function railwayGraph(): Promise { + const graph = projectDefinitionToGraph( + await railwayProgram(createRailwayContext({}), project), + ); + // What the bin reports as `ok: false` with diagnostics attached. + expect(validateGraph(graph)).toEqual([]); + return graph; } -/** The evaluator's report, whatever it exits with. */ -function railwayGraphReport(): string { - try { - return execFileSync(process.execPath, [railwayBin], { - cwd: repositoryRoot, - encoding: "utf8", - }); - } catch (error) { - // The bin exits 1 when the graph does not evaluate, which `execFileSync` - // turns into a throw — so the two assertions below never ran on the one - // input they exist for, and a bad config surfaced as an exit code with no - // diagnostic attached. Its stdout still carries the report. - const { stdout, stderr } = error as { stdout?: string; stderr?: string }; - if (stdout) return stdout; +/** The one service called `name`, or a failure that says which one is missing. */ +function serviceNamed(graph: RailwayGraph, name: string): ServiceNode { + const service = graph.resources.find( + (candidate): candidate is ServiceNode => + candidate.type === "service" && candidate.name === name, + ); + if (!service) { throw new Error( - `railway iac could not be run: ${stderr || String(error)}`, + `no service called ${name}; the graph has ${graph.resources + .map((resource) => resource.name) + .join(", ")}`, ); } -} - -function evaluateRailwayGraph(): RailwayResource[] { - const result = JSON.parse(railwayGraphReport()) as { - ok: boolean; - diagnostics: unknown[]; - graph: { resources: RailwayResource[] }; - }; - expect(result.diagnostics).toEqual([]); - expect(result.ok).toBe(true); - return result.graph.resources; + return service; } /** @@ -79,19 +54,38 @@ function evaluateRailwayGraph(): RailwayResource[] { * service quietly added to the other passes it without complaint. The whole * name list is compared instead. */ -function variableNames(resource: RailwayResource | undefined): string[] { - return Object.keys(resource?.variables ?? {}).sort(); +function variableNames(service: ServiceNode): string[] { + return Object.keys(service.variables ?? {}).sort(); } +/** + * What both services must say about restarts and health checks. + * + * Its own constant because the previous version of this file typed `deploy` as + * `{ startCommand, healthcheckPath }` and asserted nothing else: deleting the + * restart policy from both services, or setting the health-check timeout to a + * second, left the suite green. A service that never restarts after a crash is + * the failure this deployment config exists to prevent. + */ +const RESILIENCE = { + // Five minutes: the agent installs nothing at boot but does import the model + // and MCP clients, and the runtime waits on the agent. + healthcheckTimeout: 300, + // Restart a crashed container, and stop after five so a container that + // cannot start does not restart forever without anyone noticing. + restartPolicyType: "ON_FAILURE", + restartPolicyMaxRetries: 5, +} as const; + describe("Railway deployment graph", () => { - // An explicit timeout. The evaluator spawns a Node process that compiles the - // config, measured between 0.3s and 6.2s depending on machine load, which - // straddles vitest's 5s default and has gone red on unmodified config. - it("ships the Python agent and Chromium-capable runtime services", () => { - const resources = evaluateRailwayGraph(); - expect(resources.map(({ name }) => name).sort()).toEqual(["agent", "runtime"]); + it("ships the Python agent and Chromium-capable runtime services", async () => { + const graph = await railwayGraph(); + expect(graph.resources.map(({ name }) => name).sort()).toEqual([ + "agent", + "runtime", + ]); - const agent = resources.find(({ name }) => name === "agent"); + const agent = serviceNamed(graph, "agent"); expect(agent).toMatchObject({ source: { repo: "CopilotKit/OpenTag", @@ -102,12 +96,12 @@ describe("Railway deployment graph", () => { builder: "RAILPACK", }, deploy: { - startCommand: - 'uvicorn main:app --host "" --port ${PORT:-8123}', + startCommand: 'uvicorn main:app --host "" --port ${PORT:-8123}', healthcheckPath: "/health", + ...RESILIENCE, }, }); - expect(agent?.variables).toMatchObject({ + expect(agent.variables).toMatchObject({ AGENT_DISPLAY_NAME: { type: "preserve" }, OPENAI_API_KEY: { type: "preserve" }, TAVILY_API_KEY: { type: "preserve" }, @@ -129,6 +123,12 @@ describe("Railway deployment graph", () => { COMPOSIO_WORKSPACE_USER_ID: { type: "preserve" }, COMPOSIO_AUTH_CONFIGS: { type: "preserve" }, AGENT_AUTH_HEADER: { type: "preserve" }, + // The agent derives the default Composio workspace user id from this, so + // it has to reach the agent and not only the runtime. + INTELLIGENCE_CHANNEL_NAME: { type: "literal", value: "open-tag" }, + // The port the start command falls back to and the port the runtime is + // told to reach it on. + PORT: { type: "literal", value: "8123" }, }); // The agent holds the Composio key and every source credential; the @@ -163,7 +163,7 @@ describe("Railway deployment graph", () => { "TAVILY_API_KEY", ]); - const runtime = resources.find(({ name }) => name === "runtime"); + const runtime = serviceNamed(graph, "runtime"); expect(runtime).toMatchObject({ source: { repo: "CopilotKit/OpenTag", @@ -177,13 +177,13 @@ describe("Railway deployment graph", () => { deploy: { startCommand: "pnpm runtime", healthcheckPath: "/api/copilotkit/info", + ...RESILIENCE, }, variables: { AGENT_DISPLAY_NAME: { type: "preserve" }, AGENT_URL: { type: "literal", - value: - "http://${{agent.RAILWAY_PRIVATE_DOMAIN}}:${{agent.PORT}}/", + value: "http://${{agent.RAILWAY_PRIVATE_DOMAIN}}:${{agent.PORT}}/", }, INTELLIGENCE_API_KEY: { type: "preserve" }, INTELLIGENCE_API_URL: { @@ -231,5 +231,16 @@ describe("Railway deployment graph", () => { "SLACK_APP_TOKEN", "SLACK_BOT_TOKEN", ]); - }, 60_000); + }); + + it("keeps both services on the same channel the agent identifies with", async () => { + // One name, two services, and the platform matches it character for + // character. Split out because the assertions above read each service on + // its own and neither notices the pair drifting apart. + const graph = await railwayGraph(); + + expect(serviceNamed(graph, "agent").variables?.INTELLIGENCE_CHANNEL_NAME).toEqual( + serviceNamed(graph, "runtime").variables?.INTELLIGENCE_CHANNEL_NAME, + ); + }); }); diff --git a/deployment/aws/lib/opentag-stack.ts b/deployment/aws/lib/opentag-stack.ts index ff9a3cf..a27734d 100644 --- a/deployment/aws/lib/opentag-stack.ts +++ b/deployment/aws/lib/opentag-stack.ts @@ -19,12 +19,27 @@ const repositoryRoot = path.resolve(currentDirectory, "../../.."); const DATADOG_FORWARDER_TEMPLATE_URL = "https://datadog-cloudformation-template.s3.amazonaws.com/aws/forwarder/5.4.11.yaml"; +/** + * The shared secret the runtime presents and the agent checks. + * + * Named once and referenced from both lists below, because the two containers + * have to read the same field of the same secret: point one of them at a + * different name and the runtime authenticates against a value the agent never + * sees, which is a 401 on every request and nothing in the template to show + * why. See `agent/agent_auth.py` for what the agent does with it. + */ +const SHARED_AUTH_SECRET_KEY = "AGENT_AUTH_HEADER"; + +/** + * Fields every documented OpenTag secret already carries. + * + * Injected unconditionally, which is only safe because + * `deployment/aws/README.md` has required each of them since before this + * release — an existing secret has them, empty string or not. + */ const AGENT_SECRET_KEYS = [ "OPENAI_API_KEY", - // The agent owns the Composio session, so the key and the shared secret it - // checks both live on this service. - "COMPOSIO_API_KEY", - "AGENT_AUTH_HEADER", + SHARED_AUTH_SECRET_KEY, "TAVILY_API_KEY", "DAYTONA_API_KEY", "GITHUB_PERSONAL_ACCESS_TOKEN", @@ -36,9 +51,35 @@ const AGENT_SECRET_KEYS = [ const RUNTIME_SECRET_KEYS = [ "INTELLIGENCE_API_KEY", - "AGENT_AUTH_HEADER", - // Only so a Composio connect link can reach one person privately; the managed - // adapter cannot post a message only one person sees. + SHARED_AUTH_SECRET_KEY, +] as const; + +/** + * Fields this release introduces, declared only when they have a job to do. + * + * ECS resolves every declared secret field when the task starts and fails the + * task when one is missing. A new field added to the lists above is therefore + * not a deploy-time error an operator can read and correct — it is an existing + * deployment that stops starting tasks the moment it takes the upgrade, before + * anybody had the chance to add the field. So an upgrade asks for nothing new, + * and turning the feature on is one deliberate step that adds the field and + * sets the context together. + * + * The agent treats a Composio key with no toolkits as unconfigured + * (`agent/composio_tools/config.py`), so the toolkit lists are exactly the + * signal for whether the key has anything to do. + */ +const COMPOSIO_AGENT_SECRET_KEYS = ["COMPOSIO_API_KEY"] as const; + +/** + * Slack credentials the runtime needs only for personal connect links. + * + * A personal link is a bearer capability and has to reach one named person, + * which the managed adapter cannot do — it cannot post a message only one + * person sees. Shared toolkits are connected by an operator from the CLI and + * need no direct Slack at all. + */ +const PERSONAL_CONNECT_RUNTIME_SECRET_KEYS = [ "SLACK_BOT_TOKEN", "SLACK_APP_TOKEN", ] as const; @@ -146,6 +187,15 @@ export class OpenTagStack extends cdk.Stack { "daytonaTtlMinutes", 60, ); + const composioToolkits = contextString(this, "composioToolkits", ""); + const composioUserToolkits = contextString( + this, + "composioUserToolkits", + "", + ); + // Either list on its own turns the integration on, and one key serves both. + const composioConfigured = + composioToolkits.length > 0 || composioUserToolkits.length > 0; const githubAppId = contextString(this, "githubAppId", ""); const githubAppInstallationId = contextString( this, @@ -260,6 +310,11 @@ export class OpenTagStack extends cdk.Stack { "githubMcpUrl", "https://api.githubcopilot.com/mcp/readonly", ), + // The agent derives the default Composio workspace user id from this. + // Without it the team's shared connections resolve under the literal + // `open-tag` whatever the channel is really called, so a deployment + // that renamed its channel silently connects the wrong identity. + INTELLIGENCE_CHANNEL_NAME: channelName, LINEAR_MCP_URL: contextString( this, "linearMcpUrl", @@ -269,13 +324,10 @@ export class OpenTagStack extends cdk.Stack { "NOTION_MCP_URL", contextString(this, "notionMcpUrl", ""), ), - ...optionalEnvironment( - "COMPOSIO_TOOLKITS", - contextString(this, "composioToolkits", ""), - ), + ...optionalEnvironment("COMPOSIO_TOOLKITS", composioToolkits), ...optionalEnvironment( "COMPOSIO_USER_TOOLKITS", - contextString(this, "composioUserToolkits", ""), + composioUserToolkits, ), ...optionalEnvironment( "COMPOSIO_APPROVALS", @@ -318,6 +370,9 @@ export class OpenTagStack extends cdk.Stack { memoryReservationMiB: 1792, secrets: { ...secretFields(applicationSecret, AGENT_SECRET_KEYS), + ...(composioConfigured + ? secretFields(applicationSecret, COMPOSIO_AGENT_SECRET_KEYS) + : {}), ...(githubAppPrivateKeySecret ? { GITHUB_APP_PRIVATE_KEY_BASE64: @@ -366,7 +421,15 @@ export class OpenTagStack extends cdk.Stack { streamPrefix: "runtime", }), memoryReservationMiB: 1792, - secrets: secretFields(applicationSecret, RUNTIME_SECRET_KEYS), + secrets: { + ...secretFields(applicationSecret, RUNTIME_SECRET_KEYS), + ...(composioUserToolkits.length > 0 + ? secretFields( + applicationSecret, + PERSONAL_CONNECT_RUNTIME_SECRET_KEYS, + ) + : {}), + }, }); runtimeContainer.addPortMappings({ appProtocol: ecs.AppProtocol.http, diff --git a/deployment/aws/test/opentag-stack.test.ts b/deployment/aws/test/opentag-stack.test.ts index 3db4e38..ddfa61e 100644 --- a/deployment/aws/test/opentag-stack.test.ts +++ b/deployment/aws/test/opentag-stack.test.ts @@ -52,10 +52,24 @@ function expectedSecrets(keys: string[]): Record { return Object.fromEntries(keys.map((key) => [key, secretsManagerField(key)])); } -function environmentNames(template: Template, name: string): string[] { - return (containerDefinition(template, name).Environment ?? []) - .map(({ Name }) => Name) - .sort(); +/** + * A container's environment as name to value, so a comparison reads both. + * + * The names-only version this replaces passed with the CORS default flipped to + * a single origin, with `PLAYWRIGHT_BROWSERS_PATH` pointed at a directory the + * image does not have, and with the runtime `PORT` moved off the port its own + * health check probes. Every one of those is a container that boots into a + * different deployment than the one the file describes. + */ +function environmentValues( + template: Template, + name: string, +): Record { + return Object.fromEntries( + (containerDefinition(template, name).Environment ?? []).map( + ({ Name, Value }) => [Name, Value], + ), + ); } function stackWithContext( @@ -154,6 +168,29 @@ test("creates one private rolling environment service containing both containers }); }); +/** The fields the documented secret has carried since the first release. */ +const ESTABLISHED_AGENT_SECRETS = [ + "OPENAI_API_KEY", + // Presented by the runtime; checked by the agent. Both containers read the + // same field of the same secret or the runtime cannot reach the agent at + // all. Already documented as a required field before this container read it, + // so an existing secret carries it. + "AGENT_AUTH_HEADER", + "TAVILY_API_KEY", + "DAYTONA_API_KEY", + "GITHUB_PERSONAL_ACCESS_TOKEN", + "GITHUB_CODER_TOKEN", + "POSTHOG_PERSONAL_API_KEY", + "LINEAR_API_KEY", + "NOTION_MCP_AUTH_TOKEN", +]; + +const ESTABLISHED_RUNTIME_SECRETS = [ + "INTELLIGENCE_API_KEY", + // The other half of the pair above. + "AGENT_AUTH_HEADER", +]; + test("injects each container's secrets from the shared secret, and no others", () => { // Asserted as the whole set rather than one membership check at a time. The // suite already had a `assert.match(json, /OPENAI_API_KEY/)` style check, and @@ -163,65 +200,152 @@ test("injects each container's secrets from the shared secret, and no others", ( assert.deepEqual( secretsByName(template, "agent"), - expectedSecrets([ - "OPENAI_API_KEY", - // The agent owns the Composio session, so the key and the shared secret - // it checks both belong to this container and not the runtime. - "COMPOSIO_API_KEY", - "AGENT_AUTH_HEADER", - "TAVILY_API_KEY", - "DAYTONA_API_KEY", - "GITHUB_PERSONAL_ACCESS_TOKEN", - "GITHUB_CODER_TOKEN", - "POSTHOG_PERSONAL_API_KEY", - "LINEAR_API_KEY", - "NOTION_MCP_AUTH_TOKEN", - ]), + expectedSecrets(ESTABLISHED_AGENT_SECRETS), ); assert.deepEqual( secretsByName(template, "runtime"), + expectedSecrets(ESTABLISHED_RUNTIME_SECRETS), + ); +}); + +test("asks an upgrading deployment for no secret field it does not already have", () => { + // ECS resolves every declared secret field when the task starts and fails the + // task when one is missing — so a field added here unconditionally is not a + // deploy-time error an operator can read, it is an existing deployment that + // stops starting tasks after the upgrade. Nothing this release introduced may + // appear until the context that gives it a purpose is set. + const template = Template.fromStack(stackWithContext()); + const secrets = [ + ...Object.keys(secretsByName(template, "agent")), + ...Object.keys(secretsByName(template, "runtime")), + ]; + + assert.deepEqual( + secrets.filter((key) => key.startsWith("COMPOSIO_") || key.startsWith("SLACK_")), + [], + ); +}); + +test("adds the Composio key to the agent once a toolkit is configured", () => { + // The agent treats a key with no toolkits as unconfigured, so the toolkit + // lists are what decides whether the key has anything to do. Both lists, + // separately: either one on its own turns the integration on. + const contexts: Record[] = [ + { composioToolkits: "linear" }, + { composioUserToolkits: "gmail" }, + ]; + for (const context of contexts) { + const template = Template.fromStack(stackWithContext(context)); + + assert.deepEqual( + secretsByName(template, "agent"), + expectedSecrets([...ESTABLISHED_AGENT_SECRETS, "COMPOSIO_API_KEY"]), + `agent secrets with ${JSON.stringify(context)}`, + ); + } +}); + +test("adds the Slack tokens to the runtime only for personal toolkits", () => { + // The runtime holds these for exactly one reason: a personal connect link is + // a bearer capability and has to reach one named person, which the managed + // adapter cannot do. Shared toolkits are connected by an operator from the + // CLI and need no direct Slack at all. + const shared = Template.fromStack( + stackWithContext({ composioToolkits: "linear" }), + ); + assert.deepEqual( + secretsByName(shared, "runtime"), + expectedSecrets(ESTABLISHED_RUNTIME_SECRETS), + ); + + const personal = Template.fromStack( + stackWithContext({ composioUserToolkits: "gmail" }), + ); + assert.deepEqual( + secretsByName(personal, "runtime"), expectedSecrets([ - "INTELLIGENCE_API_KEY", - // Presented to the agent; the agent checks it. Both sides read the same - // field of the same secret or the runtime cannot reach the agent at all. - "AGENT_AUTH_HEADER", - // Only so a Composio connect link can reach one person privately. + ...ESTABLISHED_RUNTIME_SECRETS, "SLACK_BOT_TOKEN", "SLACK_APP_TOKEN", ]), ); }); +test("never puts a Composio credential on the internet-facing runtime", () => { + // The runtime is the service the platform reaches. The Composio key mints + // sessions against every connected account in the project, and nothing in + // the runtime reads it. + const template = Template.fromStack( + stackWithContext({ + composioToolkits: "linear", + composioUserToolkits: "gmail", + }), + ); + + assert.deepEqual( + Object.keys(secretsByName(template, "runtime")).filter((key) => + key.startsWith("COMPOSIO_"), + ), + [], + ); +}); + test("leaves optional settings out of the container until context supplies them", () => { - // The whole name list, because the failure this guards against is an - // `optionalEnvironment` that stops being optional: `COMPOSIO_APPROVALS=""` + // The whole map, name and value. Two separate failures are in scope here: an + // `optionalEnvironment` that stops being optional (`COMPOSIO_APPROVALS=""` // reaching the agent is not the same as it being absent, and every - // `arrayWith` assertion in this file is blind to a key that should not exist. + // `arrayWith` assertion in this file is blind to a key that should not + // exist), and a default quietly changing under a name that still looks + // right. const template = Template.fromStack(stackWithContext()); - assert.deepEqual(environmentNames(template, "agent"), [ - "AGENT_DISPLAY_NAME", - "CORS_ALLOW_ORIGINS", - "DAYTONA_TTL_MINUTES", - "GITHUB_MCP_URL", - "LINEAR_MCP_URL", - "OPENAI_MODEL", - "OPENAI_REASONING_EFFORT", - "OPENAI_VERBOSITY", - "POSTHOG_MCP_URL", - "SERVER_HOST", - "SERVER_PORT", - ]); - assert.deepEqual(environmentNames(template, "runtime"), [ - "AGENT_DISPLAY_NAME", - "AGENT_URL", - "INTELLIGENCE_API_URL", - "INTELLIGENCE_CHANNEL_NAME", - "INTELLIGENCE_GATEWAY_WS_URL", - "LOG_LEVEL", - "PLAYWRIGHT_BROWSERS_PATH", - "PORT", - ]); + assert.deepEqual(environmentValues(template, "agent"), { + AGENT_DISPLAY_NAME: "OpenTag", + // Wide open by default because the agent sits on a private subnet with no + // ingress; narrowing it is the operator's call, not a silent edit here. + CORS_ALLOW_ORIGINS: "*", + DAYTONA_TTL_MINUTES: "60", + GITHUB_MCP_URL: "https://api.githubcopilot.com/mcp/readonly", + // The agent derives the default Composio workspace user id from this, so + // an agent that never receives it runs the team's shared connections under + // the literal `open-tag` whatever the channel is really called. + INTELLIGENCE_CHANNEL_NAME: "open-tag", + LINEAR_MCP_URL: "https://mcp.linear.app/mcp", + OPENAI_MODEL: "gpt-5.5", + OPENAI_REASONING_EFFORT: "low", + OPENAI_VERBOSITY: "low", + POSTHOG_MCP_URL: "https://mcp.posthog.com/mcp?mode=cli&readonly=true", + SERVER_HOST: "0.0.0.0", + // The port the agent's own health check probes, and the port the runtime + // is told to reach it on. + SERVER_PORT: "8123", + }); + assert.deepEqual(environmentValues(template, "runtime"), { + AGENT_DISPLAY_NAME: "OpenTag", + AGENT_URL: "http://127.0.0.1:8123/", + INTELLIGENCE_API_URL: "https://api.intelligence.copilotkit.ai", + INTELLIGENCE_CHANNEL_NAME: "open-tag", + INTELLIGENCE_GATEWAY_WS_URL: "wss://realtime.intelligence.copilotkit.ai", + LOG_LEVEL: "warn", + // Where the runtime image installs Chromium. Point it elsewhere and the + // browser is missing at run time, not at build time. + PLAYWRIGHT_BROWSERS_PATH: "/ms-playwright", + // The port the runtime's own health check probes. + PORT: "3000", + }); +}); + +test("carries the configured channel name to both containers", () => { + const template = Template.fromStack(stackWithContext({ channelName: "kite" })); + + assert.equal( + environmentValues(template, "agent").INTELLIGENCE_CHANNEL_NAME, + "kite", + ); + assert.equal( + environmentValues(template, "runtime").INTELLIGENCE_CHANNEL_NAME, + "kite", + ); }); test("allows supported non-secret environment overrides through context", () => { From bd714ce29fa5e5d4745e666aaa8a2558778e67b6 Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 2 Sep 2026 20:55:30 +0200 Subject: [PATCH 20/23] test(connect): assert the notice where it is actually delivered MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two tests written in separate worktrees asserted a connect-click failure against a delivery path the merged code no longer uses. The code is right in both cases; the assertions were looking in the wrong place. app/channel.test.ts A click nobody can be attributed to has no user id to address an ephemeral message to — `postEphemeral("unknown", …)` addressed a user that does not exist, which is the defect being fixed. The notice now goes to the thread, so the assertion reads the thread and additionally pins that no ephemeral message was attempted. app/human-in-the-loop/__tests__/connect-account.test.tsx An incomplete deployment is answered by `handleConnectClick`'s configuration guard, which says what is wrong and who can fix it, rather than the card's own generic "could not start". That is the better message, so the test now asserts it — and adds a check the old assertion did not make: the notice must name no credential and no variable, because it is read by whoever pressed the button and `AGENT_URL` in a thread teaches them nothing. `fallbackToDM` is true here because a DM is scoped to the clicker exactly as an ephemeral message is; that is why the link path asks for it too. Mutation-checked rather than assumed: deleting the configuration notice turns the connect-account test red, and it was green against the stale assertion. agent/.gitignore `*.egg-info/` — the new `[build-system]` means `pnpm setup:dev` leaves an untracked `agent/opentag_agent.egg-info/` in every developer's tree. Verified on the merged tree, not per-branch: pnpm check-types clean vitest 375 passed, 28 files pytest 412 passed cdk (tsx --test) 18 passed Co-Authored-By: Claude Opus 5 (1M context) --- agent/.gitignore | 1 + app/channel.test.ts | 6 ++++- .../__tests__/connect-account.test.tsx | 22 ++++++++++++------- 3 files changed, 20 insertions(+), 9 deletions(-) diff --git a/agent/.gitignore b/agent/.gitignore index 83e4378..1f9d3af 100644 --- a/agent/.gitignore +++ b/agent/.gitignore @@ -3,3 +3,4 @@ __pycache__/ *.pyc .env /reports/ +*.egg-info/ diff --git a/app/channel.test.ts b/app/channel.test.ts index 5af4ee5..12f0beb 100644 --- a/app/channel.test.ts +++ b/app/channel.test.ts @@ -1233,9 +1233,13 @@ describe("createOpenTagChannel", () => { value: { toolkit: "gmail" }, }); - expect(JSON.stringify(secondAdapter.ephemeralPosts)).toMatch( + // The notice goes to the THREAD, not to an ephemeral message: with no + // identifiable clicker there is no user id to address one to, and the old + // `postEphemeral("unknown", …)` addressed a user that does not exist. + expect(JSON.stringify(secondAdapter.posted)).toMatch( /could not tell who clicked/i, ); + expect(secondAdapter.ephemeralPosts).toHaveLength(0); vi.unstubAllEnvs(); }); diff --git a/app/human-in-the-loop/__tests__/connect-account.test.tsx b/app/human-in-the-loop/__tests__/connect-account.test.tsx index 04bf1ce..998bff6 100644 --- a/app/human-in-the-loop/__tests__/connect-account.test.tsx +++ b/app/human-in-the-loop/__tests__/connect-account.test.tsx @@ -77,15 +77,21 @@ describe("ConnectAccount", () => { await press(ctx); + // The click is answered by `handleConnectClick`'s configuration guard, which + // says what is wrong and who can fix it rather than "could not start". expect(postEphemeral).toHaveBeenCalledTimes(1); - expect(JSON.stringify(postEphemeral.mock.calls[0])).toMatch( - /could not start/i, - ); - // Private, and never to a DM: a failed connect says nothing secret, but the - // path it shares with the link must not learn to follow people around. - expect(postEphemeral.mock.calls[0]![2]).toEqual({ fallbackToDM: false }); - expect(JSON.stringify(consoleError.mock.calls)).toContain( - "connect_account_click", + const notice = JSON.stringify(postEphemeral.mock.calls[0]); + expect(notice).toMatch(/not configured to connect accounts/i); + expect(notice).toMatch(/ask whoever runs it/i); + // The notice carries no credential and no variable name. A connect failure + // is read by whoever pressed the button, not by whoever operates the + // deployment, and `AGENT_URL` in a thread teaches nobody anything useful. + expect(notice).not.toMatch(/AGENT_URL|AGENT_AUTH_HEADER|INTELLIGENCE_API_KEY/); + // Private either way: DM fallback is scoped to the clicker exactly as an + // ephemeral message is, which is why the link path asks for it too. + expect(postEphemeral.mock.calls[0]![2]).toEqual({ fallbackToDM: true }); + expect(JSON.stringify(consoleError.mock.calls)).toMatch( + /could not read this deployment's configuration/i, ); vi.unstubAllEnvs(); consoleError.mockRestore(); From b1398176db72707139e2f2a2366a89f3fffd146e Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 2 Sep 2026 20:57:24 +0200 Subject: [PATCH 21/23] fix(approvals): tell the card what an intercepted MCP write actually does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The confirmation card is about to fail safe: it renders destructive styling unless the interrupt payload's `effect` says `read` or `write`. This module is the other producer of that card — the MCP interceptor that already gates every Linear and Notion write — and it sent no `effect` at all, so every one of its cards would have arrived looking like a delete. It already knew better. `register_tools` reads each tool's `readOnlyHint` annotation and then threw everything but "is this a read" away. It now keeps the whole answer, in the same three-word vocabulary the Composio path uses: * `readOnlyHint: True` -> `read`, which is the reason there is no card rather than a value any card renders — the gate returns before one exists. Verified, not assumed: `test_a_read_only_tool_produces_no_card_at_all`. * `destructiveHint: True` -> `destructive`. * `readOnlyHint: False` -> `write`. This is the caller `classify.WRITE`'s docstring says it exists for: tags cannot express a write that is not destructive, but MCP annotations can, because denying read-only is an assertion and not a silence. * anything else -> unclassified, which is `destructive`. Not neutral. A tool nobody annotated is exactly the one that must not look calm. Hints are read by value, via `classify.effect_of`. `{"readOnlyHint": False}` is a tool saying it is *not* a read; treating the key's presence as the claim would have called it one, and would have skipped the gate entirely. `require_write_confirmation` now always puts an `effect` on the card, defaults it to `destructive`, and coerces anything outside the vocabulary to the same — so no card leaves here whose styling depends on the reader guessing, and no caller can widen the vocabulary by accident. The Composio path spells its classification as an `extra_args` entry; that spelling is popped into the same slot rather than merged beside a contradicting default. Symbols, and every call site of each: * `require_write_confirmation` — signature gains keyword-only `effect`, with a fail-safe default so no existing caller breaks. Callers: `write_confirmation.WriteConfirmationInterceptor.__call__` (now passes the classification), `composio_tools/tools.py:281` (already passes `effect` through `extra_args`; that spelling still lands in the card), and `coding/repository_tools.py:395` (passes none, so its push/PR card is now explicitly `destructive` — the same styling the failing-safe card would give it anyway). Patched by name in `tests/test_repository_tools.py` and `tests/test_composio_tools.py`; both keep working, as the full suite shows. * `WriteConfirmationInterceptor._read_only_tools` — removed. It was private and had exactly three call sites, all in this file (`__init__`, `register_tools`, `__call__`), all replaced by `_effects`. No reference remains in the repo. * `WriteConfirmationInterceptor._effects` (new), `_effect_for` (new), `_tool_effect` (new), `_card_effect` (new), `_CARD_EFFECTS` (new) — used only within this module and its tests. * `register_tools` — same signature, same two callers (`internal_sources.py:169`, and the tests). A tool already established as a read stays one, so the seeded Notion searches cannot be re-gated by their own annotations. Red-green: the 13 new assertions were watched failing first (`KeyError: 'effect'`, `TypeError: ... unexpected keyword argument 'effect'`). Each new test was then mutation-checked against nine mutations of the fix — including reading the hint by key presence, defaulting the unclassified to `write`, and dropping the `read` short-circuit — and every one of them goes red for at least one. Co-Authored-By: Claude Opus 5 (1M context) --- agent/tests/test_write_confirmation.py | 203 +++++++++++++++++++++++++ agent/write_confirmation.py | 108 ++++++++++++- 2 files changed, 304 insertions(+), 7 deletions(-) diff --git a/agent/tests/test_write_confirmation.py b/agent/tests/test_write_confirmation.py index 368ba88..d365d01 100644 --- a/agent/tests/test_write_confirmation.py +++ b/agent/tests/test_write_confirmation.py @@ -146,6 +146,9 @@ async def handler(request): "args": { "action": "Create issue", "fields": [{"label": "Title", "value": "Checkout 500s"}], + # Nobody annotated `create_issue`, so the card is told to render it + # as dangerous rather than left to guess from the verb. + "effect": "destructive", }, } @@ -226,6 +229,7 @@ async def handler(request): "args": { "action": "Create issue", "fields": [{"label": "Title", "value": "Checkout 500s"}], + "effect": "destructive", }, } ] @@ -569,3 +573,202 @@ def test_require_write_confirmation_rejects_a_bad_resume(monkeypatch): action="Open draft pull request", fields=[], ) + + +def read_tool(name, **metadata): + """An MCP-shaped tool carrying exactly the annotations a server sent.""" + + async def run(**kwargs): + return kwargs + + return StructuredTool.from_function( + coroutine=run, + name=name, + description=name, + metadata=dict(metadata), + ) + + +def card_for(monkeypatch, request, tools=()): + """The interrupt args of the card the interceptor raises for `request`.""" + cards = approve_and_track(monkeypatch) + interceptor = write_confirmation.WriteConfirmationInterceptor() + if tools: + interceptor.register_tools(list(tools)) + + async def handler(_request): + return "write-result" + + asyncio.run(interceptor(request, handler)) + return cards + + +def capture_card(monkeypatch): + """Record the args of every card `require_write_confirmation` raises.""" + cards = [] + + def approve(**kwargs): + cards.append(kwargs["args"]) + return '{"confirmed": true}', {"confirmed": True} + + monkeypatch.setattr(write_confirmation, "copilotkit_interrupt", approve) + return cards + + +def test_a_card_for_an_unregistered_tool_says_destructive(monkeypatch): + cards = card_for(monkeypatch, save_project(name="OpenTag")) + + assert cards[0]["effect"] == "destructive" + + +def test_a_tool_that_declares_it_is_not_read_only_gets_a_write_card(monkeypatch): + # `readOnlyHint: False` is a tool asserting it is *not* a read. Reading the + # key's presence instead of its value would call this unclassified. + cards = card_for( + monkeypatch, + save_project(name="OpenTag"), + tools=[read_tool("save_project", readOnlyHint=False)], + ) + + assert cards[0]["effect"] == "write" + + +def test_a_tool_that_declares_itself_destructive_gets_a_destructive_card( + monkeypatch, +): + cards = card_for( + monkeypatch, + save_project(name="OpenTag"), + tools=[ + read_tool("save_project", readOnlyHint=False, destructiveHint=True) + ], + ) + + assert cards[0]["effect"] == "destructive" + + +def test_a_tool_whose_annotations_say_nothing_gets_a_destructive_card( + monkeypatch, +): + cards = card_for( + monkeypatch, + save_project(name="OpenTag"), + tools=[read_tool("save_project", title="Save project")], + ) + + assert cards[0]["effect"] == "destructive" + + +def test_a_non_boolean_read_only_hint_is_not_an_assertion(monkeypatch): + # MCP hints are booleans. A string is a shape nobody meant to send, and it + # must not be able to talk the card down to a calmer styling. + cards = card_for( + monkeypatch, + save_project(name="OpenTag"), + tools=[read_tool("save_project", readOnlyHint="false")], + ) + + assert cards[0]["effect"] == "destructive" + + +def test_a_read_only_tool_produces_no_card_at_all(monkeypatch): + cards = approve_and_track(monkeypatch) + interceptor = write_confirmation.WriteConfirmationInterceptor() + interceptor.register_tools([read_tool("get_issue", readOnlyHint=True)]) + handled = [] + + async def handler(request): + handled.append(request) + return "read-result" + + request = MCPToolCallRequest( + name="get_issue", args={"issue_id": "CPK-9"}, server_name="linear" + ) + result = asyncio.run(interceptor(request, handler)) + + # The gate returns before any card exists, so `read` is never a value the + # card has to render — it is the reason there is no card. + assert result == "read-result" + assert handled == [request] + assert cards == [] + + +def test_a_known_read_only_notion_search_stays_a_read(monkeypatch): + # These POST endpoints are read-only despite what their own annotations + # look like, so a later registration must not gate them. + cards = approve_and_track(monkeypatch) + interceptor = write_confirmation.WriteConfirmationInterceptor() + interceptor.register_tools([read_tool("API-post-search", readOnlyHint=False)]) + + async def handler(_request): + return "read-result" + + result = asyncio.run( + interceptor( + MCPToolCallRequest( + name="API-post-search", args={"query": "x"}, server_name="notion" + ), + handler, + ) + ) + + assert result == "read-result" + assert cards == [] + + +def test_require_write_confirmation_defaults_to_a_destructive_card(monkeypatch): + cards = capture_card(monkeypatch) + + write_confirmation.require_write_confirmation( + action="Open draft pull request", fields=[] + ) + + assert cards[0]["effect"] == "destructive" + + +def test_require_write_confirmation_carries_a_classified_effect(monkeypatch): + cards = capture_card(monkeypatch) + + write_confirmation.require_write_confirmation( + action="Save project", fields=[], effect="write" + ) + + assert cards[0]["effect"] == "write" + + +@pytest.mark.parametrize("effect", ["mostly harmless", "", None, "READ", 1]) +def test_require_write_confirmation_fails_safe_on_an_unknown_effect( + monkeypatch, effect +): + cards = capture_card(monkeypatch) + + write_confirmation.require_write_confirmation( + action="Save project", fields=[], effect=effect + ) + + assert cards[0]["effect"] == "destructive" + + +def test_an_effect_from_extra_args_lands_in_the_card_once(monkeypatch): + # How the Composio path spells it. It must land in the same slot rather + # than beside a default that contradicts it. + cards = capture_card(monkeypatch) + + write_confirmation.require_write_confirmation( + action="Gmail send email", + fields=[], + extra_args={"approver": "U1", "effect": "read"}, + ) + + assert cards[0]["effect"] == "read" + assert cards[0]["approver"] == "U1" + + +def test_an_unclassified_effect_from_extra_args_is_destructive(monkeypatch): + cards = capture_card(monkeypatch) + + write_confirmation.require_write_confirmation( + action="Gmail send email", fields=[], extra_args={"effect": None} + ) + + assert cards[0]["effect"] == "destructive" diff --git a/agent/write_confirmation.py b/agent/write_confirmation.py index b8a3fea..b1a1937 100644 --- a/agent/write_confirmation.py +++ b/agent/write_confirmation.py @@ -7,6 +7,13 @@ from collections import OrderedDict from concurrent.futures import ThreadPoolExecutor +from composio_tools.classify import ( + DESTRUCTIVE, + READ, + READ_ONLY_HINT, + WRITE, + effect_of, +) from copilotkit.langgraph import copilotkit_emit_message, copilotkit_interrupt from langchain_core.messages import ToolMessage from langchain_core.runnables.config import ensure_config @@ -27,6 +34,11 @@ # Longest failure text carried into the thread and onto the next card. _MAX_ERROR = 240 +# Everything the confirmation card understands. It renders danger for anything +# else, including a missing value, so a card leaves here carrying one of these +# three words and never a fourth. +_CARD_EFFECTS = frozenset({READ, WRITE, DESTRUCTIVE}) + # How many (thread, tool) failures are remembered at once. The interceptor # outlives every conversation, so this memory is bounded rather than unbounded. _MAX_TRACKED_FAILURES = 64 @@ -165,19 +177,76 @@ def parse_confirm_write_response(response) -> bool: return response["confirmed"] is True +def _card_effect(value) -> str: + """One of the three words the card knows, erring towards the dangerous one. + + The card renders destructive styling unless something positively said + otherwise, so a value it cannot read is not a neutral card — it is a + dangerous-looking one. Saying `destructive` here rather than passing an + unreadable value on keeps the payload honest about which of the two it is, + and means no caller can quietly widen the vocabulary. + """ + # `isinstance` first because an unhashable value must answer `destructive` + # rather than raise: a malformed classification cannot be what stops a + # confirmation from being asked for. + if isinstance(value, str) and value in _CARD_EFFECTS: + return value + return DESTRUCTIVE + + +def _tool_effect(metadata) -> str | None: + """What an MCP tool's annotations say it does, or `None` when they don't. + + `effect_of` answers for the two hints that speak for themselves, reading + them by value: `{"readOnlyHint": False}` is a tool asserting it is **not** + a read, and the key being present claims nothing on its own. + + The third reading is this caller's alone, and it is the one `classify.WRITE` + exists for. Tags cannot express a write that is not destructive, but MCP + annotations can: a tool that denied being read-only has said more than + "unclassified" — it has said it changes something. Anything else is + unclassified, and `None` here is not a safe answer, it is no answer. + """ + metadata = metadata or {} + claimed = effect_of(metadata) + if claimed is not None: + return claimed + try: + denied_read_only = metadata.get(READ_ONLY_HINT) is False + except AttributeError: + # Not a mapping. Same answer as no annotations: nothing was claimed. + return None + return WRITE if denied_read_only else None + + def require_write_confirmation( *, action: str, fields: list[dict], + effect: str = DESTRUCTIVE, extra_args: dict | None = None, ) -> bool: - """Pause on the existing confirm_write card. Return True if approved.""" + """Pause on the existing confirm_write card. Return True if approved. + + `effect` is what the caller classified the action as, and it is the card's + only defence against styling a delete like a rename. It defaults to + `destructive` rather than to nothing: a caller that did not classify has + not established that the action is safe, and the card would fail safe + anyway — saying so here makes every card this module produces carry the + answer instead of relying on the reader to fail safe. + """ + extra = dict(extra_args or {}) + # The Composio path spells its classification as an `extra_args` entry. + # Popping it means the two spellings land in one slot rather than side by + # side, where whichever the dict merged last would silently win. + classified = extra.pop("effect", effect) _answer, response = copilotkit_interrupt( action="confirm_write", args={ "action": action, "fields": fields, - **(extra_args or {}), + "effect": _card_effect(classified), + **extra, }, ) return parse_confirm_write_response(response) @@ -243,7 +312,13 @@ class WriteConfirmationInterceptor: } def __init__(self): - self._read_only_tools = set(self._KNOWN_READ_ONLY_TOOLS) + # Tool name -> what it does, in the card's vocabulary. A name missing + # from here is one this interceptor could not classify, which is not + # the same as a harmless one: `_effect_for` answers `destructive`, so + # an unannotated tool is both gated and shown as dangerous. + self._effects: dict[str, str] = dict.fromkeys( + self._KNOWN_READ_ONLY_TOOLS, READ + ) # (thread id, tool name) -> (attempts so far, last failure text). self._failures: OrderedDict[tuple[str, str], tuple[int, str]] = ( OrderedDict() @@ -251,9 +326,23 @@ def __init__(self): def register_tools(self, tools: list[BaseTool]) -> None: for source_tool in tools: - metadata = source_tool.metadata or {} - if metadata.get("readOnlyHint") is True: - self._read_only_tools.add(source_tool.name) + if self._effects.get(source_tool.name) == READ: + # Already established as a read, and it stays one. The seeded + # Notion searches are here precisely because their own + # annotations are not what got them classified. + continue + effect = _tool_effect(source_tool.metadata) + if effect is not None: + self._effects[source_tool.name] = effect + + def _effect_for(self, name: str) -> str: + """What the card should say this tool does. + + Unclassified is `destructive`, never neutral. A tool nobody annotated + is exactly the case that must not look calm, and it is also the case + the gate below refuses to let through unasked. + """ + return self._effects.get(name, DESTRUCTIVE) def _remember_failure(self, key, error: str) -> None: if key is None: @@ -282,7 +371,11 @@ async def __call__( request: MCPToolCallRequest, handler, ) -> MCPToolCallResult: - if request.name in self._read_only_tools: + effect = self._effect_for(request.name) + if effect == READ: + # The only effect that never reaches a card: a read is not gated, + # so `read` is the reason there is no card rather than a value one + # ever renders. return await handler(request) action = request.name.replace("_", " ").replace("-", " ").strip() @@ -292,6 +385,7 @@ async def __call__( confirmed = require_write_confirmation( action=action, fields=summarize_args(request.args), + effect=effect, extra_args=self._retry_args(key), ) From 07d4548b0e7881cca0d3f69c7772a5921195f300 Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 2 Sep 2026 21:01:48 +0200 Subject: [PATCH 22/23] fix(composio): pin the operator's auth config on the sessions that run calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three loose ends from the parallel fix round, grouped because each one is a place where a decision was made in one file and not carried to the file beside it. agent/composio_tools/sessions.py `COMPOSIO_AUTH_CONFIGS` exists to settle which credential a shared toolkit connects against when it has several. The connect script pins it; the runtime did not. So a toolkit could be *connected* through the auth config an operator named and then *used* through whichever one the project resolves on its own — the ambiguity the setting exists to remove, half-removed. `_pinned_auth_configs` narrows the mapping to the scope's own toolkits, so a session is never told about a config for a toolkit it does not carry, and sends `None` rather than `{}` when nothing is pinned because the SDK forwards the argument only when it is not None. Red-green, then mutation-checked: deleting the `auth_configs` argument turns both new tests red and nothing else. agent/coding/repository_tools.py The push/pull-request card now says `effect="write"`. The card assumes the worst of anything that does not say, which is right, but pushing a branch and opening a PR adds things and destroys none. A card that cries danger over every ordinary write teaches people to approve red ones. deployment/aws/README.md The upgrade note still said the three new secret fields were mandatory. They are not: the stack now declares each one only when the context that gives it a purpose is set, so an existing secret starts untouched and the fields are added when the feature is enabled. Also documents the four `composio*` context keys, which had none, and records that `COMPOSIO_AUTH_CONFIGS` has no AWS context key at all while Railway can set it. Call sites: `_pinned_auth_configs` is private to `SessionCache` and called once, at the `sessions.create` in `for_scope`. `require_write_confirmation` already took `effect`; this adds a second caller passing it. Verified on the merged tree: pnpm check-types clean vitest 375 passed, 28 files pytest 430 passed cdk (tsx --test) 18 passed Co-Authored-By: Claude Opus 5 (1M context) --- agent/coding/repository_tools.py | 5 +++++ agent/composio_tools/sessions.py | 20 +++++++++++++++++ agent/tests/test_composio_sessions.py | 32 +++++++++++++++++++++++++++ deployment/aws/README.md | 31 +++++++++++++++++++++----- 4 files changed, 83 insertions(+), 5 deletions(-) diff --git a/agent/coding/repository_tools.py b/agent/coding/repository_tools.py index f871c58..e8418c7 100644 --- a/agent/coding/repository_tools.py +++ b/agent/coding/repository_tools.py @@ -395,6 +395,11 @@ def publish_changes( confirmed = require_write_confirmation( action="Push branch and publish pull request", fields=fields, + # Said, because the card now assumes the worst of anything that + # does not say. Pushing a branch and opening a pull request adds + # things and destroys none, and a card that cries danger over + # every ordinary write teaches people to approve red ones. + effect="write", ) if not confirmed: return ( diff --git a/agent/composio_tools/sessions.py b/agent/composio_tools/sessions.py index 75cf11a..1850d17 100644 --- a/agent/composio_tools/sessions.py +++ b/agent/composio_tools/sessions.py @@ -103,6 +103,17 @@ def client(self) -> Any: def _key(self, scope: ResolvedScope) -> tuple[str, tuple[str, ...]]: return (scope.user_id, scope.toolkits) + def _pinned_auth_configs(self, scope: ResolvedScope) -> dict[str, str]: + """The operator's auth-config choices that apply to this scope. + + Keyed by toolkit, and narrowed to the scope's own toolkits so a session + is never told about a pin for a toolkit it does not carry. + """ + pinned = self._config.auth_configs + return { + toolkit: pinned[toolkit] for toolkit in scope.toolkits if toolkit in pinned + } + def invalidate(self, scope: ResolvedScope) -> None: """Forget one scope's session so the next use builds a fresh one. @@ -147,6 +158,15 @@ def for_scope(self, scope: ResolvedScope) -> ScopedSession: # needs it: `authorize()` mints links over the session's own # REST endpoint and does not read this flag. manage_connections=False, + # The same pinning the connect script applies, applied to the + # sessions that actually run the calls. Without it a toolkit + # could be *connected* through the auth config an operator + # named and then *used* through whichever one the project + # resolves on its own — the exact ambiguity + # `COMPOSIO_AUTH_CONFIGS` exists to settle, half-settled. + # `None` rather than `{}` when nothing is pinned: the SDK + # forwards the argument only when it is not None. + auth_configs=self._pinned_auth_configs(scope) or None, ) self._sessions[key] = session while len(self._sessions) > MAX_SESSIONS: diff --git a/agent/tests/test_composio_sessions.py b/agent/tests/test_composio_sessions.py index d0be822..dc13367 100644 --- a/agent/tests/test_composio_sessions.py +++ b/agent/tests/test_composio_sessions.py @@ -62,6 +62,38 @@ def test_a_session_disables_the_sandbox_explicitly(): assert "workbench" not in client.sessions.calls[0] +def test_a_session_pins_the_auth_config_the_operator_named(): + # `COMPOSIO_AUTH_CONFIGS` exists to settle which credential a shared toolkit + # connects against when it has several. The connect script pinned it and the + # runtime did not, so a toolkit could be *connected* through the named + # config and then *used* through whichever one the project resolved on its + # own — the ambiguity, half-settled. + client = FakeComposio() + cfg = ComposioConfig( + api_key="ak_test", + workspace_toolkits=("linear", "notion"), + user_toolkits=(), + approvals="on", + workspace_user_id="open-tag", + auth_configs={"linear": "ac_ExAmPle1"}, + ) + + SessionCache(cfg, client=client).for_scope(scope("open-tag", "linear", "notion")) + + # Narrowed to the scope's own toolkits, and only the pinned one appears: + # a session is never told about a config for a toolkit it does not carry. + assert client.sessions.calls[0]["auth_configs"] == {"linear": "ac_ExAmPle1"} + + +def test_a_session_with_nothing_pinned_sends_no_auth_configs(): + # `None`, not `{}` — the SDK forwards the argument only when it is not None, + # and an empty mapping is a different thing to say than "no preference". + client = FakeComposio() + SessionCache(config(), client=client).for_scope(scope("open-tag", "linear")) + + assert client.sessions.calls[0]["auth_configs"] is None + + def test_a_session_is_created_once_per_identity_and_toolkit_set(): client = FakeComposio() cache = SessionCache(config(), client=client) diff --git a/deployment/aws/README.md b/deployment/aws/README.md index f1c6dd5..9267603 100644 --- a/deployment/aws/README.md +++ b/deployment/aws/README.md @@ -83,11 +83,32 @@ Only `INTELLIGENCE_API_KEY` and `OPENAI_API_KEY` are required by the standard deployment. Every JSON field must exist because ECS resolves each one when the task starts; use an empty string for an unused integration. -**Upgrading an existing deployment: add the three new fields to the secret -before you deploy.** `COMPOSIO_API_KEY`, `SLACK_BOT_TOKEN`, and `SLACK_APP_TOKEN` -are new in this release. A task whose secret is missing any of them fails to -start with `does not contain the specified JSON key` and the deployment rolls -back — empty strings are enough, and they leave every feature off. +**Upgrading an existing deployment: nothing to add unless you are turning +Composio on.** `COMPOSIO_API_KEY`, `SLACK_BOT_TOKEN` and `SLACK_APP_TOKEN` are +new in this release, and the stack declares each one only when the context that +gives it a purpose is set — `COMPOSIO_API_KEY` when either toolkit list is +non-empty, the Slack pair when `composioUserToolkits` is. A deployment that does +not set those contexts never asks ECS for the fields, so an existing secret +still starts. + +Add them **when you enable the feature**, in the same change that sets the +context. ECS resolves every declared field at task start, so a secret missing a +field the stack now declares fails with `does not contain the specified JSON +key` and the deployment rolls back. + +### Composio context keys + +Set these with `-c` at deploy time, or in `cdk.json`: + +| Key | Effect | +|---|---| +| `composioToolkits` | Toolkit slugs everyone shares one connection for. Setting either list makes the stack declare `COMPOSIO_API_KEY`. | +| `composioUserToolkits` | Toolkit slugs scoped to whoever sent the message. Setting it also makes the stack declare `SLACK_BOT_TOKEN` and `SLACK_APP_TOKEN`, which the personal connect link needs. | +| `composioApprovals` | `on` (default) or `off`. `destructive` and `writes` are the old spellings and still parse as `on`. | +| `composioWorkspaceUserId` | The Composio user id shared toolkits act as. Set it explicitly: it otherwise defaults to the Channel name, and renaming the Channel would move every shared connection. | + +`COMPOSIO_AUTH_CONFIGS` has no context key yet, so an AWS deployment cannot pin +which auth config a shared toolkit connects against. Railway can. Create a second Secrets Manager secret for Datadog. Its entire plaintext value must be the raw Datadog API key, not JSON. From 4310a9282babb83b76113d79f8818a8372aa553b Mon Sep 17 00:00:00 2001 From: Maxim Date: Wed, 2 Sep 2026 21:31:56 +0200 Subject: [PATCH 23/23] chore(agent): ignore the wheel build output on purpose `agent/dist/` was already ignored, but only because `uv build` writes a `.gitignore` containing `*` inside the directory it creates. No repo rule mentioned it. That works right up until a tool writes there without leaving its own ignore file behind. Now that `[build-system]` exists the project is actually built, so `uv build` and `uv sync` both produce output here as a matter of course. Co-Authored-By: Claude Opus 5 (1M context) --- agent/.gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/agent/.gitignore b/agent/.gitignore index 1f9d3af..4af28ef 100644 --- a/agent/.gitignore +++ b/agent/.gitignore @@ -4,3 +4,4 @@ __pycache__/ .env /reports/ *.egg-info/ +dist/