@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.
Files changed (38) hide show
  1. package/.github/workflows/deprecation-check.yml +25 -0
  2. package/.release-please-manifest.json +1 -1
  3. package/README.md +3 -2
  4. package/integrations/README.md +25 -0
  5. package/integrations/catalog/atlassian-rovo.json +44 -0
  6. package/integrations/catalog/atlassian.json +13 -0
  7. package/integrations/catalog/github.json +21 -1
  8. package/integrations/catalog/linear.json +1 -1
  9. package/integrations/catalog/microsoft-outlook.json +13 -0
  10. package/integrations/catalog/microsoft-teams.json +13 -0
  11. package/integrations/catalog/notion.json +13 -0
  12. package/integrations/catalog/onedrive.json +13 -0
  13. package/integrations/catalog/sharepoint.json +13 -0
  14. package/integrations/catalog/slack.json +16 -2
  15. package/integrations/catalog/vanta.json +55 -0
  16. package/integrations/catalog-index.js +71 -67
  17. package/integrations/catalog.schema.json +70 -3
  18. package/integrations/index.d.ts +49 -3
  19. package/marketplaces/openhands-extensions.json +9 -0
  20. package/package.json +1 -1
  21. package/pyproject.toml +6 -1
  22. package/python/openhands_extensions/__init__.py +38 -0
  23. package/python/openhands_extensions/_version.py +2 -2
  24. package/python/openhands_extensions/deprecation.py +159 -0
  25. package/python/openhands_extensions/integration_models.py +233 -0
  26. package/python/openhands_extensions/integrations.py +59 -4
  27. package/scripts/check_deprecations.py +488 -0
  28. package/skills/index.js +7 -1
  29. package/skills/iterate/README.md +4 -1
  30. package/skills/iterate/SKILL.md +22 -2
  31. package/skills/technical-writing/.plugin/plugin.json +15 -0
  32. package/skills/technical-writing/README.md +14 -0
  33. package/skills/technical-writing/SKILL.md +106 -0
  34. package/tests/test_catalog_schema.py +58 -0
  35. package/tests/test_catalogs.py +67 -0
  36. package/tests/test_deprecations.py +136 -0
  37. package/tests/test_integration_catalog_in_sync.py +118 -7
  38. 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(entries, key=lambda entry: (-(entry.get("popularityRank") if entry.get("popularityRank") is not None else -1), entry["id"]))
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(option.get("auth", {}).get("strategy") == "oauth2" for option in entry["connectionOptions"])
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(sample_id)
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 = {e["id"] for e in openhands_extensions.list_integration_catalog(oauth=True)}
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"] for e in openhands_extensions.list_integration_catalog(oauth=True, mcp=False)
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(sample_id)
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
+ )