diff --git a/.env.example b/.env.example index a93ba82..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. @@ -61,3 +60,48 @@ 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. 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 +# 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 agent's own +# INTELLIGENCE_CHANNEL_NAME, and to "open-tag" when that is unset there. +# export COMPOSIO_WORKSPACE_USER_ID=open-tag +# +# 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. +# 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) -- +# 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/.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/.railway/railway.ts b/.railway/railway.ts index a71abfa..98be2ca 100644 --- a/.railway/railway.ts +++ b/.railway/railway.ts @@ -41,6 +41,22 @@ 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(), + // 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", }, }); @@ -71,6 +87,12 @@ 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. + 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/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 b9485b4..a3ee259 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 team accounts) ``` | You run | CopilotKit Intelligence manages | @@ -329,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 @@ -361,6 +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 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/agent/.gitignore b/agent/.gitignore index 83e4378..4af28ef 100644 --- a/agent/.gitignore +++ b/agent/.gitignore @@ -3,3 +3,5 @@ __pycache__/ *.pyc .env /reports/ +*.egg-info/ +dist/ diff --git a/agent/agent.py b/agent/agent.py index d8fd087..c5199da 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,9 @@ 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.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 from prompts import ( BASE_SYSTEM_PROMPT, @@ -39,6 +43,8 @@ ) from tools import web_search +logger = logging.getLogger(__name__) + load_dotenv(Path(__file__).resolve().parent.parent / ".env") @@ -174,10 +180,22 @@ def build_agent(): internal_tools = [ tool for tools in source_toolsets.values() for tool in tools ] + # 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 is None + else build_composio_tools(composio.config, composio.cache, composio.effects) + ) + 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 +226,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 +252,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 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/agent_auth.py b/agent/agent_auth.py new file mode 100644 index 0000000..c2a9795 --- /dev/null +++ b/agent/agent_auth.py @@ -0,0 +1,108 @@ +"""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. 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 + 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. + + 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(_comparable(presented.strip()), _comparable(expected)) + + +def is_authorized( + path: str, + presented: str | None, + env: Mapping[str, str] | None = None, +) -> bool: + """Whether ordinary traffic for `path` may proceed.""" + if _public_path(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/agui.py b/agent/agui.py index c2ff184..1997059 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,13 +27,62 @@ ) +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 + 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/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/__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..8b14980 --- /dev/null +++ b/agent/composio_tools/classify.py @@ -0,0 +1,91 @@ +"""Effect classification from Composio's MCP behaviour tags. + +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 Mapping +from typing import Any + +READ = "read" +#: 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" + +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. + + `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 READ_ONLY_HINT in claimed: + return READ + return None + + +def needs_approval(effect: str, mode: str) -> bool: + """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 + return effect != READ diff --git a/agent/composio_tools/config.py b/agent/composio_tools/config.py new file mode 100644 index 0000000..10eaa01 --- /dev/null +++ b/agent/composio_tools/config.py @@ -0,0 +1,165 @@ +"""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 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 +#: 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): + """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()` 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]: + 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. + + `destructive` and `writes` are folded to `on`; see + `DEPRECATED_APPROVAL_MODES` for why they could never have differed. + """ + 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 ' + + ", ".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". + # + # 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( + 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.strip() + or DEFAULT_WORKSPACE_USER_ID + ), + auth_configs=_auth_config_map(_value(source, "COMPOSIO_AUTH_CONFIGS")), + ) 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/connect_cli.py b/agent/composio_tools/connect_cli.py new file mode 100644 index 0000000..f6f3060 --- /dev/null +++ b/agent/composio_tools/connect_cli.py @@ -0,0 +1,163 @@ +"""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 + +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 + +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 ( + 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 +) -> 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 = operator_environment(env, env_file=ENV_FILE) + + config = read_composio_config( + source, + default_user_id=source.get( + "INTELLIGENCE_CHANNEL_NAME", DEFAULT_WORKSPACE_USER_ID + ), + ) + 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 + + # 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) + # 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 " + f"exists at {DASHBOARD_URL}.", + file=sys.stderr, + ) + 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.{pinned_note}' + ) + return 0 + + +if __name__ == "__main__": # pragma: no cover - entry point + raise SystemExit(main()) diff --git a/agent/composio_tools/effects.py b/agent/composio_tools/effects.py new file mode 100644 index 0000000..55f8ed4 --- /dev/null +++ b/agent/composio_tools/effects.py @@ -0,0 +1,86 @@ +"""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 *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: + return cached + + try: + 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", + 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 + + 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/runtime.py b/agent/composio_tools/runtime.py new file mode 100644 index 0000000..480e90d --- /dev/null +++ b/agent/composio_tools/runtime.py @@ -0,0 +1,92 @@ +"""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 typing import Any + +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 +#: 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) +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, _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, _built_from + _runtime = None + _built = False + _built_from = None 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..1850d17 --- /dev/null +++ b/agent/composio_tools/sessions.py @@ -0,0 +1,216 @@ +"""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 collections import OrderedDict +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__) + +#: 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.""" + + 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 + + +@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. + + 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: 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. + + 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 + + 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. + + 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 = 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, + 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}, + # 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, + # 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: + self._sessions.popitem(last=False) + return ScopedSession(session=session, scope=scope) + + def resolve(self, scopes: tuple[ResolvedScope, ...]) -> ResolvedSessions: + """ + 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 + 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. + + 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 — " + "running the turn without it: %s", + scope.user_id, + ",".join(scope.toolkits), + error, + ) + 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 new file mode 100644 index 0000000..9793ab9 --- /dev/null +++ b/agent/composio_tools/state.py @@ -0,0 +1,211 @@ +"""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 `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 + +#: 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: + """ + `(platform, id)` when this value names somebody, else `None`. + + 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. + """ + if not isinstance(actor, Mapping): + return None + + identifier = actor.get("id") + 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 personal_actor(state.get("channel_actor")) + + +def actor_key(actor: Mapping[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. + + 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. + """ + named = _named_identity(actor) + if named is None: + return None + 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. 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: + actor = personal_actor(forwarded_props.get(key)) + if actor is not None: + return actor + 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.""" + + 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..cdc547f --- /dev/null +++ b/agent/composio_tools/tools.py @@ -0,0 +1,532 @@ +"""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.classify import needs_approval +from composio_tools.config import ComposioConfig +from composio_tools.effects import EffectMap +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 ( + emit_write_failure, + require_write_confirmation, + summarize_args, +) + +logger = logging.getLogger(__name__) + +#: How many candidates the model sees. Tunable; not a principle. +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 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): + return [_plain(item) for item in value] + return value + + +def _as_list(value: Any) -> list[Any]: + plain = _plain(value) + return plain if isinstance(plain, list) else [] + + +def _as_dict(value: Any) -> dict[str, Any]: + plain = _plain(value) + return plain if isinstance(plain, dict) else {} + + +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. + + 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 _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. + + 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 humanize_slug(slug: str) -> str: + """`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("_") + if not rest: + return toolkit.capitalize() + words = rest.replace("_", " ").lower() + return f"{words[:1].upper()}{words[1:]} ({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) -> 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 + # identity — and therefore each other's connected accounts. + 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 + 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'. + """ + resolved = sessions_for(state) + scopes = resolved.sessions + if not scopes: + 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 + # — 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)) + + 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) + + 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 + ] + 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( + 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. + """ + resolved = sessions_for(state) + scopes = resolved.sessions + if not scopes: + 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) + # 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. + # + # 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=label, + 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"{label} was declined, so nothing ran." + + def failed(reason: Any, *, log_id: Any = None) -> str: + """One failure, told to everyone who is waiting on it. + + 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, + ) + 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 + + # 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/main.py b/agent/main.py index 3eeba37..740a8de 100644 --- a/agent/main.py +++ b/agent/main.py @@ -5,11 +5,18 @@ 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.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 app = FastAPI( title="OpenTag Agent", @@ -17,6 +24,38 @@ version="0.1.0", ) +# 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") +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. + + `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) + + # Allow all origins locally, or set CORS_ALLOW_ORIGINS to restrict access. _cors_origins = [ o.strip() @@ -32,12 +71,77 @@ ) -@app.get("/health") +# 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"} +class ConnectRequest(BaseModel): + """One person, one app. No link comes in; exactly one goes out.""" + + 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") +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( + # 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( + {"error": "Composio is not configured on this deployment."}, + status_code=503, + ) + + 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) + + 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 85d4362..3eda9b2 100644 --- a/agent/pyproject.toml +++ b/agent/pyproject.toml @@ -5,6 +5,11 @@ description = "OpenTag general-purpose team knowledge-work agent — CopilotKit requires-python = ">=3.12" dependencies = [ "ag-ui-langgraph>=0.0.23", + # 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", @@ -16,17 +21,21 @@ 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] dev = ["pytest>=8.0.0"] [tool.setuptools] -packages = ["prompts", "coding"] +packages = ["prompts", "coding", "composio_tools"] py-modules = [ "agent", + "agent_auth", "agui", "internal_sources", "main", @@ -39,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_agent_auth.py b/agent/tests/test_agent_auth.py new file mode 100644 index 0000000..e8a7bc4 --- /dev/null +++ b/agent/tests/test_agent_auth.py @@ -0,0 +1,238 @@ +"""The shared secret between the runtime and this agent.""" + +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 + + +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_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 + # 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 + + +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_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 + # 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 + + +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_approval_resume.py b/agent/tests/test_composio_approval_resume.py new file mode 100644 index 0000000..4fb745b --- /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("slack:U1") + shared = RecordingSession("open-tag") + config = ComposioConfig( + api_key="ak_test", + workspace_toolkits=("linear",), + user_toolkits=("gmail",), + approvals="on", + workspace_user_id="open-tag", + ) + cache = SessionCache( + config, client=RecordingComposio({"slack: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("slack:U1") + config = ComposioConfig( + api_key="ak_test", + workspace_toolkits=(), + user_toolkits=("gmail",), + approvals="on", + workspace_user_id="open-tag", + ) + cache = SessionCache(config, client=RecordingComposio({"slack: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_classify.py b/agent/tests/test_composio_classify.py new file mode 100644 index 0000000..95125a7 --- /dev/null +++ b/agent/tests/test_composio_classify.py @@ -0,0 +1,172 @@ +"""Effect classification and the approval decision it feeds.""" + +from __future__ import annotations + +import pytest + +from composio_tools.classify import effect_of, needs_approval +from composio_tools.effects import EffectMap + + +@pytest.mark.parametrize( + ("tags", "expected"), + [ + (["readOnlyHint"], "read"), + (["destructiveHint"], "destructive"), + # Both present: the dangerous claim wins. + (["readOnlyHint", "destructiveHint"], "destructive"), + # 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), + ], +) +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"), + [ + ("destructive", "off", False), + ("write", "off", 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 + + +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. `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" + + +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"] + + +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_config.py b/agent/tests/test_composio_config.py new file mode 100644 index 0000000..6850121 --- /dev/null +++ b/agent/tests/test_composio_config.py @@ -0,0 +1,195 @@ +"""The Composio environment contract.""" + +from __future__ import annotations + +import logging + +import pytest + +from composio_tools.config import ( + DEFAULT_WORKSPACE_USER_ID, + 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_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 ("", " "): + 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" + + +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"} + + +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 new file mode 100644 index 0000000..a8847a6 --- /dev/null +++ b/agent/tests/test_composio_connect.py @@ -0,0 +1,297 @@ +"""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 ConnectLink, 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: + """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 "destructive" + + +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): + 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 + + +@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", "kind": "human", "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", "kind": "human", "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", "kind": "human", "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", "kind": "human", "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 + + +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, + "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_connect_cli.py b/agent/tests/test_composio_connect_cli.py new file mode 100644 index 0000000..d8fc9f8 --- /dev/null +++ b/agent/tests/test_composio_connect_cli.py @@ -0,0 +1,213 @@ +"""The operator path for connecting a shared toolkit.""" + +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 + + +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 + + +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} diff --git a/agent/tests/test_composio_identity.py b/agent/tests/test_composio_identity.py new file mode 100644 index 0000000..2830e08 --- /dev/null +++ b/agent/tests/test_composio_identity.py @@ -0,0 +1,378 @@ +"""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 AssistantMessage, RunAgentInput, UserMessage +from langchain_core.messages import AIMessage +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. + + 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")) + 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): + 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, + 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()) if message_id is None else message_id, + role="user", + content=text, + ), + *extra_messages, + ], + 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_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() + + 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_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. + 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/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_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_sessions.py b/agent/tests/test_composio_sessions.py new file mode 100644 index 0000000..dc13367 --- /dev/null +++ b/agent/tests/test_composio_sessions.py @@ -0,0 +1,319 @@ +"""Session creation, caching, and what happens when one identity is unreachable.""" + +from __future__ import annotations + +import logging + +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 MAX_SESSIONS, 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="on", + 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_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) + + 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.sessions] == ["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 + + +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 new file mode 100644 index 0000000..a580fc4 --- /dev/null +++ b/agent/tests/test_composio_tools.py @@ -0,0 +1,941 @@ +"""Discovery and execution, and whose account each one happens in.""" + +from __future__ import annotations + +import logging + +import pytest + +import composio_tools.tools as tools_mod +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 +from composio_tools.tools import build_composio_tools, humanize_slug, owns_slug + +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, + 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. + "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 + }, + **({"toolkit_connection_statuses": statuses} if statuses else {}), + } + ) + + +class FakeSession: + 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 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): + 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] = [] + 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] + + +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": "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. + + 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] = [] + + 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) + 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 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("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", "slack: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 "slack: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("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")}) + + 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", + 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}) + + 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", "has_active_connection": 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("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")}) + + assert [entry["slug"] for entry in result["tools"]] == ["LINEAR_OK"] + 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") + # 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")} + ) + + 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}, effects=FakeEffects(default="read") + ) + + 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}, effects=FakeEffects(default="read") + ) + + result = run.invoke( + {"slug": "LINEAR_CREATE_ISSUE", "arguments": {}, "state": state()} + ) + + 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"), + [ + (("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 + + +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"] == "Delete issue (Linear)" + 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): + # `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), + ("destructive", True), + ("writes", True), + ): + shared = FakeSession("open-tag") + _search, run, _client = tools_for( + {"open-tag": shared}, + cfg=parsed_config(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("slack:U1") + shared = FakeSession("open-tag") + _search, run, _client = tools_for( + {"open-tag": shared, "slack:U1": personal}, + effects=FakeEffects({"GMAIL_SEND_EMAIL": "write"}), + cfg=parsed_config("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=parsed_config("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"), + [ + # 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"), + ], +) +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 + + + + +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, 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=parsed_config(""), + 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/agent/tests/test_health.py b/agent/tests/test_health.py index ffb962c..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): @@ -101,6 +102,12 @@ 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) + # 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: {}) @@ -115,6 +122,51 @@ 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") + reset_composio_runtime() + 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 diff --git a/agent/tests/test_packaging.py b/agent/tests/test_packaging.py index 5640c33..27fe7b8 100644 --- a/agent/tests/test_packaging.py +++ b/agent/tests/test_packaging.py @@ -1,34 +1,249 @@ +import re import tomllib +from collections.abc import Iterable 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"}) + +#: 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.""" + 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 + + +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. + + `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. + + 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 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]: + """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 packaged_modules == runtime_modules - assert project["tool"]["setuptools"]["packages"] == ["prompts", "coding"] + 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. + assert set(project["tool"]["setuptools"]["packages"]) == runtime_packages() -def test_agent_image_copies_the_coding_package(): - repo_root = Path(__file__).resolve().parents[2] + +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 + # 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") - assert "COPY agent/coding ./coding" 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`. + # `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/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/uv.lock b/agent/uv.lock index 8252a97..57faa3f 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,17 +1509,18 @@ 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]] name = "opentag-agent" version = "0.1.0" -source = { virtual = "." } +source = { editable = "." } dependencies = [ { name = "ag-ui-langgraph" }, + { name = "composio" }, { name = "copilotkit" }, { name = "daytona" }, { name = "deepagents" }, @@ -1494,13 +1544,14 @@ dev = [ [package.metadata] requires-dist = [ { name = "ag-ui-langgraph", specifier = ">=0.0.23" }, + { 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" }, @@ -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/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), ) diff --git a/app/channel.test.ts b/app/channel.test.ts index 5c1ca2e..12f0beb 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(); @@ -540,6 +566,7 @@ describe("createOpenTagChannel", () => { "triage", ]); expect(appTools.map(({ name }) => name).sort()).toEqual([ + "connect_app", "issue_card", "issue_list", "page_list", @@ -865,6 +892,7 @@ describe("createOpenTagChannel", () => { replyTarget: {}, userText: "save it", platform: "slack", + actor: { id: "U1", kind: "human" }, }); expect(adapter.posted).toHaveLength(1); @@ -877,6 +905,149 @@ 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: "slack:U1", + effect: "write", + }); + + await adapter.getSink().onInteraction({ + 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 }, + }); + + 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: "slack:U1", + effect: "write", + }); + + await adapter.getSink().onInteraction({ + 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 }, + }); + + expect(adapter.updated).toHaveLength(1); + expect(JSON.stringify(adapter.updated)).toContain("Approved"); + 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", + 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") @@ -911,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(); }); @@ -926,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); }, @@ -962,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 }, }); @@ -971,6 +1177,72 @@ 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" }, + }); + + // 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(); + }); + it("re-registers incident actions when a new Channel uses the same store", async () => { const sharedState = new MemoryStore(); const firstAdapter = new FakeAdapter({ platform: "intelligence" }); @@ -1019,6 +1291,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" }, @@ -1031,3 +1304,175 @@ 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!; +} + +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 311a2c3..2eea7de 100644 --- a/app/channel.tsx +++ b/app/channel.tsx @@ -1,8 +1,11 @@ +import { slack } from "@copilotkit/channels/slack"; import { createChannel, type Channel, type ChannelTool, type CreateChannelOptions, + type ProviderActor, + type Thread, } from "@copilotkit/channels"; import { managedRunInput, @@ -12,8 +15,11 @@ 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 { + DEFAULT_AGENT_DISPLAY_NAME, + type SlackDirectConfig, +} from "./env.js"; +import { ConfirmWrite, ConnectAccount } from "./human-in-the-loop/index.js"; import { parseConfirmWriteInterrupt } from "./interrupt.js"; import { FILE_ISSUE_CALLBACK, fileIssueSubmit } from "./modals/file-issue.js"; import { IncidentCard } from "./tools/showcase-tools.js"; @@ -26,16 +32,54 @@ 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, 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 +89,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, ], }); @@ -80,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; } @@ -100,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; } @@ -109,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]); } }); @@ -119,16 +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 }) => { @@ -159,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/env.test.ts b/app/env.test.ts index a4de71b..2a1907f 100644 --- a/app/env.test.ts +++ b/app/env.test.ts @@ -5,6 +5,7 @@ import { DEFAULT_INTELLIGENCE_GATEWAY_WS_URL, parsePort, readEnvironment, + readSlackDirect, } from "./env.js"; const requiredEnvironment = { @@ -25,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", @@ -82,16 +110,110 @@ describe("readEnvironment", () => { ).toMatchObject({ agentDisplayName: "Kite" }); }); - it("does not expose platform credentials owned by Intelligence", () => { + 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.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. const environment = readEnvironment({ ...requiredEnvironment, SLACK_BOT_TOKEN: "xoxb-unused", + SLACK_APP_TOKEN: "xapp-unused", 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", + }); }); }); @@ -111,3 +233,41 @@ 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. + // + // 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( + "only SLACK_BOT_TOKEN is set", + ); + expect(() => readSlackDirect({ SLACK_APP_TOKEN: "xapp-1" })).toThrow( + "only SLACK_APP_TOKEN is set", + ); + }); + + 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..87fa631 100644 --- a/app/env.ts +++ b/app/env.ts @@ -5,10 +5,33 @@ 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, 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; + 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; @@ -17,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}`); } @@ -39,6 +63,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 { @@ -46,17 +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/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..78e07f2 --- /dev/null +++ b/app/human-in-the-loop/__tests__/confirm-write-approver.test.tsx @@ -0,0 +1,305 @@ +/** + * 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 type { + ClickHandler, + EphemeralResult, + InteractionContext, + MessageRef, + Renderable, +} from "@copilotkit/channels"; +import { ConfirmWrite } from "../confirm-write.js"; + +/** + * 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); + 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 ClickHandler); + } + if (element.props?.children) visit(element.props.children); + if (element.children) visit(element.children); + }; + visit(node); + 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?: ClickContext["thread"]["postEphemeral"]; + } = {}, +) { + 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 (): Promise => ({ + ok: true, + usedFallback: false, + })), + ); + 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, + actor, + ref: { id: "m1" }, + platform, + }, + } satisfies ClickContext; + + return { + ctx: ctx as unknown as InteractionContext, + update, + resume, + post, + postEphemeral, + }; +} + +describe("ConfirmWrite approver", () => { + it("lets the named person answer", async () => { + const { confirm } = cardButtons( + ConfirmWrite({ action: "Gmail send email", approver: "slack:U1" }), + ); + const { ctx, update, postEphemeral } = interaction("U1"); + + await confirm(ctx); + + expect(update).toHaveBeenCalled(); + expect(postEphemeral).not.toHaveBeenCalled(); + }); + + it("refuses anybody else, and leaves the card for the right person", async () => { + const { confirm } = cardButtons( + ConfirmWrite({ action: "Gmail send email", approver: "slack:U1" }), + ); + const { ctx, update, resume, postEphemeral } = interaction("U2"); + + await confirm(ctx); + + expect(update).not.toHaveBeenCalled(); + expect(resume).not.toHaveBeenCalled(); + expect(postEphemeral).toHaveBeenCalledTimes(1); + // 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 () => { + const { decline } = cardButtons( + ConfirmWrite({ action: "Gmail send email", approver: "slack:U1" }), + ); + const { ctx, update, postEphemeral } = interaction("U2"); + + await decline(ctx); + + expect(update).not.toHaveBeenCalled(); + expect(postEphemeral).toHaveBeenCalledTimes(1); + }); + + it("lets anyone answer a workspace action, which names no approver", async () => { + const { confirm } = cardButtons( + ConfirmWrite({ action: "Create issue" }), + ); + const { ctx, update, postEphemeral } = interaction("U2"); + + await confirm(ctx); + + expect(update).toHaveBeenCalled(); + 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 { confirm } = cardButtons( + ConfirmWrite({ action: "Gmail send email", approver: "slack:U1" }), + ); + const { ctx, resume, post, postEphemeral } = interaction("U2", { + postEphemeral: async () => null, + }); + + await confirm(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 { confirm } = cardButtons( + ConfirmWrite({ action: "Gmail send email", approver: "slack:U1" }), + ); + const { ctx, update, resume, post } = interaction("U2", { + postEphemeral: async () => { + throw new Error("ephemeral unavailable"); + }, + }); + + await confirm(ctx); + + expect(update).not.toHaveBeenCalled(); + expect(resume).not.toHaveBeenCalled(); + expect(post).toHaveBeenCalledTimes(1); + consoleError.mockRestore(); + }); + + 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 confirm(ctx); + + expect(update).not.toHaveBeenCalled(); + expect(resume).not.toHaveBeenCalled(); + expect(postEphemeral).toHaveBeenCalledTimes(1); + }); + + 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, resume, postEphemeral } = interaction("U1"); + + 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 { confirm } = cardButtons( + ConfirmWrite({ action: "Gmail send email", approver: "slack:U1" }), + ); + const { ctx, update, resume } = interaction(""); + + 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 { confirm } = cardButtons( + ConfirmWrite({ action: "Gmail send email", approver: "teams:U1" }), + ); + const { ctx, update, postEphemeral } = interaction("U1"); + + await confirm(ctx); + + expect(update).not.toHaveBeenCalled(); + expect(postEphemeral).toHaveBeenCalledTimes(1); + }); + + 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 { confirm } = cardButtons( + ConfirmWrite({ action: "Gmail send email", approver: "slack:U1" }), + ); + const { ctx, update, postEphemeral } = interaction("U1", { + platform: "Slack", + }); + + 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 e1aedc9..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[] { @@ -392,12 +395,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 +413,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 +532,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,13 +547,13 @@ 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 () => { + it("replaces the optimistic card with an unknown outcome when resume fails", async () => { const ir = renderToIR( , ); @@ -498,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"); @@ -530,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..998bff6 --- /dev/null +++ b/app/human-in-the-loop/__tests__/connect-account.test.tsx @@ -0,0 +1,99 @@ +/** + * 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); + + // 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); + 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(); + }); +}); diff --git a/app/human-in-the-loop/confirm-write.tsx b/app/human-in-the-loop/confirm-write.tsx index 7a14f63..836dd57 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 { @@ -32,9 +33,50 @@ 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; + /** + * 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 @@ -54,8 +96,30 @@ 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. 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?: 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"], @@ -69,16 +133,24 @@ async function resumeOrShowFailure( 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; @@ -149,12 +221,120 @@ 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. + * + * 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, + 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. + // + // 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 === (interaction.platform ?? "").trim().toLowerCase(); +} + +/** + * 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 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 (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", + }); + } + return true; +} + export function ConfirmWrite({ action, + approver, fields, detail, attempt, previousError, + effect, }: ConfirmWriteProps) { const body = fields?.length ? fieldTable(fields) @@ -168,9 +348,45 @@ 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. 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 = + !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 + // 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); + }; return ( @@ -184,9 +400,10 @@ 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..226c874 100644 --- a/app/human-in-the-loop/index.ts +++ b/app/human-in-the-loop/index.ts @@ -6,4 +6,14 @@ * 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, + ConnectLink, +} from "./connect-account.js"; +export type { ConnectRequest } from "./connect-account.js"; diff --git a/app/index.ts b/app/index.ts index df2a735..a0fa400 100644 --- a/app/index.ts +++ b/app/index.ts @@ -3,20 +3,55 @@ 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) { + // 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; }; +} + +export function createOpenTagApplication( + environment: AppEnvironment = readEnvironment(), +) { + 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 = [ @@ -24,6 +59,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..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 = { @@ -154,3 +155,120 @@ describe("parseConfirmWriteInterrupt", () => { expect(() => parseConfirmWriteInterrupt("{broken")).toThrow(); }); }); + +function interruptPayload(action: string, args: unknown) { + return { + __copilotkit_interrupt_value__: { action, args }, + __copilotkit_messages__: [], + }; +} + +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("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" }), + ); + 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 420edef..cbabfa8 100644 --- a/app/interrupt.ts +++ b/app/interrupt.ts @@ -1,31 +1,99 @@ 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({ 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(), + /** + * 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: 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. }); +/** + * 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) { - const normalized = - typeof payload === "string" ? JSON.parse(payload) : payload; - return confirmWriteInterruptSchema.parse(normalized) - .__copilotkit_interrupt_value__; + return envelopeSchema.parse(payload).__copilotkit_interrupt_value__; } diff --git a/app/railway.test.ts b/app/railway.test.ts index 02fb5f4..d6c9cbe 100644 --- a/app/railway.test.ts +++ b/app/railway.test.ts @@ -1,55 +1,91 @@ -import { execFileSync } from "node:child_process"; +import { + createRailwayContext, + project, + projectDefinitionToGraph, + validateGraph, + type RailwayGraph, + type ServiceNode, +} from "railway/iac"; import { describe, expect, it } from "vitest"; +import railwayProgram from "../.railway/railway.js"; -interface RailwayVariable { - type: "literal" | "preserve"; - value?: string; +/** + * 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; } -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 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( + `no service called ${name}; the graph has ${graph.resources + .map((resource) => resource.name) + .join(", ")}`, + ); + } + return service; } -function evaluateRailwayGraph(): RailwayResource[] { - const stdout = execFileSync( - process.execPath, - ["node_modules/railway/dist/iac/bin.js"], - { - cwd: process.cwd(), - encoding: "utf8", - }, - ); - const result = JSON.parse(stdout) as { - ok: boolean; - diagnostics: unknown[]; - graph: { resources: RailwayResource[] }; - }; - expect(result.ok).toBe(true); - expect(result.diagnostics).toEqual([]); - 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(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", () => { - 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", @@ -60,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" }, @@ -80,9 +116,54 @@ 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" }, + // 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" }, }); - const runtime = resources.find(({ name }) => name === "runtime"); + // 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 = serviceNamed(graph, "runtime"); expect(runtime).toMatchObject({ source: { repo: "CopilotKit/OpenTag", @@ -96,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: { @@ -118,6 +199,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", @@ -128,5 +212,35 @@ 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", + ]); + }); + + 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/app/server.test.ts b/app/server.test.ts index 5477679..3a1c22b 100644 --- a/app/server.test.ts +++ b/app/server.test.ts @@ -8,12 +8,14 @@ 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; 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(); @@ -134,19 +271,80 @@ 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.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); + 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) => ({ @@ -156,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 new file mode 100644 index 0000000..f976a8a --- /dev/null +++ b/app/tools/__tests__/composio-connect.test.ts @@ -0,0 +1,410 @@ +import { describe, expect, it, vi } from "vitest"; +import { + connectEndpoint, + normalizeToolkit, + 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", + actorKind: "human", + 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", + // 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", + }); + }); + + 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.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: "Bearer 0123456789abcdef", + fetchImpl, + }); + + expect(result.ok).toBe(false); + 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 () => { + 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("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"); + }) 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); + } + }); + + 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 new file mode 100644 index 0000000..f5b658e --- /dev/null +++ b/app/tools/__tests__/connect-app.test.tsx @@ -0,0 +1,99 @@ +/** + * 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.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(); + + 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/__tests__/connect-click.test.tsx b/app/tools/__tests__/connect-click.test.tsx new file mode 100644 index 0000000..23e65bf --- /dev/null +++ b/app/tools/__tests__/connect-click.test.tsx @@ -0,0 +1,281 @@ +import { describe, expect, it, vi } from "vitest"; +import { handleConnectClick } from "../connect-click.js"; + +const LINK = "https://backend.composio.dev/connect/abc123"; + +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 }) => + ephemeral, + ); + const post = vi.fn(async (_ui: unknown) => { + if (options.postRejects) throw options.postRejects; + return { id: "m1" }; + }); + return { + ctx: { + actor, + platform: "slack", + thread: { postEphemeral, post }, + message: { ref: "m1" }, + action: { id: "a1" }, + values: {}, + user: null, + } as never, + postEphemeral, + post, + }; +} + +const environment = { + agentUrl: "http://agent:8123/", + 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 })); + const { ctx } = interaction({ id: "U2", kind: "human" }); + + await handleConnectClick("gmail", ctx, { environment, request }); + + expect(request).toHaveBeenCalledWith( + 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, 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("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: 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, post } = interaction(undefined); + + await handleConnectClick("gmail", ctx, { environment, request }); + + expect(request).not.toHaveBeenCalled(); + 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 () => { + const request = vi.fn(async () => ({ + ok: false as const, + message: "Shared apps are connected by an operator.", + })); + const { ctx, postEphemeral } = interaction({ id: "U2", kind: "human" }); + + await handleConnectClick("linear", ctx, { environment, request }); + + expect(postEphemeral).toHaveBeenCalledTimes(1); + 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 new file mode 100644 index 0000000..fa2fe6e --- /dev/null +++ b/app/tools/composio-connect.ts @@ -0,0 +1,289 @@ +/** + * 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. + * + * 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; + 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; + /** 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. */ +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 + * 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(); +} + +/** 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, + actorId, + actorKind, + 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. 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: + `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(endpoint, { + method: "POST", + headers: { + "content-type": "application/json", + authorization: secret, + }, + body, + signal: controller.signal, + }); + } 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) { + 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.`, + }; + } + + 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: 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. + * + * 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 && response.status !== 503) return null; + if (!isJson(response)) return null; + try { + 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 new file mode 100644 index 0000000..f451e26 --- /dev/null +++ b/app/tools/connect-app.tsx @@ -0,0 +1,57 @@ +/** + * `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"; +import { normalizeToolkit } from "./composio-connect.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 }) { + 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 ( + `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/connect-click.tsx b/app/tools/connect-click.tsx new file mode 100644 index 0000000..caaa844 --- /dev/null +++ b/app/tools/connect-click.tsx @@ -0,0 +1,222 @@ +/** + * 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. + * + * 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, Renderable } from "@copilotkit/channels"; +import { readEnvironment } from "../env.js"; +import { + ConnectFailed, + ConnectLink, + type ConnectRequest, +} from "../human-in-the-loop/connect-account.js"; +import { normalizeToolkit, requestConnectLink } from "./composio-connect.js"; + +type Interaction = InteractionContext; + +/** + * 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. + * + * 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: Interaction, + deps: { + environment?: ReturnType; + readEnvironment?: typeof readEnvironment; + request?: typeof requestConnectLink; + } = {}, +): Promise { + 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; + + // 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. 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, + , + ); + 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; + } + + const result = await request({ + agentUrl: environment.agentUrl, + agentAuthHeader: environment.agentAuthHeader, + actorId: actor.id, + actorKind: actor.kind, + platform: interaction.platform, + toolkit: slug, + }); + + 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, + , + ); + 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); + } +} 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/deployment/aws/README.md b/deployment/aws/README.md index 44ac2dd..9267603 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,33 @@ 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: 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. @@ -115,10 +145,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/deployment/aws/lib/opentag-stack.ts b/deployment/aws/lib/opentag-stack.ts index 56fc391..a27734d 100644 --- a/deployment/aws/lib/opentag-stack.ts +++ b/deployment/aws/lib/opentag-stack.ts @@ -19,8 +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", + SHARED_AUTH_SECRET_KEY, "TAVILY_API_KEY", "DAYTONA_API_KEY", "GITHUB_PERSONAL_ACCESS_TOKEN", @@ -32,7 +51,37 @@ const AGENT_SECRET_KEYS = [ const RUNTIME_SECRET_KEYS = [ "INTELLIGENCE_API_KEY", - "AGENT_AUTH_HEADER", + 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; function contextString( @@ -138,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, @@ -252,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", @@ -261,6 +324,19 @@ export class OpenTagStack extends cdk.Stack { "NOTION_MCP_URL", contextString(this, "notionMcpUrl", ""), ), + ...optionalEnvironment("COMPOSIO_TOOLKITS", composioToolkits), + ...optionalEnvironment( + "COMPOSIO_USER_TOOLKITS", + 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, @@ -294,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: @@ -342,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 62f38ae..ddfa61e 100644 --- a/deployment/aws/test/opentag-stack.test.ts +++ b/deployment/aws/test/opentag-stack.test.ts @@ -7,6 +7,71 @@ 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)])); +} + +/** + * 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( context: Record = {}, shared = false, @@ -103,6 +168,186 @@ 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 + // 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(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([ + ...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 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), and a default quietly changing under a name that still looks + // right. + const template = Template.fromStack(stackWithContext()); + + 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", () => { const template = Template.fromStack( stackWithContext({ @@ -117,6 +362,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 +378,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/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 diff --git a/setup.md b/setup.md index 7826442..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 @@ -82,6 +86,14 @@ 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 | `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 | | `DAYTONA_API_KEY` | No | Enables the coding subagent (Daytona sandbox) | @@ -142,9 +154,11 @@ 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`, 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. @@ -227,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: @@ -269,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 @@ -333,6 +356,162 @@ 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 **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** + 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 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 +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 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 + 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 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 +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. 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: + +- **`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 `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 +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 The IaC file declares exactly: @@ -344,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: 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"] }