@openhands/extensions 0.9.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/deprecation-check.yml +25 -0
- package/.release-please-manifest.json +1 -1
- package/README.md +3 -2
- package/integrations/README.md +25 -0
- package/integrations/catalog/atlassian-rovo.json +44 -0
- package/integrations/catalog/atlassian.json +13 -0
- package/integrations/catalog/github.json +21 -1
- package/integrations/catalog/linear.json +1 -1
- package/integrations/catalog/microsoft-outlook.json +13 -0
- package/integrations/catalog/microsoft-teams.json +13 -0
- package/integrations/catalog/notion.json +13 -0
- package/integrations/catalog/onedrive.json +13 -0
- package/integrations/catalog/sharepoint.json +13 -0
- package/integrations/catalog/slack.json +16 -2
- package/integrations/catalog/vanta.json +55 -0
- package/integrations/catalog-index.js +71 -67
- package/integrations/catalog.schema.json +70 -3
- package/integrations/index.d.ts +49 -3
- package/marketplaces/openhands-extensions.json +9 -0
- package/package.json +1 -1
- package/pyproject.toml +6 -1
- package/python/openhands_extensions/__init__.py +38 -0
- package/python/openhands_extensions/_version.py +2 -2
- package/python/openhands_extensions/deprecation.py +159 -0
- package/python/openhands_extensions/integration_models.py +233 -0
- package/python/openhands_extensions/integrations.py +59 -4
- package/scripts/check_deprecations.py +488 -0
- package/skills/index.js +7 -1
- package/skills/iterate/README.md +4 -1
- package/skills/iterate/SKILL.md +22 -2
- package/skills/technical-writing/.plugin/plugin.json +15 -0
- package/skills/technical-writing/README.md +14 -0
- package/skills/technical-writing/SKILL.md +106 -0
- package/tests/test_catalog_schema.py +58 -0
- package/tests/test_catalogs.py +67 -0
- package/tests/test_deprecations.py +136 -0
- package/tests/test_integration_catalog_in_sync.py +118 -7
- package/tests/test_live_integration_smoke.py +366 -0
|
@@ -4,9 +4,12 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import json
|
|
6
6
|
import subprocess
|
|
7
|
+
from copy import deepcopy
|
|
7
8
|
from pathlib import Path
|
|
8
9
|
|
|
9
10
|
import openhands_extensions
|
|
11
|
+
import pytest
|
|
12
|
+
from pydantic import ValidationError
|
|
10
13
|
|
|
11
14
|
ROOT = Path(__file__).resolve().parents[1]
|
|
12
15
|
CATALOG_DIR = ROOT / "integrations" / "catalog"
|
|
@@ -23,7 +26,17 @@ def _catalog_files() -> dict[str, dict]:
|
|
|
23
26
|
|
|
24
27
|
def _catalog_entries() -> list[dict]:
|
|
25
28
|
entries = list(_catalog_files().values())
|
|
26
|
-
return sorted(
|
|
29
|
+
return sorted(
|
|
30
|
+
entries,
|
|
31
|
+
key=lambda entry: (
|
|
32
|
+
-(
|
|
33
|
+
entry.get("popularityRank")
|
|
34
|
+
if entry.get("popularityRank") is not None
|
|
35
|
+
else -1
|
|
36
|
+
),
|
|
37
|
+
entry["id"],
|
|
38
|
+
),
|
|
39
|
+
)
|
|
27
40
|
|
|
28
41
|
|
|
29
42
|
def _supports_mcp(entry: dict) -> bool:
|
|
@@ -31,8 +44,10 @@ def _supports_mcp(entry: dict) -> bool:
|
|
|
31
44
|
|
|
32
45
|
|
|
33
46
|
def _supports_oauth(entry: dict) -> bool:
|
|
34
|
-
return any(
|
|
35
|
-
|
|
47
|
+
return any(
|
|
48
|
+
option.get("auth", {}).get("strategy") == "oauth2"
|
|
49
|
+
for option in entry["connectionOptions"]
|
|
50
|
+
)
|
|
36
51
|
|
|
37
52
|
|
|
38
53
|
def test_catalog_directory_is_hand_authored_source_of_truth() -> None:
|
|
@@ -84,6 +99,95 @@ def test_python_list_integration_catalog_returns_raw_entries() -> None:
|
|
|
84
99
|
assert "runtimeAvailability" not in entry
|
|
85
100
|
|
|
86
101
|
|
|
102
|
+
def test_python_list_integration_catalog_models_returns_typed_entries() -> None:
|
|
103
|
+
entries = openhands_extensions.list_integration_catalog()
|
|
104
|
+
models = openhands_extensions.list_integration_catalog_models()
|
|
105
|
+
|
|
106
|
+
assert [model.model_dump(exclude_none=True) for model in models] == entries
|
|
107
|
+
assert all(
|
|
108
|
+
isinstance(model, openhands_extensions.IntegrationCatalogEntry)
|
|
109
|
+
for model in models
|
|
110
|
+
)
|
|
111
|
+
assert (
|
|
112
|
+
openhands_extensions.get_integration_catalog_entry_model(entries[0]["id"])
|
|
113
|
+
== models[0]
|
|
114
|
+
)
|
|
115
|
+
assert openhands_extensions.get_integration_catalog_entry_model("missing") is None
|
|
116
|
+
|
|
117
|
+
with pytest.raises(ValidationError):
|
|
118
|
+
openhands_extensions.IntegrationCatalogEntry.model_validate(
|
|
119
|
+
{**entries[0], "unexpected": True}
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_http_catalog_options_require_both_urls() -> None:
|
|
124
|
+
entry = next(
|
|
125
|
+
entry
|
|
126
|
+
for entry in _catalog_entries()
|
|
127
|
+
if any(option["provider"] == "http" for option in entry["connectionOptions"])
|
|
128
|
+
)
|
|
129
|
+
option = next(
|
|
130
|
+
option for option in entry["connectionOptions"] if option["provider"] == "http"
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
for field in ("apiBaseUrl", "openApiUrl"):
|
|
134
|
+
invalid = deepcopy(entry)
|
|
135
|
+
invalid_option = next(
|
|
136
|
+
item for item in invalid["connectionOptions"] if item["id"] == option["id"]
|
|
137
|
+
)
|
|
138
|
+
invalid_option["http"].pop(field)
|
|
139
|
+
with pytest.raises(ValidationError, match=field):
|
|
140
|
+
openhands_extensions.IntegrationCatalogEntry.model_validate(invalid)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def test_catalog_models_require_connectability_and_install_metadata() -> None:
|
|
144
|
+
entry = next(entry for entry in _catalog_entries() if entry["id"] == "slack")
|
|
145
|
+
|
|
146
|
+
missing_docs = deepcopy(entry)
|
|
147
|
+
missing_docs.pop("docsUrl")
|
|
148
|
+
with pytest.raises(ValidationError, match="docsUrl"):
|
|
149
|
+
openhands_extensions.IntegrationCatalogEntry.model_validate(missing_docs)
|
|
150
|
+
|
|
151
|
+
missing_field_type = deepcopy(entry)
|
|
152
|
+
missing_field_type["connectionOptions"][1]["transport"]["envFields"][0].pop("type")
|
|
153
|
+
with pytest.raises(ValidationError, match="type"):
|
|
154
|
+
openhands_extensions.IntegrationCatalogEntry.model_validate(missing_field_type)
|
|
155
|
+
|
|
156
|
+
missing_required_marker = deepcopy(entry)
|
|
157
|
+
missing_required_marker["connectionOptions"][1]["transport"]["envFields"][0].pop(
|
|
158
|
+
"required"
|
|
159
|
+
)
|
|
160
|
+
with pytest.raises(ValidationError, match="required"):
|
|
161
|
+
openhands_extensions.IntegrationCatalogEntry.model_validate(
|
|
162
|
+
missing_required_marker
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
missing_principal_mapping = deepcopy(entry)
|
|
166
|
+
missing_principal_mapping["connectionOptions"][0]["connectionModel"][
|
|
167
|
+
"identityMapping"
|
|
168
|
+
].pop("externalPrincipalIdPath")
|
|
169
|
+
with pytest.raises(ValidationError, match="externalPrincipalIdPath"):
|
|
170
|
+
openhands_extensions.IntegrationCatalogEntry.model_validate(
|
|
171
|
+
missing_principal_mapping
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
null_instead_of_omission = deepcopy(entry)
|
|
175
|
+
null_instead_of_omission["notes"] = None
|
|
176
|
+
with pytest.raises(ValidationError, match="must be omitted"):
|
|
177
|
+
openhands_extensions.IntegrationCatalogEntry.model_validate(
|
|
178
|
+
null_instead_of_omission
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
invalid_identity_path = deepcopy(entry)
|
|
182
|
+
invalid_identity_path["connectionOptions"][0]["connectionModel"]["identityMapping"][
|
|
183
|
+
"externalPrincipalIdPath"
|
|
184
|
+
] = "not/a/path"
|
|
185
|
+
with pytest.raises(ValidationError, match="pattern"):
|
|
186
|
+
openhands_extensions.IntegrationCatalogEntry.model_validate(
|
|
187
|
+
invalid_identity_path
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
87
191
|
def test_logo_metadata_is_serializable_and_language_agnostic() -> None:
|
|
88
192
|
entries = openhands_extensions.list_integration_catalog()
|
|
89
193
|
with_logo = [entry for entry in entries if entry.get("logoUrl")]
|
|
@@ -130,7 +234,9 @@ def test_js_reads_the_same_catalog_as_python() -> None:
|
|
|
130
234
|
"import { getIntegrationCatalogEntry } from './integrations/index.js';\n"
|
|
131
235
|
f"process.stdout.write(JSON.stringify(getIntegrationCatalogEntry({json.dumps(sample_id)})));"
|
|
132
236
|
)
|
|
133
|
-
assert json.loads(sample) == openhands_extensions.get_integration_catalog_entry(
|
|
237
|
+
assert json.loads(sample) == openhands_extensions.get_integration_catalog_entry(
|
|
238
|
+
sample_id
|
|
239
|
+
)
|
|
134
240
|
|
|
135
241
|
|
|
136
242
|
def _js_filter(mcp, oauth) -> list[str]:
|
|
@@ -155,7 +261,9 @@ def test_filter_mcp_only() -> None:
|
|
|
155
261
|
|
|
156
262
|
|
|
157
263
|
def test_filter_oauth_only() -> None:
|
|
158
|
-
py_ids = {
|
|
264
|
+
py_ids = {
|
|
265
|
+
e["id"] for e in openhands_extensions.list_integration_catalog(oauth=True)
|
|
266
|
+
}
|
|
159
267
|
js_ids = set(_js_filter("undefined", "true"))
|
|
160
268
|
all_oauth = {
|
|
161
269
|
e["id"]
|
|
@@ -168,7 +276,8 @@ def test_filter_oauth_only() -> None:
|
|
|
168
276
|
|
|
169
277
|
def test_filter_oauth_not_mcp() -> None:
|
|
170
278
|
py_ids = {
|
|
171
|
-
e["id"]
|
|
279
|
+
e["id"]
|
|
280
|
+
for e in openhands_extensions.list_integration_catalog(oauth=True, mcp=False)
|
|
172
281
|
}
|
|
173
282
|
js_ids = set(_js_filter("false", "true"))
|
|
174
283
|
expected = {
|
|
@@ -197,7 +306,9 @@ def test_accessors_return_independent_copies() -> None:
|
|
|
197
306
|
sample = openhands_extensions.get_integration_catalog_entry(sample_id)
|
|
198
307
|
assert sample is not None
|
|
199
308
|
sample["__mutated"] = True
|
|
200
|
-
assert "__mutated" not in openhands_extensions.get_integration_catalog_entry(
|
|
309
|
+
assert "__mutated" not in openhands_extensions.get_integration_catalog_entry(
|
|
310
|
+
sample_id
|
|
311
|
+
)
|
|
201
312
|
|
|
202
313
|
snapshot = openhands_extensions.INTEGRATION_CATALOG_SNAPSHOT
|
|
203
314
|
snapshot["integrations"][0]["__mutated"] = True
|
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
"""Live smoke tests for the daily-workflow integrations (OSS-5193).
|
|
2
|
+
|
|
3
|
+
These tests exercise the published catalog entries for GitHub, Linear, and
|
|
4
|
+
Slack against the real services, through the same client stack
|
|
5
|
+
(``openhands.sdk.mcp.create_mcp_tools``) that backs the agent-server's
|
|
6
|
+
``POST /api/mcp/test`` probe behind Agent Canvas's Test connection.
|
|
7
|
+
|
|
8
|
+
Every test is skipped unless real credentials are present in the environment,
|
|
9
|
+
so credential-less CI runs them as skips with zero network access. To run
|
|
10
|
+
live:
|
|
11
|
+
|
|
12
|
+
GITHUB_PERSONAL_ACCESS_TOKEN=... \
|
|
13
|
+
LINEAR_API_KEY=... \
|
|
14
|
+
SLACK_BOT_TOKEN=... SLACK_TEAM_ID=... \
|
|
15
|
+
uv run --group test pytest tests/test_live_integration_smoke.py -rA -s
|
|
16
|
+
|
|
17
|
+
Preconditions:
|
|
18
|
+
- The GitHub token needs the scopes stated in the catalog entry (classic:
|
|
19
|
+
``repo`` and ``read:user``).
|
|
20
|
+
- The Linear API key needs at least read access.
|
|
21
|
+
- The Slack bot must be a member of at least one channel with message history
|
|
22
|
+
(``/invite @your-bot``), and its token needs the scopes stated in the
|
|
23
|
+
catalog entry.
|
|
24
|
+
|
|
25
|
+
Evidence lines are printed as ``[OSS-5193] ...`` and contain only tool names,
|
|
26
|
+
counts, and provider error codes — never credentials or message content.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
import json
|
|
30
|
+
import os
|
|
31
|
+
from contextlib import contextmanager
|
|
32
|
+
|
|
33
|
+
import mcp.types
|
|
34
|
+
import pytest
|
|
35
|
+
from openhands.sdk.mcp import MCPError, MCPServer, MCPTimeoutError, create_mcp_tools
|
|
36
|
+
|
|
37
|
+
from openhands_extensions import get_integration_catalog_entry_model
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
GITHUB_TOKEN_ENV = "GITHUB_PERSONAL_ACCESS_TOKEN"
|
|
41
|
+
LINEAR_TOKEN_ENV = "LINEAR_API_KEY"
|
|
42
|
+
SLACK_ENV_KEYS = ("SLACK_TEAM_ID", "SLACK_BOT_TOKEN")
|
|
43
|
+
|
|
44
|
+
HTTP_TIMEOUT = 30.0
|
|
45
|
+
STDIO_TIMEOUT = 90.0 # a cold `npx -y` run downloads the Slack server first
|
|
46
|
+
|
|
47
|
+
# Auth failure codes the Agent Canvas interpreter reclassifies as credential
|
|
48
|
+
# failures (agent-canvas src/utils/mcp-credential-validation.ts).
|
|
49
|
+
SLACK_AUTH_FAILURES = {
|
|
50
|
+
"invalid_auth",
|
|
51
|
+
"not_authed",
|
|
52
|
+
"account_inactive",
|
|
53
|
+
"token_revoked",
|
|
54
|
+
"token_expired",
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
requires_github = pytest.mark.skipif(
|
|
58
|
+
not os.environ.get(GITHUB_TOKEN_ENV),
|
|
59
|
+
reason=f"set {GITHUB_TOKEN_ENV} to run the live GitHub MCP smoke test",
|
|
60
|
+
)
|
|
61
|
+
requires_linear = pytest.mark.skipif(
|
|
62
|
+
not os.environ.get(LINEAR_TOKEN_ENV),
|
|
63
|
+
reason=f"set {LINEAR_TOKEN_ENV} to run the live Linear MCP smoke test",
|
|
64
|
+
)
|
|
65
|
+
requires_slack = pytest.mark.skipif(
|
|
66
|
+
not all(os.environ.get(key) for key in SLACK_ENV_KEYS),
|
|
67
|
+
reason=f"set {' and '.join(SLACK_ENV_KEYS)} to run the live Slack MCP smoke test",
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _evidence(line):
|
|
72
|
+
print(f"[OSS-5193] {line}")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _installable_option(entry_id):
|
|
76
|
+
"""The option a local Agent Canvas install uses — the GUI filters out
|
|
77
|
+
provider-OAuth options (isLocallyInstallableMcpOption in agent-canvas)."""
|
|
78
|
+
entry = get_integration_catalog_entry_model(entry_id)
|
|
79
|
+
assert entry is not None, f"catalog entry {entry_id!r} missing"
|
|
80
|
+
for option in entry.connectionOptions:
|
|
81
|
+
if option.auth.strategy != "oauth2":
|
|
82
|
+
return option
|
|
83
|
+
raise AssertionError(f"{entry_id}: no locally installable connection option")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _remote_server(option, token):
|
|
87
|
+
return MCPServer.model_validate(
|
|
88
|
+
{
|
|
89
|
+
"url": option.transport.url,
|
|
90
|
+
"transport": "http",
|
|
91
|
+
"auth": {"strategy": option.auth.strategy, "value": token},
|
|
92
|
+
}
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _stdio_server(option, env):
|
|
97
|
+
return MCPServer.model_validate(
|
|
98
|
+
{
|
|
99
|
+
"transport": "stdio",
|
|
100
|
+
"command": option.transport.command,
|
|
101
|
+
"args": list(option.transport.args),
|
|
102
|
+
"env": env,
|
|
103
|
+
}
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@contextmanager
|
|
108
|
+
def _connect(server_id, server, timeout):
|
|
109
|
+
"""Yield ``(tool_names, call)`` through the same code path as the
|
|
110
|
+
agent-server probe (mcp_router._probe_mcp_server): connect, list tools,
|
|
111
|
+
invoke read-only tools, join TextContent blocks."""
|
|
112
|
+
with create_mcp_tools({server_id: server}, timeout=timeout) as client:
|
|
113
|
+
tool_names = [tool.name for tool in client.tools]
|
|
114
|
+
|
|
115
|
+
def call(name, arguments):
|
|
116
|
+
result = client.call_async_from_sync(
|
|
117
|
+
client.call_tool_mcp,
|
|
118
|
+
name=name,
|
|
119
|
+
arguments=arguments,
|
|
120
|
+
timeout=timeout,
|
|
121
|
+
)
|
|
122
|
+
text = "\n".join(
|
|
123
|
+
block.text
|
|
124
|
+
for block in result.content
|
|
125
|
+
if isinstance(block, mcp.types.TextContent)
|
|
126
|
+
)
|
|
127
|
+
return bool(result.isError), text
|
|
128
|
+
|
|
129
|
+
yield tool_names, call
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _first_advertised(tool_names, candidates):
|
|
133
|
+
for name, arguments in candidates:
|
|
134
|
+
if name in tool_names:
|
|
135
|
+
return name, arguments
|
|
136
|
+
pytest.fail(
|
|
137
|
+
"none of the candidate tools "
|
|
138
|
+
f"{sorted({name for name, _ in candidates})} are advertised; "
|
|
139
|
+
f"server offers: {sorted(tool_names)}"
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@requires_github
|
|
144
|
+
def test_github_identity_and_pr_read():
|
|
145
|
+
option = _installable_option("github")
|
|
146
|
+
# The env var doubles as the catalog's secret name; fail loudly on drift.
|
|
147
|
+
assert option.auth.credentialSecretName == GITHUB_TOKEN_ENV
|
|
148
|
+
server = _remote_server(option, os.environ[GITHUB_TOKEN_ENV])
|
|
149
|
+
|
|
150
|
+
with _connect("github", server, HTTP_TIMEOUT) as (tool_names, call):
|
|
151
|
+
assert "get_me" in tool_names, (
|
|
152
|
+
"Agent Canvas's credential probe calls get_me; "
|
|
153
|
+
f"server offers: {sorted(tool_names)}"
|
|
154
|
+
)
|
|
155
|
+
is_error, text = call("get_me", {})
|
|
156
|
+
assert not is_error, f"get_me failed: {text}"
|
|
157
|
+
assert "login" in text
|
|
158
|
+
_evidence(f"github: {len(tool_names)} tools; get_me ok (login present)")
|
|
159
|
+
|
|
160
|
+
# Representative PR lookup against a merged, permanently public PR in
|
|
161
|
+
# this very repo (OpenHands/extensions#408).
|
|
162
|
+
name, arguments = _first_advertised(
|
|
163
|
+
tool_names,
|
|
164
|
+
[
|
|
165
|
+
(
|
|
166
|
+
"pull_request_read",
|
|
167
|
+
{
|
|
168
|
+
"method": "get",
|
|
169
|
+
"owner": "OpenHands",
|
|
170
|
+
"repo": "extensions",
|
|
171
|
+
"pullNumber": 408,
|
|
172
|
+
},
|
|
173
|
+
),
|
|
174
|
+
(
|
|
175
|
+
"get_pull_request",
|
|
176
|
+
{"owner": "OpenHands", "repo": "extensions", "pullNumber": 408},
|
|
177
|
+
),
|
|
178
|
+
(
|
|
179
|
+
"search_pull_requests",
|
|
180
|
+
{"query": "repo:OpenHands/extensions is:pr 408"},
|
|
181
|
+
),
|
|
182
|
+
("list_pull_requests", {"owner": "OpenHands", "repo": "extensions"}),
|
|
183
|
+
],
|
|
184
|
+
)
|
|
185
|
+
is_error, text = call(name, arguments)
|
|
186
|
+
if is_error and "pullNumber" in arguments:
|
|
187
|
+
# Tolerate snake_case argument naming on the hosted server.
|
|
188
|
+
arguments = {
|
|
189
|
+
("pull_number" if key == "pullNumber" else key): value
|
|
190
|
+
for key, value in arguments.items()
|
|
191
|
+
}
|
|
192
|
+
is_error, text = call(name, arguments)
|
|
193
|
+
assert not is_error, f"{name} failed: {text}"
|
|
194
|
+
assert text.strip()
|
|
195
|
+
if "408" in json.dumps(arguments):
|
|
196
|
+
assert "408" in text, f"{name} response does not reference PR 408"
|
|
197
|
+
_evidence(f"github: PR lookup ok via {name}")
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@requires_github
|
|
201
|
+
def test_github_invalid_token_yields_structured_failure():
|
|
202
|
+
option = _installable_option("github")
|
|
203
|
+
bogus = "github_pat_invalid_oss5193"
|
|
204
|
+
server = _remote_server(option, bogus)
|
|
205
|
+
|
|
206
|
+
try:
|
|
207
|
+
with _connect("github-invalid", server, HTTP_TIMEOUT) as (tool_names, call):
|
|
208
|
+
# Some deployments accept the connection and fail on invocation.
|
|
209
|
+
if "get_me" in tool_names:
|
|
210
|
+
is_error, text = call("get_me", {})
|
|
211
|
+
else:
|
|
212
|
+
is_error, text = True, "get_me not advertised"
|
|
213
|
+
assert is_error, "expected an auth failure with an invalid token"
|
|
214
|
+
assert bogus not in text, "failure text must not echo the credential"
|
|
215
|
+
_evidence("github: invalid token -> tool-level error (is_error=true)")
|
|
216
|
+
except MCPTimeoutError:
|
|
217
|
+
pytest.fail("invalid GitHub token should fail fast, not time out")
|
|
218
|
+
except MCPError as exc:
|
|
219
|
+
detail = str(exc.__cause__ or exc.__context__ or exc)
|
|
220
|
+
assert bogus not in detail, "failure text must not echo the credential"
|
|
221
|
+
_evidence(
|
|
222
|
+
"github: invalid token -> connection-stage failure "
|
|
223
|
+
"(Canvas error_kind=connection)"
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
@requires_linear
|
|
228
|
+
def test_linear_teams_and_issue_read():
|
|
229
|
+
option = _installable_option("linear")
|
|
230
|
+
server = _remote_server(option, os.environ[LINEAR_TOKEN_ENV])
|
|
231
|
+
|
|
232
|
+
with _connect("linear", server, HTTP_TIMEOUT) as (tool_names, call):
|
|
233
|
+
assert "list_teams" in tool_names, (
|
|
234
|
+
"Agent Canvas's credential probe calls list_teams; "
|
|
235
|
+
f"server offers: {sorted(tool_names)}"
|
|
236
|
+
)
|
|
237
|
+
is_error, text = call("list_teams", {})
|
|
238
|
+
assert not is_error, f"list_teams failed: {text}"
|
|
239
|
+
_evidence(f"linear: {len(tool_names)} tools; list_teams ok")
|
|
240
|
+
|
|
241
|
+
name, arguments = _first_advertised(
|
|
242
|
+
tool_names,
|
|
243
|
+
[("list_issues", {"limit": 1}), ("list_my_issues", {"limit": 1})],
|
|
244
|
+
)
|
|
245
|
+
is_error, text = call(name, arguments)
|
|
246
|
+
assert not is_error, f"{name} failed: {text}"
|
|
247
|
+
assert text.strip()
|
|
248
|
+
_evidence(f"linear: issue read ok via {name}")
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
@requires_linear
|
|
252
|
+
def test_linear_invalid_token_yields_structured_failure():
|
|
253
|
+
option = _installable_option("linear")
|
|
254
|
+
bogus = "lin_api_invalid_oss5193"
|
|
255
|
+
server = _remote_server(option, bogus)
|
|
256
|
+
|
|
257
|
+
try:
|
|
258
|
+
with _connect("linear-invalid", server, HTTP_TIMEOUT) as (tool_names, call):
|
|
259
|
+
if "list_teams" in tool_names:
|
|
260
|
+
is_error, text = call("list_teams", {})
|
|
261
|
+
else:
|
|
262
|
+
is_error, text = True, "list_teams not advertised"
|
|
263
|
+
assert is_error, "expected an auth failure with an invalid token"
|
|
264
|
+
assert bogus not in text, "failure text must not echo the credential"
|
|
265
|
+
_evidence("linear: invalid token -> tool-level error (is_error=true)")
|
|
266
|
+
except MCPTimeoutError:
|
|
267
|
+
pytest.fail("invalid Linear token should fail fast, not time out")
|
|
268
|
+
except MCPError as exc:
|
|
269
|
+
detail = str(exc.__cause__ or exc.__context__ or exc)
|
|
270
|
+
assert bogus not in detail, "failure text must not echo the credential"
|
|
271
|
+
_evidence(
|
|
272
|
+
"linear: invalid token -> connection-stage failure "
|
|
273
|
+
"(Canvas error_kind=connection)"
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
@requires_slack
|
|
278
|
+
def test_slack_channel_and_thread_read():
|
|
279
|
+
option = _installable_option("slack")
|
|
280
|
+
env_keys = [field.key for field in option.transport.envFields or []]
|
|
281
|
+
assert set(env_keys) == set(SLACK_ENV_KEYS)
|
|
282
|
+
server = _stdio_server(option, {key: os.environ[key] for key in env_keys})
|
|
283
|
+
|
|
284
|
+
with _connect("slack", server, STDIO_TIMEOUT) as (tool_names, call):
|
|
285
|
+
for required in (
|
|
286
|
+
"slack_list_channels",
|
|
287
|
+
"slack_get_channel_history",
|
|
288
|
+
"slack_get_thread_replies",
|
|
289
|
+
):
|
|
290
|
+
assert required in tool_names, (
|
|
291
|
+
f"{required} not advertised; server offers: {sorted(tool_names)}"
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
is_error, text = call("slack_list_channels", {"limit": 5})
|
|
295
|
+
assert not is_error, f"slack_list_channels failed: {text}"
|
|
296
|
+
payload = json.loads(text)
|
|
297
|
+
assert payload.get("ok") is True, (
|
|
298
|
+
f"slack_list_channels not ok: {payload.get('error')}"
|
|
299
|
+
)
|
|
300
|
+
channels = payload.get("channels") or []
|
|
301
|
+
assert channels, "no channels visible to the bot"
|
|
302
|
+
_evidence(
|
|
303
|
+
f"slack: {len(tool_names)} tools; list_channels ok "
|
|
304
|
+
f"({len(channels)} channel(s))"
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
for channel in channels:
|
|
308
|
+
is_error, text = call(
|
|
309
|
+
"slack_get_channel_history",
|
|
310
|
+
{"channel_id": channel["id"], "limit": 10},
|
|
311
|
+
)
|
|
312
|
+
if is_error:
|
|
313
|
+
continue
|
|
314
|
+
history = json.loads(text)
|
|
315
|
+
if history.get("ok") is not True:
|
|
316
|
+
continue # e.g. not_in_channel — try the next channel
|
|
317
|
+
for message in history.get("messages") or []:
|
|
318
|
+
ts = message.get("thread_ts") or message.get("ts")
|
|
319
|
+
if not ts:
|
|
320
|
+
continue
|
|
321
|
+
is_error, text = call(
|
|
322
|
+
"slack_get_thread_replies",
|
|
323
|
+
{"channel_id": channel["id"], "thread_ts": ts},
|
|
324
|
+
)
|
|
325
|
+
assert not is_error, f"slack_get_thread_replies failed: {text}"
|
|
326
|
+
replies = json.loads(text)
|
|
327
|
+
assert replies.get("ok") is True, (
|
|
328
|
+
f"slack_get_thread_replies not ok: {replies.get('error')}"
|
|
329
|
+
)
|
|
330
|
+
_evidence(
|
|
331
|
+
"slack: channel history + thread read ok "
|
|
332
|
+
f"({len(replies.get('messages') or [])} message(s) in thread)"
|
|
333
|
+
)
|
|
334
|
+
return
|
|
335
|
+
pytest.fail(
|
|
336
|
+
"no channel with readable message history: invite the bot to a "
|
|
337
|
+
"public channel that has messages (/invite @your-bot) and rerun"
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
@requires_slack
|
|
342
|
+
def test_slack_invalid_token_yields_in_band_error():
|
|
343
|
+
option = _installable_option("slack")
|
|
344
|
+
bogus = "xoxb-invalid-oss5193"
|
|
345
|
+
server = _stdio_server(
|
|
346
|
+
option,
|
|
347
|
+
{"SLACK_TEAM_ID": os.environ["SLACK_TEAM_ID"], "SLACK_BOT_TOKEN": bogus},
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
with _connect("slack-invalid", server, STDIO_TIMEOUT) as (tool_names, call):
|
|
351
|
+
# The stdio server starts and lists tools with any token — the exact
|
|
352
|
+
# reason Agent Canvas interprets the in-band payload of a read-only
|
|
353
|
+
# tool call instead of trusting tools/list.
|
|
354
|
+
assert "slack_list_channels" in tool_names
|
|
355
|
+
is_error, text = call("slack_list_channels", {"limit": 1})
|
|
356
|
+
assert bogus not in text, "failure text must not echo the credential"
|
|
357
|
+
payload = json.loads(text)
|
|
358
|
+
assert payload.get("ok") is False
|
|
359
|
+
assert payload.get("error") in SLACK_AUTH_FAILURES, (
|
|
360
|
+
f"unexpected error code {payload.get('error')!r}; Canvas reclassifies "
|
|
361
|
+
f"only {sorted(SLACK_AUTH_FAILURES)} as credential failures"
|
|
362
|
+
)
|
|
363
|
+
_evidence(
|
|
364
|
+
f"slack: invalid token -> in-band ok=false error={payload.get('error')} "
|
|
365
|
+
f"(is_error={is_error})"
|
|
366
|
+
)
|