runspec-console 0.44.2__tar.gz → 0.45.0__tar.gz

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 (96) hide show
  1. {runspec_console-0.44.2 → runspec_console-0.45.0}/CHANGELOG.md +27 -0
  2. {runspec_console-0.44.2 → runspec_console-0.45.0}/PKG-INFO +1 -1
  3. {runspec_console-0.44.2 → runspec_console-0.45.0}/pyproject.toml +1 -1
  4. {runspec_console-0.44.2 → runspec_console-0.45.0}/runspec_console/bridge.py +191 -2
  5. {runspec_console-0.44.2 → runspec_console-0.45.0}/runspec_console/sources/chat.py +4 -1
  6. {runspec_console-0.44.2 → runspec_console-0.45.0}/tests/test_chat_bridge.py +81 -0
  7. {runspec_console-0.44.2 → runspec_console-0.45.0}/.gitignore +0 -0
  8. {runspec_console-0.44.2 → runspec_console-0.45.0}/IMPL_NOTES.md +0 -0
  9. {runspec_console-0.44.2 → runspec_console-0.45.0}/README.md +0 -0
  10. {runspec_console-0.44.2 → runspec_console-0.45.0}/SECURITY.md +0 -0
  11. {runspec_console-0.44.2 → runspec_console-0.45.0}/dev_build.py +0 -0
  12. {runspec_console-0.44.2 → runspec_console-0.45.0}/examples/console-adapter-plugin/.github/prompts/create-console-plugin.prompt.md +0 -0
  13. {runspec_console-0.44.2 → runspec_console-0.45.0}/examples/console-adapter-plugin/AGENTS.md +0 -0
  14. {runspec_console-0.44.2 → runspec_console-0.45.0}/examples/console-adapter-plugin/README.md +0 -0
  15. {runspec_console-0.44.2 → runspec_console-0.45.0}/examples/console-adapter-plugin/console_adapter_plugin/__init__.py +0 -0
  16. {runspec_console-0.44.2 → runspec_console-0.45.0}/examples/console-adapter-plugin/console_adapter_plugin/adapter.py +0 -0
  17. {runspec_console-0.44.2 → runspec_console-0.45.0}/examples/console-adapter-plugin/pyproject.toml +0 -0
  18. {runspec_console-0.44.2 → runspec_console-0.45.0}/examples/console-adapter-plugin/tests/test_adapter_conformance.py +0 -0
  19. {runspec_console-0.44.2 → runspec_console-0.45.0}/runspec_console/__init__.py +0 -0
  20. {runspec_console-0.44.2 → runspec_console-0.45.0}/runspec_console/adapters/__init__.py +0 -0
  21. {runspec_console-0.44.2 → runspec_console-0.45.0}/runspec_console/adapters/anthropic.py +0 -0
  22. {runspec_console-0.44.2 → runspec_console-0.45.0}/runspec_console/adapters/base.py +0 -0
  23. {runspec_console-0.44.2 → runspec_console-0.45.0}/runspec_console/adapters/bedrock.py +0 -0
  24. {runspec_console-0.44.2 → runspec_console-0.45.0}/runspec_console/adapters/langserve.py +0 -0
  25. {runspec_console-0.44.2 → runspec_console-0.45.0}/runspec_console/adapters/openai.py +0 -0
  26. {runspec_console-0.44.2 → runspec_console-0.45.0}/runspec_console/adapters/testing.py +0 -0
  27. {runspec_console-0.44.2 → runspec_console-0.45.0}/runspec_console/app.py +0 -0
  28. {runspec_console-0.44.2 → runspec_console-0.45.0}/runspec_console/catalog.py +0 -0
  29. {runspec_console-0.44.2 → runspec_console-0.45.0}/runspec_console/config.py +0 -0
  30. {runspec_console-0.44.2 → runspec_console-0.45.0}/runspec_console/discovery.py +0 -0
  31. {runspec_console-0.44.2 → runspec_console-0.45.0}/runspec_console/events.py +0 -0
  32. {runspec_console-0.44.2 → runspec_console-0.45.0}/runspec_console/executor.py +0 -0
  33. {runspec_console-0.44.2 → runspec_console-0.45.0}/runspec_console/filters_store.py +0 -0
  34. {runspec_console-0.44.2 → runspec_console-0.45.0}/runspec_console/fs_browse.py +0 -0
  35. {runspec_console-0.44.2 → runspec_console-0.45.0}/runspec_console/fs_transfer.py +0 -0
  36. {runspec_console-0.44.2 → runspec_console-0.45.0}/runspec_console/hosts.py +0 -0
  37. {runspec_console-0.44.2 → runspec_console-0.45.0}/runspec_console/notify.py +0 -0
  38. {runspec_console-0.44.2 → runspec_console-0.45.0}/runspec_console/runspec.toml +0 -0
  39. {runspec_console-0.44.2 → runspec_console-0.45.0}/runspec_console/sources/__init__.py +0 -0
  40. {runspec_console-0.44.2 → runspec_console-0.45.0}/runspec_console/sources/chat_transport.py +0 -0
  41. {runspec_console-0.44.2 → runspec_console-0.45.0}/runspec_console/sources/notifications.py +0 -0
  42. {runspec_console-0.44.2 → runspec_console-0.45.0}/runspec_console/sources/outlook.py +0 -0
  43. {runspec_console-0.44.2 → runspec_console-0.45.0}/runspec_console/ssh_pool.py +0 -0
  44. {runspec_console-0.44.2 → runspec_console-0.45.0}/runspec_console/tools/__init__.py +0 -0
  45. {runspec_console-0.44.2 → runspec_console-0.45.0}/runspec_console/tools/generate_ssh_key.py +0 -0
  46. {runspec_console-0.44.2 → runspec_console-0.45.0}/runspec_console/trigger_runner.py +0 -0
  47. {runspec_console-0.44.2 → runspec_console-0.45.0}/runspec_console/triggers.py +0 -0
  48. {runspec_console-0.44.2 → runspec_console-0.45.0}/runspec_console/version.py +0 -0
  49. {runspec_console-0.44.2 → runspec_console-0.45.0}/tests/__init__.py +0 -0
  50. {runspec_console-0.44.2 → runspec_console-0.45.0}/tests/test_adapter_testing.py +0 -0
  51. {runspec_console-0.44.2 → runspec_console-0.45.0}/tests/test_analytics.py +0 -0
  52. {runspec_console-0.44.2 → runspec_console-0.45.0}/tests/test_anthropic_adapter.py +0 -0
  53. {runspec_console-0.44.2 → runspec_console-0.45.0}/tests/test_autonomy_gate.py +0 -0
  54. {runspec_console-0.44.2 → runspec_console-0.45.0}/tests/test_bridge_adapter.py +0 -0
  55. {runspec_console-0.44.2 → runspec_console-0.45.0}/tests/test_catalog.py +0 -0
  56. {runspec_console-0.44.2 → runspec_console-0.45.0}/tests/test_chat_history.py +0 -0
  57. {runspec_console-0.44.2 → runspec_console-0.45.0}/tests/test_chat_source.py +0 -0
  58. {runspec_console-0.44.2 → runspec_console-0.45.0}/tests/test_chat_transport.py +0 -0
  59. {runspec_console-0.44.2 → runspec_console-0.45.0}/tests/test_command_tools.py +0 -0
  60. {runspec_console-0.44.2 → runspec_console-0.45.0}/tests/test_config.py +0 -0
  61. {runspec_console-0.44.2 → runspec_console-0.45.0}/tests/test_copy_ssh_id.py +0 -0
  62. {runspec_console-0.44.2 → runspec_console-0.45.0}/tests/test_devtools.py +0 -0
  63. {runspec_console-0.44.2 → runspec_console-0.45.0}/tests/test_discovery_globals.py +0 -0
  64. {runspec_console-0.44.2 → runspec_console-0.45.0}/tests/test_dispatch_queue.py +0 -0
  65. {runspec_console-0.44.2 → runspec_console-0.45.0}/tests/test_draft_send.py +0 -0
  66. {runspec_console-0.44.2 → runspec_console-0.45.0}/tests/test_events.py +0 -0
  67. {runspec_console-0.44.2 → runspec_console-0.45.0}/tests/test_executor_ssh.py +0 -0
  68. {runspec_console-0.44.2 → runspec_console-0.45.0}/tests/test_filters_store.py +0 -0
  69. {runspec_console-0.44.2 → runspec_console-0.45.0}/tests/test_fs_browse.py +0 -0
  70. {runspec_console-0.44.2 → runspec_console-0.45.0}/tests/test_fs_transfer.py +0 -0
  71. {runspec_console-0.44.2 → runspec_console-0.45.0}/tests/test_key_pubkey.py +0 -0
  72. {runspec_console-0.44.2 → runspec_console-0.45.0}/tests/test_langserve_adapter.py +0 -0
  73. {runspec_console-0.44.2 → runspec_console-0.45.0}/tests/test_list_dir_unsaved.py +0 -0
  74. {runspec_console-0.44.2 → runspec_console-0.45.0}/tests/test_logs_management.py +0 -0
  75. {runspec_console-0.44.2 → runspec_console-0.45.0}/tests/test_notifications.py +0 -0
  76. {runspec_console-0.44.2 → runspec_console-0.45.0}/tests/test_notify.py +0 -0
  77. {runspec_console-0.44.2 → runspec_console-0.45.0}/tests/test_outlook.py +0 -0
  78. {runspec_console-0.44.2 → runspec_console-0.45.0}/tests/test_path_normalize.py +0 -0
  79. {runspec_console-0.44.2 → runspec_console-0.45.0}/tests/test_plugin_adapters.py +0 -0
  80. {runspec_console-0.44.2 → runspec_console-0.45.0}/tests/test_positional_args.py +0 -0
  81. {runspec_console-0.44.2 → runspec_console-0.45.0}/tests/test_prompt_caching.py +0 -0
  82. {runspec_console-0.44.2 → runspec_console-0.45.0}/tests/test_refresh_cycle.py +0 -0
  83. {runspec_console-0.44.2 → runspec_console-0.45.0}/tests/test_relay_filter.py +0 -0
  84. {runspec_console-0.44.2 → runspec_console-0.45.0}/tests/test_remote_command_injection.py +0 -0
  85. {runspec_console-0.44.2 → runspec_console-0.45.0}/tests/test_rotate_ssh_key.py +0 -0
  86. {runspec_console-0.44.2 → runspec_console-0.45.0}/tests/test_save_config_pool.py +0 -0
  87. {runspec_console-0.44.2 → runspec_console-0.45.0}/tests/test_secret_args.py +0 -0
  88. {runspec_console-0.44.2 → runspec_console-0.45.0}/tests/test_shutdown_abort.py +0 -0
  89. {runspec_console-0.44.2 → runspec_console-0.45.0}/tests/test_ssh_logging.py +0 -0
  90. {runspec_console-0.44.2 → runspec_console-0.45.0}/tests/test_ssh_pool.py +0 -0
  91. {runspec_console-0.44.2 → runspec_console-0.45.0}/tests/test_terminal.py +0 -0
  92. {runspec_console-0.44.2 → runspec_console-0.45.0}/tests/test_trigger_config.py +0 -0
  93. {runspec_console-0.44.2 → runspec_console-0.45.0}/tests/test_trigger_runner.py +0 -0
  94. {runspec_console-0.44.2 → runspec_console-0.45.0}/tests/test_triggers.py +0 -0
  95. {runspec_console-0.44.2 → runspec_console-0.45.0}/tests/test_usage.py +0 -0
  96. {runspec_console-0.44.2 → runspec_console-0.45.0}/tests/test_version.py +0 -0
@@ -8,6 +8,33 @@ Version numbers follow [Semantic Versioning](https://semver.org/).
8
8
  ---
9
9
 
10
10
 
11
+ ## [0.45.0] — 2026-06-12
12
+
13
+ ### Added
14
+ - **Room connection tester (Rooms tab → "Test").** Diagnoses a room/queue's
15
+ connection on demand over the *real* paramiko path the supervisor uses (HTTP
16
+ proxy, key, ssh_config, then the tunnel or exec relay), in an isolated attempt
17
+ that ignores the pool's connect-backoff and doesn't disturb the live
18
+ connection. It reports the actual blocker with a localised stage —
19
+ **ssh** (reach/auth the host, incl. the proxy), **channel** (tunnel/exec
20
+ relay), or **room** (the relay reached the host but the room server isn't
21
+ answering on the port) — plus an actionable hint, in a dialog. No more reading
22
+ tracebacks or fumbling with a local `ssh` command (which exercises OpenSSH
23
+ ControlMaster/config the console never uses).
24
+
25
+ ### Changed
26
+ - **Blocked SSH port-forwarding now logs one actionable line, not a repeating
27
+ traceback.** A host with `AllowTcpForwarding no` refuses the direct-tcpip room
28
+ tunnel (`Administratively prohibited`) — a permanent condition the reconnect
29
+ loop would otherwise re-report as a full traceback every backoff. The
30
+ supervisor now detects it and points at the fix (`room_via = "exec"`); the same
31
+ classification powers the connection tester.
32
+ - **Agent-mode chat source logs its queue.** `listening on queue 'helpdesk/<q>'`
33
+ instead of the empty `listening in room ''` (an agent subscribes to a namespace,
34
+ not a room).
35
+
36
+ ---
37
+
11
38
  ## [0.44.2] — 2026-06-12
12
39
 
13
40
  ### Changed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: runspec-console
3
- Version: 0.44.2
3
+ Version: 0.45.0
4
4
  Requires-Python: >=3.11
5
5
  Requires-Dist: cryptography>=42.0
6
6
  Requires-Dist: paramiko>=3.0
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "runspec-console"
7
- version = "0.44.2"
7
+ version = "0.45.0"
8
8
  requires-python = ">=3.11"
9
9
  dependencies = [
10
10
  "pywebview>=5.0",
@@ -150,6 +150,71 @@ class _BridgeTriggerOps:
150
150
  )
151
151
 
152
152
 
153
+ def _chat_connect_hint(exc: BaseException, *, via: str) -> str | None:
154
+ """A concise, actionable message for a known-*permanent* chat-connect failure,
155
+ or ``None`` to fall back to a full traceback.
156
+
157
+ ``Administratively prohibited`` on a ``direct-tcpip`` channel means the host
158
+ refuses SSH port-forwarding (``AllowTcpForwarding no``) — retrying the tunnel
159
+ will never succeed, so a 20-line traceback every backoff is just noise. The
160
+ fix is the exec relay, so point the operator straight at it."""
161
+ if via != "exec" and "Administratively prohibited" in str(exc):
162
+ return (
163
+ "SSH port-forwarding is blocked on this host (Administratively "
164
+ 'prohibited). Set room_via = "exec" on the host entry to reach the '
165
+ "room over an exec channel (console-room connect) instead of a tunnel."
166
+ )
167
+ return None
168
+
169
+
170
+ def _classify_room_connection(exc: BaseException | None, *, via: str) -> dict[str, Any]:
171
+ """Turn a room-connect attempt's outcome into a structured, human-readable
172
+ result for the UI's connection tester: ``{ok, stage, message, hint}``.
173
+
174
+ ``stage`` localises the failure — ``ssh`` (reaching/authing the host, incl.
175
+ the HTTP proxy), ``channel`` (opening the tunnel / exec relay), ``room`` (the
176
+ relay reached the host but not the room server), or ``ok``. ``hint`` is an
177
+ actionable next step when we recognise the failure."""
178
+ if exc is None:
179
+ return {"ok": True, "stage": "ok", "message": "", "hint": None}
180
+ text = str(exc) or type(exc).__name__
181
+ low = text.lower()
182
+ if "in connect-backoff" in low:
183
+ return {
184
+ "ok": False,
185
+ "stage": "ssh",
186
+ "message": f"Host is in connect-backoff after recent failures ({text}). The always-on reconnect loop is already retrying — try again in a moment.",
187
+ "hint": None,
188
+ }
189
+ if isinstance(exc, TimeoutError) or "timed out" in low or "timeout" in low:
190
+ return {
191
+ "ok": False,
192
+ "stage": "ssh",
193
+ "message": "Timed out reaching the host — the SSH connection (through the HTTP proxy, if configured) didn't complete in time.",
194
+ "hint": "If the proxy works for discovery/transfers, this is usually transient at startup (many connections open at once) — retry. If it persists, check the [ssh] proxy host:port is reachable.",
195
+ }
196
+ if "administratively prohibited" in low:
197
+ return {
198
+ "ok": False,
199
+ "stage": "channel",
200
+ "message": "The host refused the tunnel — SSH port-forwarding is disabled (AllowTcpForwarding no).",
201
+ "hint": _chat_connect_hint(exc, via=via),
202
+ }
203
+ if "authentication" in low or "not a valid" in low or "no authentication" in low:
204
+ return {
205
+ "ok": False,
206
+ "stage": "ssh",
207
+ "message": f"SSH authentication failed ({text}). Check this host's key / identityFile.",
208
+ "hint": None,
209
+ }
210
+ return {
211
+ "ok": False,
212
+ "stage": "ssh",
213
+ "message": text,
214
+ "hint": _chat_connect_hint(exc, via=via),
215
+ }
216
+
217
+
153
218
  class _RotaPresenceWatcher:
154
219
  """Pushes console presence to the chat room as the working-hours rota flips.
155
220
 
@@ -5054,8 +5119,15 @@ class Bridge:
5054
5119
  )
5055
5120
  try:
5056
5121
  source.start(self._on_chat_event)
5057
- except Exception:
5058
- logger.exception("chat: connect to room %r failed", room)
5122
+ except Exception as exc:
5123
+ # A blocked-forwarding failure is permanent — log one actionable
5124
+ # line, not a fresh traceback every backoff. Genuinely unexpected
5125
+ # errors still get the full traceback.
5126
+ hint = _chat_connect_hint(exc, via=str(spec.get("via") or "tunnel"))
5127
+ if hint:
5128
+ logger.error("chat: room %r — %s", room, hint)
5129
+ else:
5130
+ logger.exception("chat: connect to room %r failed", room)
5059
5131
  if self._chat_stop.wait(backoff):
5060
5132
  break
5061
5133
  backoff = min(backoff * 2, self._CHAT_BACKOFF_MAX)
@@ -5291,6 +5363,123 @@ class Bridge:
5291
5363
  rooms.append(entry)
5292
5364
  return {"rooms": rooms}
5293
5365
 
5366
+ def test_room_connection(self, host: str) -> dict[str, Any]:
5367
+ """Diagnose a room/queue's connection the way the supervisor reaches it —
5368
+ an isolated, fresh attempt (no pool backoff, no effect on the live
5369
+ connection) over the *same* paramiko path (HTTP proxy, key, ssh_config),
5370
+ so the UI can show the real outcome instead of a buried traceback.
5371
+
5372
+ Steps, matching the transport: connect+auth to the host (the ``ssh``
5373
+ stage — where the proxy timeout surfaces), open the tunnel / exec relay
5374
+ (``channel`` — where blocked forwarding surfaces), and for exec confirm
5375
+ the relay didn't immediately exit (``room`` — the room server is actually
5376
+ listening). Returns the ``_classify_room_connection`` shape plus the host
5377
+ / room / queue it tested."""
5378
+ from .executor import _make_ssh_client
5379
+
5380
+ specs = [
5381
+ s
5382
+ for s in self._room_specs()
5383
+ if s.get("host") == host or s.get("ssh_target") == host
5384
+ ]
5385
+ if not specs:
5386
+ return {
5387
+ "ok": False,
5388
+ "stage": "config",
5389
+ "host": host,
5390
+ "message": f"No room or queue is configured on host {host!r}.",
5391
+ "hint": "Add room/room_port (group chat) or room_queue/room_port (help desk) to this host.",
5392
+ }
5393
+ spec = specs[0]
5394
+ cfg = self.get_config().get("ssh")
5395
+ cfg = cfg if isinstance(cfg, dict) else None
5396
+ via = str(spec.get("via") or "tunnel")
5397
+ meta = {
5398
+ "host": host,
5399
+ "room": spec["room"],
5400
+ "queue": str(spec.get("queue") or ""),
5401
+ }
5402
+
5403
+ client = None
5404
+ chan = None
5405
+ try:
5406
+ try:
5407
+ client = _make_ssh_client(
5408
+ str(spec["ssh_target"]), spec.get("identity_file") or None, cfg
5409
+ )
5410
+ except Exception as exc: # noqa: BLE001 — report, don't raise
5411
+ return {**_classify_room_connection(exc, via=via), **meta}
5412
+
5413
+ transport = client.get_transport()
5414
+ if transport is None:
5415
+ return {
5416
+ **_classify_room_connection(OSError("transport closed"), via=via),
5417
+ **meta,
5418
+ }
5419
+ try:
5420
+ if via == "exec":
5421
+ chan = transport.open_session()
5422
+ chan.exec_command(str(spec["exec_command"]))
5423
+ else:
5424
+ chan = transport.open_channel(
5425
+ "direct-tcpip",
5426
+ (str(spec.get("dest_host") or "127.0.0.1"), int(spec["port"])),
5427
+ ("127.0.0.1", 0),
5428
+ )
5429
+ except Exception as exc: # noqa: BLE001
5430
+ return {**_classify_room_connection(exc, via=via), **meta}
5431
+
5432
+ # Exec: the relay (console-room connect) connects to localhost:port on
5433
+ # the host. If the room server is down it exits straight away — catch
5434
+ # that so "host reachable but room down" isn't reported as success.
5435
+ if via == "exec":
5436
+ time.sleep(1.5)
5437
+ if chan.exit_status_ready():
5438
+ err = ""
5439
+ try:
5440
+ if chan.recv_stderr_ready():
5441
+ err = (
5442
+ chan.recv_stderr(2048)
5443
+ .decode("utf-8", "replace")
5444
+ .strip()
5445
+ )
5446
+ except Exception:
5447
+ pass
5448
+ return {
5449
+ **meta,
5450
+ "ok": False,
5451
+ "stage": "room",
5452
+ "message": (
5453
+ f"Reached the host, but the relay exited — the room "
5454
+ f"server isn't answering on port {spec['port']} "
5455
+ f"(is console-room running there?)."
5456
+ + (f" [{err}]" if err else "")
5457
+ ),
5458
+ "hint": "Start the room server on the host (console-room) and confirm its --ndjson-port matches this host's room_port.",
5459
+ }
5460
+
5461
+ return {
5462
+ **meta,
5463
+ "ok": True,
5464
+ "stage": "ok",
5465
+ "message": (
5466
+ f"Connected to the room on {host} (port {spec['port']}, via {via})."
5467
+ + (f" Queue {spec['queue']}." if spec.get("queue") else "")
5468
+ ),
5469
+ "hint": None,
5470
+ }
5471
+ finally:
5472
+ try:
5473
+ if chan is not None:
5474
+ chan.close()
5475
+ except Exception:
5476
+ pass
5477
+ try:
5478
+ if client is not None:
5479
+ client.close()
5480
+ except Exception:
5481
+ pass
5482
+
5294
5483
  def _abort_turns(self, ids: set[str] | None) -> None:
5295
5484
  """Unblock in-flight turns: signal each cancel flag and wake each pending
5296
5485
  autonomy-gate confirm (as cancelled). ``ids=None`` aborts everything
@@ -335,7 +335,10 @@ class ChatSource:
335
335
  transport = self._transport
336
336
  if transport is None:
337
337
  return
338
- logger.info("chat source: listening in room %r", self._room)
338
+ if self.is_agent:
339
+ logger.info("chat source: listening on queue %r", self._namespace)
340
+ else:
341
+ logger.info("chat source: listening in room %r", self._room)
339
342
  while not self._stop.is_set():
340
343
  try:
341
344
  obj = transport.recv()
@@ -639,3 +639,84 @@ def test_propose_reply_rejects_empty_text():
639
639
  _propose_stub()._gated_propose_reply({"text": " "}, "card1", "ops")
640
640
  )
641
641
  assert out.startswith("✗") and "empty" in out
642
+
643
+
644
+ # ── connect-error hint (blocked port-forwarding → exec) ────────────────────────
645
+
646
+
647
+ def test_chat_connect_hint_for_blocked_forwarding():
648
+ from runspec_console.bridge import _chat_connect_hint
649
+
650
+ exc = Exception("ChannelException(1, 'Administratively prohibited')")
651
+ hint = _chat_connect_hint(exc, via="tunnel")
652
+ assert hint is not None and 'room_via = "exec"' in hint
653
+
654
+
655
+ def test_chat_connect_hint_none_in_exec_mode():
656
+ from runspec_console.bridge import _chat_connect_hint
657
+
658
+ # Already on exec → the prohibited-forwarding hint doesn't apply.
659
+ exc = Exception("ChannelException(1, 'Administratively prohibited')")
660
+ assert _chat_connect_hint(exc, via="exec") is None
661
+
662
+
663
+ def test_chat_connect_hint_none_for_other_errors():
664
+ from runspec_console.bridge import _chat_connect_hint
665
+
666
+ assert _chat_connect_hint(TimeoutError("timed out"), via="tunnel") is None
667
+
668
+
669
+ # ── room connection tester (classifier) ────────────────────────────────────────
670
+
671
+
672
+ def test_classify_room_connection_success():
673
+ from runspec_console.bridge import _classify_room_connection
674
+
675
+ r = _classify_room_connection(None, via="exec")
676
+ assert r["ok"] and r["stage"] == "ok"
677
+
678
+
679
+ def test_classify_room_connection_proxy_timeout():
680
+ from runspec_console.bridge import _classify_room_connection
681
+
682
+ r = _classify_room_connection(TimeoutError("timed out"), via="exec")
683
+ assert not r["ok"] and r["stage"] == "ssh"
684
+ assert "proxy" in r["hint"].lower()
685
+
686
+
687
+ def test_classify_room_connection_blocked_forwarding():
688
+ from runspec_console.bridge import _classify_room_connection
689
+
690
+ exc = Exception("ChannelException(1, 'Administratively prohibited')")
691
+ r = _classify_room_connection(exc, via="tunnel")
692
+ assert not r["ok"] and r["stage"] == "channel"
693
+ assert 'room_via = "exec"' in r["hint"]
694
+
695
+
696
+ def test_classify_room_connection_backoff():
697
+ from runspec_console.bridge import _classify_room_connection
698
+
699
+ from runspec_console.ssh_pool import BackoffError
700
+
701
+ r = _classify_room_connection(
702
+ BackoffError("host in connect-backoff for 4s"), via="exec"
703
+ )
704
+ assert not r["ok"] and r["stage"] == "ssh" and "backoff" in r["message"].lower()
705
+
706
+
707
+ def test_classify_room_connection_auth():
708
+ from runspec_console.bridge import _classify_room_connection
709
+
710
+ r = _classify_room_connection(Exception("Authentication failed."), via="exec")
711
+ assert not r["ok"] and r["stage"] == "ssh" and "identityFile" in r["message"]
712
+
713
+
714
+ def test_test_room_connection_unconfigured_host():
715
+ from runspec_console.bridge import Bridge
716
+
717
+ s = types.SimpleNamespace()
718
+ s._room_specs = lambda: []
719
+ s.get_config = lambda: {}
720
+ s.test_room_connection = types.MethodType(Bridge.test_room_connection, s)
721
+ r = s.test_room_connection("nope")
722
+ assert not r["ok"] and r["stage"] == "config"