Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,39 @@ export OPENAI_API_KEY=sk-... # Create a key at https://platform.ope
# -- Notion (Optional) --
# export NOTION_MCP_URL=https://your-notion-mcp.example.com/mcp
# export NOTION_MCP_AUTH_TOKEN=your-remote-mcp-bearer-token

# -- Composio (Optional) --
# Connect any Composio toolkit without writing an MCP block. Two steps per app:
# add the toolkit at https://app.composio.dev, then name its slug below.
# Slugs are Composio's own — lowercase, unspaced: `googlecalendar`, not `gcal`.
# COMPOSIO_API_KEY is the master switch; without it nothing is constructed.
# export COMPOSIO_API_KEY=ak_...
#
# One shared identity everyone in Slack reaches. Connect each of these once with
# cd agent && uv run python -m composio_tools.connect_cli <toolkit>
# export COMPOSIO_TOOLKITS=linear,jira
#
# Each person's own account. They connect it themselves from a thread.
# export COMPOSIO_USER_TOOLKITS=gmail,googlecalendar
#
# off | destructive (default) | writes — which calls wait for a person.
# export COMPOSIO_APPROVALS=destructive
#
# The Composio user_id shared toolkits act as. Defaults to the Channel name.
# export COMPOSIO_WORKSPACE_USER_ID=open-tag
#
# Read only by the connect script: pins which auth config a shared toolkit
# connects against when it has several. Ids are case-sensitive.
# export COMPOSIO_AUTH_CONFIGS=linear:ac_ExAmPle1

# -- Slack direct delivery (Optional; needed for COMPOSIO_USER_TOOLKITS) --
# Intelligence owns the Slack edge and no Slack token is otherwise needed here.
# The one thing it cannot do is post a message only one person can see, and a
# connect link must reach exactly one person. Set both or neither.
# export SLACK_BOT_TOKEN=xoxb-...
# export SLACK_APP_TOKEN=xapp-...

# -- Agent authentication (Optional; required to connect personal accounts) --
# Sent by the runtime and now checked by the agent. Without it the connect
# endpoint refuses to mint a link, since that link is a bearer capability.
# export AGENT_AUTH_HEADER=Bearer generate-a-long-random-string
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
16 changes: 16 additions & 0 deletions .railway/railway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,17 @@ export default defineRailway(() => {
LINEAR_API_KEY: preserve(),
NOTION_MCP_URL: preserve(),
NOTION_MCP_AUTH_TOKEN: preserve(),
// Composio is read by the agent, which is where the toolkits live. The
// runtime carries only the shared secret it presents when asking for a
// connect link.
COMPOSIO_API_KEY: preserve(),
COMPOSIO_TOOLKITS: preserve(),
COMPOSIO_USER_TOOLKITS: preserve(),
COMPOSIO_APPROVALS: preserve(),
COMPOSIO_WORKSPACE_USER_ID: preserve(),
COMPOSIO_AUTH_CONFIGS: preserve(),
AGENT_AUTH_HEADER: preserve(),
INTELLIGENCE_CHANNEL_NAME: "open-tag",
},
});

Expand Down Expand Up @@ -71,6 +82,11 @@ export default defineRailway(() => {
"wss://realtime.intelligence.copilotkit.ai",
INTELLIGENCE_LEARNING_CONTAINER_ID: preserve(),
INTELLIGENCE_CHANNEL_NAME: "open-tag",
AGENT_AUTH_HEADER: preserve(),
// Only so a Composio connect link can reach one person privately; the
// managed adapter cannot post a message only one person sees.
SLACK_BOT_TOKEN: preserve(),
SLACK_APP_TOKEN: preserve(),
PLAYWRIGHT_BROWSERS_PATH: "0",
RAILPACK_DEPLOY_APT_PACKAGES:
"fonts-liberation fonts-noto-color-emoji fonts-unifont libasound2 libatk-bridge2.0-0 libatk1.0-0 libatspi2.0-0 libcairo2 libcups2 libdbus-1-3 libdrm2 libexpat1 libfontconfig1 libfreetype6 libgbm1 libglib2.0-0 libnspr4 libnss3 libpango-1.0-0 libx11-6 libx11-xcb1 libxcb1 libxcomposite1 libxdamage1 libxext6 libxfixes3 libxkbcommon0 libxrandr2 libxrender1 libxshmfence1",
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -320,7 +320,8 @@ agent (Python + LangGraph deepagents)
├── GitHub MCP (optional, read-only)
├── PostHog MCP (optional, read-only)
├── Linear MCP (optional)
└── Notion MCP (optional remote server)
├── Notion MCP (optional remote server)
└── Composio toolkits (optional; shared or per-person)
```

| You run | CopilotKit Intelligence manages |
Expand Down Expand Up @@ -361,6 +362,7 @@ knowledge work, and renders UI from model knowledge.
| `GITHUB_PERSONAL_ACCESS_TOKEN` | Read-only repository, code, PR, and CI search |
| `POSTHOG_PERSONAL_API_KEY` | PostHog analytics, read-only (use the **MCP Server** key preset) |
| `LINEAR_API_KEY` | Hosted Linear MCP |
| `COMPOSIO_API_KEY` | Composio toolkits, shared or per-person (see setup.md) |
| `NOTION_MCP_URL` + `NOTION_MCP_AUTH_TOKEN` | Remote Notion MCP; setting only one disables it |
| `DAYTONA_API_KEY` + a PAT or GitHub App | Coding subagent: edit in Daytona, then push and publish a draft PR after `confirm_write` |

Expand Down
39 changes: 37 additions & 2 deletions agent/agent.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""OpenTag's general-purpose knowledge-work Deep Agent."""

import logging
import os
from pathlib import Path

Expand All @@ -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,
Expand All @@ -39,6 +43,8 @@
)
from tools import web_search

logger = logging.getLogger(__name__)

load_dotenv(Path(__file__).resolve().parent.parent / ".env")


Expand Down Expand Up @@ -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 = (
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
78 changes: 78 additions & 0 deletions agent/agent_auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
"""The shared secret between the runtime and this agent.

The runtime has always sent `AGENT_AUTH_HEADER` as its `Authorization` header and
this service has always ignored it. In the deployed topology that was survivable:
the runtime reaches the agent over Railway's private domain, so nothing off the
project could call it anyway. It is not survivable for an endpoint that mints
connect links, because such a link is a bearer capability — whoever opens it
binds an account to the user id it was minted for.

Two different rules, on purpose:

- Ordinary traffic is checked only when a secret is configured. A local `pnpm
dev` has no secret and must keep working, and switching enforcement on for
every existing deployment would take them down on upgrade.
- Anything that mints a capability requires a secret unconditionally. With none
configured the route reports itself unavailable rather than serving
unauthenticated. Fail closed where it counts, unchanged everywhere else.
"""

from __future__ import annotations

import hmac
import os
from collections.abc import Mapping

#: Paths served without a secret even when one is configured. The platform's
#: health probe has no way to send one.
PUBLIC_PATHS = frozenset({"/health"})


def configured_secret(env: Mapping[str, str] | None = None) -> str | None:
"""The expected `Authorization` value, or `None` when none is configured."""
source = os.environ if env is None else env
return (source.get("AGENT_AUTH_HEADER") or "").strip() or None


def header_matches(presented: str | None, expected: str) -> bool:
"""
Whether a presented header is the configured secret.

Compared with `compare_digest` rather than `==`: an early-exit comparison
leaks the length of the matching prefix, and this value is the only thing
standing in front of the agent.
"""
if not presented:
return False
return hmac.compare_digest(presented.strip(), expected)


def is_authorized(
path: str,
presented: str | None,
env: Mapping[str, str] | None = None,
) -> bool:
"""Whether ordinary traffic for `path` may proceed."""
if path in PUBLIC_PATHS:
return True
expected = configured_secret(env)
if expected is None:
return True
return header_matches(presented, expected)


def authorizes_capability(
presented: str | None,
env: Mapping[str, str] | None = None,
) -> bool:
"""
Whether a capability-minting request may proceed.

Unlike `is_authorized`, an absent secret is a refusal. There is no
configuration in which handing out connect links to unauthenticated callers
is the intended behaviour.
"""
expected = configured_secret(env)
if expected is None:
return False
return header_matches(presented, expected)
6 changes: 6 additions & 0 deletions agent/composio_tools/__init__.py
Original file line number Diff line number Diff line change
@@ -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.
"""
34 changes: 34 additions & 0 deletions agent/composio_tools/classify.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
"""Effect classification from Composio's MCP behaviour tags.

A tool's tags are a plain list of strings that defaults to empty, so an empty
list cannot be told apart from "nobody classified this". Anything not positively
marked read-only is therefore treated as a write, which fails safe and matches
how `internal_sources.py` already treats an unclassified MCP tool.
"""

from __future__ import annotations

from collections.abc import Iterable

READ = "read"
WRITE = "write"
DESTRUCTIVE = "destructive"


def effect_of(tags: Iterable[str] | None) -> str:
"""The effect a tool's tags claim, erring towards the more dangerous read."""
present = set(tags or ())
if "destructiveHint" in present:
return DESTRUCTIVE
if "readOnlyHint" in present:
return READ
return WRITE


def needs_approval(effect: str, mode: str) -> bool:
"""Whether an effect must be confirmed by a person under this approval mode."""
if mode == "off":
return False
if mode == "destructive":
return effect == DESTRUCTIVE
return effect != READ
Loading