mcp-switchboard-client 0.3.0.dev3__tar.gz → 0.3.0.dev4__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 (25) hide show
  1. mcp_switchboard_client-0.3.0.dev4/.gitignore +15 -0
  2. {mcp_switchboard_client-0.3.0.dev3 → mcp_switchboard_client-0.3.0.dev4}/PKG-INFO +19 -1
  3. {mcp_switchboard_client-0.3.0.dev3 → mcp_switchboard_client-0.3.0.dev4}/README.md +18 -0
  4. {mcp_switchboard_client-0.3.0.dev3 → mcp_switchboard_client-0.3.0.dev4}/pyproject.toml +1 -1
  5. {mcp_switchboard_client-0.3.0.dev3 → mcp_switchboard_client-0.3.0.dev4}/src/mcp_switchboard_client/cli.py +41 -2
  6. mcp_switchboard_client-0.3.0.dev4/src/mcp_switchboard_client/environment.py +469 -0
  7. {mcp_switchboard_client-0.3.0.dev3 → mcp_switchboard_client-0.3.0.dev4}/src/mcp_switchboard_client/protocol.py +30 -4
  8. {mcp_switchboard_client-0.3.0.dev3 → mcp_switchboard_client-0.3.0.dev4}/src/mcp_switchboard_client/supervisor.py +15 -1
  9. {mcp_switchboard_client-0.3.0.dev3 → mcp_switchboard_client-0.3.0.dev4}/src/mcp_switchboard_client/tunnel.py +190 -5
  10. mcp_switchboard_client-0.3.0.dev4/tests/test_env_brief.py +104 -0
  11. mcp_switchboard_client-0.3.0.dev4/tests/test_instructions.py +222 -0
  12. {mcp_switchboard_client-0.3.0.dev3 → mcp_switchboard_client-0.3.0.dev4}/tests/test_protocol_conformance.py +33 -9
  13. {mcp_switchboard_client-0.3.0.dev3 → mcp_switchboard_client-0.3.0.dev4}/tests/test_settings.py +53 -0
  14. {mcp_switchboard_client-0.3.0.dev3 → mcp_switchboard_client-0.3.0.dev4}/tests/test_supervisor.py +22 -0
  15. {mcp_switchboard_client-0.3.0.dev3 → mcp_switchboard_client-0.3.0.dev4}/tests/test_tunnel.py +103 -0
  16. mcp_switchboard_client-0.3.0.dev3/.gitignore +0 -18
  17. mcp_switchboard_client-0.3.0.dev3/src/mcp_switchboard_client/environment.py +0 -202
  18. {mcp_switchboard_client-0.3.0.dev3 → mcp_switchboard_client-0.3.0.dev4}/src/mcp_switchboard_client/__init__.py +0 -0
  19. {mcp_switchboard_client-0.3.0.dev3 → mcp_switchboard_client-0.3.0.dev4}/src/mcp_switchboard_client/__main__.py +0 -0
  20. {mcp_switchboard_client-0.3.0.dev3 → mcp_switchboard_client-0.3.0.dev4}/src/mcp_switchboard_client/config.py +0 -0
  21. {mcp_switchboard_client-0.3.0.dev3 → mcp_switchboard_client-0.3.0.dev4}/src/mcp_switchboard_client/envconf.py +0 -0
  22. {mcp_switchboard_client-0.3.0.dev3 → mcp_switchboard_client-0.3.0.dev4}/tests/conftest.py +0 -0
  23. {mcp_switchboard_client-0.3.0.dev3 → mcp_switchboard_client-0.3.0.dev4}/tests/test_config.py +0 -0
  24. {mcp_switchboard_client-0.3.0.dev3 → mcp_switchboard_client-0.3.0.dev4}/tests/test_envconf_cases.py +0 -0
  25. {mcp_switchboard_client-0.3.0.dev3 → mcp_switchboard_client-0.3.0.dev4}/tests/test_environment.py +0 -0
@@ -0,0 +1,15 @@
1
+ __pycache__/
2
+ *.pyc
3
+ *.egg-info/
4
+ .venv/
5
+ venv/
6
+ build/
7
+ dist/
8
+ .pytest_cache/
9
+ .mypy_cache/
10
+ .ruff_cache/
11
+ .env
12
+ result
13
+ result-*
14
+ .direnv/
15
+ hub/mcp-switchboard-hub
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: mcp-switchboard-client
3
- Version: 0.3.0.dev3
3
+ Version: 0.3.0.dev4
4
4
  Summary: Tunnels local stdio MCP servers to an mcp-switchboard hub over one outbound WebSocket
5
5
  Project-URL: Homepage, https://github.com/AkosPapp/mcp-switchboard
6
6
  Project-URL: Repository, https://github.com/AkosPapp/mcp-switchboard
@@ -69,6 +69,24 @@ the client in. Turn it off with
69
69
  `--no-harness` or `MCP_SWITCHBOARD_HARNESS=false`; an `mcp.json` entry named
70
70
  `harness` replaces it.
71
71
 
72
+ The client also collects the instruction files your repository carries for coding
73
+ agents (`AGENTS.md`, `CLAUDE.md`, `.cursorrules`, `.github/copilot-instructions.md`,
74
+ also at any depth, capped at 8 files / 64 KiB) from the start directory and ships them
75
+ to the hub, which puts them into the agent's system prompt; it re-sends them whenever
76
+ they change, so an edit to `AGENTS.md` reaches a running session. Turn it off with
77
+ `--no-instructions` or `MCP_SWITCHBOARD_INSTRUCTIONS=false`.
78
+
79
+ A push is treated as what it is: the harness marks `git_push` irreversible in its tool `_meta`, so
80
+ the hub asks for approval before any agent can run it — even one set to `approval=never`. If that
81
+ friction is wrong for your deployment, that is a decision to make deliberately, not by silence.
82
+
83
+ Alongside them the client ships a short environment brief of the host your tools run
84
+ on — user, hostname, git branch/dirtiness (remote with credentials redacted), what the
85
+ file tools may write, tool presence; sudo is reported only if provable without a
86
+ prompt, and network reachability is never probed — refreshed like the instruction
87
+ files and marked stale by the hub if a client vanishes. Turn it off with
88
+ `--no-env-brief` or `MCP_SWITCHBOARD_ENV_BRIEF=false`.
89
+
72
90
  Settings can also come from `MCP_SWITCHBOARD_*` environment variables or a
73
91
  `.env` file (`HUB_URL`, `TUNNEL_TOKEN`, `LABEL`, `CONFIG`, …). Any value that
74
92
  starts with `/` and points at an existing regular file is read from that file, so
@@ -47,6 +47,24 @@ the client in. Turn it off with
47
47
  `--no-harness` or `MCP_SWITCHBOARD_HARNESS=false`; an `mcp.json` entry named
48
48
  `harness` replaces it.
49
49
 
50
+ The client also collects the instruction files your repository carries for coding
51
+ agents (`AGENTS.md`, `CLAUDE.md`, `.cursorrules`, `.github/copilot-instructions.md`,
52
+ also at any depth, capped at 8 files / 64 KiB) from the start directory and ships them
53
+ to the hub, which puts them into the agent's system prompt; it re-sends them whenever
54
+ they change, so an edit to `AGENTS.md` reaches a running session. Turn it off with
55
+ `--no-instructions` or `MCP_SWITCHBOARD_INSTRUCTIONS=false`.
56
+
57
+ A push is treated as what it is: the harness marks `git_push` irreversible in its tool `_meta`, so
58
+ the hub asks for approval before any agent can run it — even one set to `approval=never`. If that
59
+ friction is wrong for your deployment, that is a decision to make deliberately, not by silence.
60
+
61
+ Alongside them the client ships a short environment brief of the host your tools run
62
+ on — user, hostname, git branch/dirtiness (remote with credentials redacted), what the
63
+ file tools may write, tool presence; sudo is reported only if provable without a
64
+ prompt, and network reachability is never probed — refreshed like the instruction
65
+ files and marked stale by the hub if a client vanishes. Turn it off with
66
+ `--no-env-brief` or `MCP_SWITCHBOARD_ENV_BRIEF=false`.
67
+
50
68
  Settings can also come from `MCP_SWITCHBOARD_*` environment variables or a
51
69
  `.env` file (`HUB_URL`, `TUNNEL_TOKEN`, `LABEL`, `CONFIG`, …). Any value that
52
70
  starts with `/` and points at an existing regular file is read from that file, so
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "mcp-switchboard-client"
7
- version = "0.3.0.dev3"
7
+ version = "0.3.0.dev4"
8
8
  description = "Tunnels local stdio MCP servers to an mcp-switchboard hub over one outbound WebSocket"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -54,6 +54,8 @@ class Settings:
54
54
  harness: bool = True
55
55
  project_name: Optional[str] = None
56
56
  environment: Optional[Dict[str, Any]] = None # detected at load_settings
57
+ instruction_root: Optional[str] = None # cwd by default; None disables shipping
58
+ env_brief: bool = True # host brief in hello + context_update
57
59
 
58
60
  def tunnel_settings(self) -> TunnelSettings:
59
61
  return TunnelSettings(
@@ -63,6 +65,8 @@ class Settings:
63
65
  reconnect_delay=self.reconnect_delay,
64
66
  max_retries=self.max_retries,
65
67
  environment=self.environment,
68
+ instruction_root=self.instruction_root,
69
+ env_brief=self.env_brief,
66
70
  )
67
71
 
68
72
 
@@ -150,6 +154,22 @@ def build_parser() -> argparse.ArgumentParser:
150
154
  default=None,
151
155
  help=f"Log level (default {DEFAULT_LOG_LEVEL}, or {envconf.PREFIX}LOG_LEVEL)",
152
156
  )
157
+ parser.add_argument(
158
+ "--no-instructions",
159
+ action="store_true",
160
+ help=(
161
+ "Do not collect instruction files (AGENTS.md, CLAUDE.md, ...) from the working "
162
+ f"directory and send them to the hub (or set {envconf.PREFIX}INSTRUCTIONS=false)"
163
+ ),
164
+ )
165
+ parser.add_argument(
166
+ "--no-env-brief",
167
+ action="store_true",
168
+ help=(
169
+ "Do not send the host environment brief (identity, host, git state, writable set, "
170
+ f"tool presence) to the hub (or set {envconf.PREFIX}ENV_BRIEF=false)"
171
+ ),
172
+ )
153
173
  return parser
154
174
 
155
175
 
@@ -231,6 +251,12 @@ def load_settings(args: argparse.Namespace) -> Settings:
231
251
  harness=not args.no_harness and envconf.get_bool("HARNESS", True),
232
252
  project_name=project_name,
233
253
  environment=environment.detect(project_override=project_name),
254
+ instruction_root=(
255
+ None
256
+ if args.no_instructions or not envconf.get_bool("INSTRUCTIONS", True)
257
+ else str(Path.cwd())
258
+ ),
259
+ env_brief=not args.no_env_brief and envconf.get_bool("ENV_BRIEF", True),
234
260
  )
235
261
 
236
262
 
@@ -271,7 +297,8 @@ def setup_logging(level: str) -> None:
271
297
  )
272
298
 
273
299
 
274
- async def _run(settings: Settings, specs: List[ServerSpec]) -> None:
300
+ async def _run(settings: Settings, specs: List[ServerSpec]) -> Optional[str]:
301
+ """Run the tunnel; returns a failure message if it gave up, else None."""
275
302
  connection = HubConnection(specs, settings.tunnel_settings(), version=__version__)
276
303
 
277
304
  def request_stop() -> None:
@@ -297,8 +324,17 @@ async def _run(settings: Settings, specs: List[ServerSpec]) -> None:
297
324
  )
298
325
  if settings.environment is not None:
299
326
  LOGGER.info("%s", environment.summarize(settings.environment))
327
+ if settings.instruction_root:
328
+ found = environment.collect_instructions(Path(settings.instruction_root))
329
+ LOGGER.info(
330
+ "instruction files: %s",
331
+ ", ".join(f["path"] for f in found) if found else "none found",
332
+ )
333
+ if settings.env_brief:
334
+ LOGGER.info("environment brief: enabled (turn off with --no-env-brief)")
300
335
  await connection.run()
301
336
  LOGGER.info("shutdown complete")
337
+ return connection.failure
302
338
 
303
339
 
304
340
  def main(argv: Optional[Sequence[str]] = None) -> None:
@@ -324,7 +360,10 @@ def main(argv: Optional[Sequence[str]] = None) -> None:
324
360
  sys.exit(1)
325
361
 
326
362
  try:
327
- asyncio.run(_run(settings, specs))
363
+ failure = asyncio.run(_run(settings, specs))
364
+ if failure:
365
+ print(f"{PROG}: error: {failure}", file=sys.stderr)
366
+ sys.exit(1)
328
367
  except KeyboardInterrupt:
329
368
  pass
330
369
  except TunnelError as e:
@@ -0,0 +1,469 @@
1
+ """Detect where the client runs, for the ``hello`` frame's ``client.environment``.
2
+
3
+ Pure and best-effort: :func:`detect` never raises, reads only a small fixed set of
4
+ signals, and reports paths and names, never environment variable values beyond the
5
+ documented details (docs/AGENT_MODEL_API.md, "Connections: environment detection").
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import logging
12
+ import os
13
+ import re
14
+ import sys
15
+ from pathlib import Path
16
+ from typing import Any, Dict, List, Mapping, Optional, Sequence
17
+
18
+ LOGGER = logging.getLogger("mcp_switchboard_client.environment")
19
+
20
+ _CGROUP_MARKERS = ("docker", "kubepods", "containerd", "podman")
21
+ _MAX_STR = 256
22
+ _MAX_WALK = 64
23
+
24
+
25
+ def _cap(value: str) -> str:
26
+ return value[:_MAX_STR]
27
+
28
+
29
+ def strip_jsonc(text: str) -> str:
30
+ """Remove ``//`` and ``/* */`` comments and trailing commas, leaving strings intact."""
31
+ out: List[str] = []
32
+ i, n = 0, len(text)
33
+ while i < n:
34
+ c = text[i]
35
+ if c == '"':
36
+ j = i + 1
37
+ while j < n and text[j] != '"':
38
+ j += 2 if text[j] == "\\" else 1
39
+ out.append(text[i : j + 1])
40
+ i = j + 1
41
+ elif text.startswith("//", i):
42
+ while i < n and text[i] != "\n":
43
+ i += 1
44
+ elif text.startswith("/*", i):
45
+ end = text.find("*/", i + 2)
46
+ i = n if end < 0 else end + 2
47
+ else:
48
+ out.append(c)
49
+ i += 1
50
+ cleaned = "".join(out)
51
+ # Trailing commas: a comma followed only by whitespace and a closer. Strings
52
+ # were already tokenised above, but the regex could match inside one, so redo
53
+ # it string-aware.
54
+ result: List[str] = []
55
+ i, n = 0, len(cleaned)
56
+ while i < n:
57
+ c = cleaned[i]
58
+ if c == '"':
59
+ j = i + 1
60
+ while j < n and cleaned[j] != '"':
61
+ j += 2 if cleaned[j] == "\\" else 1
62
+ result.append(cleaned[i : j + 1])
63
+ i = j + 1
64
+ elif c == "," and re.match(r"\s*[}\]]", cleaned[i + 1 :]):
65
+ i += 1
66
+ else:
67
+ result.append(c)
68
+ i += 1
69
+ return "".join(result)
70
+
71
+
72
+ def _read(path: Path, limit: int = 1_000_000) -> Optional[str]:
73
+ try:
74
+ with open(path, "r", encoding="utf-8", errors="replace") as f:
75
+ return f.read(limit)
76
+ except OSError:
77
+ return None
78
+
79
+
80
+ def _parents(cwd: Path) -> List[Path]:
81
+ return [cwd, *cwd.parents][:_MAX_WALK]
82
+
83
+
84
+ def find_devcontainer_name(cwd: Path) -> Optional[str]:
85
+ """``name`` from the nearest devcontainer.json at or above ``cwd``."""
86
+ for directory in _parents(cwd):
87
+ for candidate in (
88
+ directory / ".devcontainer" / "devcontainer.json",
89
+ directory / ".devcontainer.json",
90
+ ):
91
+ text = _read(candidate)
92
+ if text is None:
93
+ continue
94
+ try:
95
+ data = json.loads(strip_jsonc(text))
96
+ except ValueError:
97
+ continue
98
+ name = data.get("name") if isinstance(data, dict) else None
99
+ if isinstance(name, str) and name.strip():
100
+ return _cap(name.strip())
101
+ return None
102
+
103
+
104
+ def find_git_root(cwd: Path) -> Optional[Path]:
105
+ """The nearest directory at or above ``cwd`` containing ``.git`` (dir or file)."""
106
+ for directory in _parents(cwd):
107
+ try:
108
+ if (directory / ".git").exists():
109
+ return directory
110
+ except OSError:
111
+ continue
112
+ return None
113
+
114
+
115
+ def _in_container(root: Path) -> bool:
116
+ for marker in ("/.dockerenv", "/run/.containerenv"):
117
+ try:
118
+ if Path(marker).exists():
119
+ return True
120
+ except OSError:
121
+ pass
122
+ cgroup = _read(Path("/proc/1/cgroup"), 65536) if root == Path("/") else None
123
+ return bool(cgroup) and any(m in cgroup.lower() for m in _CGROUP_MARKERS)
124
+
125
+
126
+ def detect(
127
+ cwd: Optional[Path] = None,
128
+ env: Optional[Mapping[str, str]] = None,
129
+ project_override: Optional[str] = None,
130
+ ) -> Dict[str, Any]:
131
+ """Return the ``environment`` object for ``hello``. Never raises."""
132
+ try:
133
+ return _detect(cwd, env, project_override)
134
+ except Exception: # noqa: BLE001 - detection must never break the client
135
+ LOGGER.debug("environment detection failed", exc_info=True)
136
+ return {"kinds": []}
137
+
138
+
139
+ def _detect(
140
+ cwd: Optional[Path], env: Optional[Mapping[str, str]], project_override: Optional[str]
141
+ ) -> Dict[str, Any]:
142
+ env = os.environ if env is None else env
143
+ cwd = Path(os.getcwd() if cwd is None else cwd).absolute()
144
+ kinds: List[str] = []
145
+ details: Dict[str, str] = {}
146
+
147
+ container = _in_container(Path("/"))
148
+ devcontainer = any(
149
+ env.get(k) for k in ("REMOTE_CONTAINERS", "DEVCONTAINER", "CODESPACES", "VSCODE_REMOTE_CONTAINERS_SESSION")
150
+ ) or (container and str(cwd).startswith("/workspaces/"))
151
+ dc_name = find_devcontainer_name(cwd) if devcontainer else None
152
+
153
+ if devcontainer:
154
+ kinds.append("devcontainer")
155
+ if dc_name:
156
+ details["devcontainerName"] = dc_name
157
+ image = env.get("DEVCONTAINER_IMAGE") or env.get("CONTAINER_IMAGE")
158
+ if image:
159
+ details["image"] = _cap(image)
160
+ if container:
161
+ kinds.append("container")
162
+ if env.get("DIRENV_DIR") or env.get("DIRENV_FILE") or env.get("DIRENV_DIFF"):
163
+ kinds.append("direnv")
164
+ direnv_dir = env.get("DIRENV_DIR", "").lstrip("-")
165
+ if direnv_dir:
166
+ details["direnvDir"] = _cap(direnv_dir)
167
+ in_nix = env.get("IN_NIX_SHELL")
168
+ if in_nix or env.get("NIX_BUILD_TOP") or "/nix/store/" in env.get("PATH", ""):
169
+ kinds.append("nix-shell")
170
+ if in_nix in ("pure", "impure"):
171
+ details["nixShell"] = in_nix
172
+ elif in_nix:
173
+ details["nixShell"] = "impure"
174
+ venv = env.get("VIRTUAL_ENV")
175
+ if venv or sys.prefix != getattr(sys, "base_prefix", sys.prefix):
176
+ kinds.append("venv")
177
+ name = os.path.basename((venv or sys.prefix).rstrip("/\\"))
178
+ if name:
179
+ details["venv"] = _cap(name)
180
+
181
+ project = (project_override or "").strip() or None
182
+ if project is None and dc_name:
183
+ project = dc_name
184
+ if project is None:
185
+ root = find_git_root(cwd)
186
+ project = (root or cwd).name or None
187
+
188
+ result: Dict[str, Any] = {"kinds": kinds}
189
+ if project:
190
+ result["project"] = _cap(project)
191
+ result["workspace"] = _cap(str(cwd))
192
+ if details:
193
+ result["details"] = details
194
+ return result
195
+
196
+
197
+ def summarize(environment: Mapping[str, Any]) -> str:
198
+ kinds = ",".join(environment.get("kinds", [])) or "none"
199
+ return (
200
+ f"environment: kinds={kinds} project={environment.get('project', '-')} "
201
+ f"workspace={environment.get('workspace', '-')}"
202
+ )
203
+
204
+
205
+ # ---------- Instruction files (hello instructions / context_update) ----------
206
+ #
207
+ # The repo's own agent instructions, read from the client host and shipped to
208
+ # the hub (docs/PROTOCOL.md: hello.client.instructions, refreshed by the
209
+ # context_update frame). These budgets are deliberately SEPARATE from the
210
+ # environment caps above (which are 256-rune strings for display); instruction
211
+ # bodies are content the model must see in full, so they get a larger,
212
+ # explicitly-documented budget. Nothing here may raise: the tunnel treats a
213
+ # failed collection as "no instruction files", never as a fault.
214
+
215
+ INSTRUCTION_FILENAMES = ("AGENTS.md", "CLAUDE.md", ".cursorrules", ".github/copilot-instructions.md")
216
+
217
+ INSTRUCTION_MAX_FILE_CHARS = 32 * 1024 # per file
218
+ INSTRUCTION_MAX_FILES = 8 # per host
219
+ INSTRUCTION_MAX_TOTAL_CHARS = 64 * 1024 # per hello/context_update
220
+ _INSTRUCTION_MAX_DIR_SCANS = 5000 # walk bound so a huge tree cannot stall a connect
221
+ # Cache, dependency and build trees: instruction files under them are never the
222
+ # project's own guidance. Dotted directories are not descended into except
223
+ # .github, which is on the standard-name list.
224
+ _INSTRUCTION_SKIP_DIRS = {
225
+ ".git", "node_modules", "__pycache__", ".venv", "venv", ".harness",
226
+ ".mypy_cache", ".pytest_cache", ".ruff_cache", ".tox", ".cache",
227
+ "dist", "build", "target", ".next", ".terraform",
228
+ }
229
+ _INSTRUCTION_DESCEND_DOTS = {".github"}
230
+
231
+
232
+ def find_instruction_files(cwd: Path) -> List[Path]:
233
+ """All standard instruction files at or under ``cwd``, root-first.
234
+
235
+ Every found file is returned, not just the nearest: the client cannot
236
+ predict which paths the agent will work under, and the hub labels each with
237
+ its path and states the refine/nested rule. Deterministic order: directory
238
+ depth, then path, then the fixed filename order within a directory.
239
+ """
240
+ names = {n.split("/")[-1] for n in INSTRUCTION_FILENAMES}
241
+ order = {n.split("/")[-1]: i for i, n in enumerate(INSTRUCTION_FILENAMES)}
242
+ located: List[tuple] = [] # (depth, posix, Path)
243
+ scans = 0
244
+ try:
245
+ for dirpath, dirnames, filenames in os.walk(cwd, followlinks=False):
246
+ scans += 1
247
+ if scans > _INSTRUCTION_MAX_DIR_SCANS:
248
+ break
249
+ base = Path(dirpath)
250
+ dirnames[:] = sorted(
251
+ d for d in dirnames
252
+ if d not in _INSTRUCTION_SKIP_DIRS and (not d.startswith(".") or d in _INSTRUCTION_DESCEND_DOTS)
253
+ )
254
+ depth = len(base.relative_to(cwd).parts)
255
+ for name in sorted(filenames):
256
+ if name not in names:
257
+ continue
258
+ # copilot-instructions.md only counts inside a .github
259
+ # directory; the bare names count anywhere kept.
260
+ if name == "copilot-instructions.md" and base.name != ".github":
261
+ continue
262
+ located.append((depth, base / name, order[name]))
263
+ except OSError:
264
+ return []
265
+ located.sort(key=lambda item: (item[0], item[1].as_posix(), item[2]))
266
+ return [item[1] for item in located]
267
+
268
+
269
+ def collect_instructions(cwd: Path) -> List[Dict[str, str]]:
270
+ """Instruction files as ``[{path, content}, ...]`` within the wire budget.
271
+
272
+ ``path`` is relative to ``cwd`` (forward slashes). A file over the per-file
273
+ budget is truncated with a visible marker; files that do not fit the
274
+ whole-hello budget are replaced by one ``(omitted)`` entry listing them, so
275
+ the agent knows the set is incomplete. Never raises.
276
+ """
277
+ files = find_instruction_files(cwd)
278
+ out: List[Dict[str, str]] = []
279
+ skipped: List[str] = []
280
+ used = 0
281
+ try:
282
+ for path in files:
283
+ rel = path.relative_to(cwd).as_posix()
284
+ if len(out) >= INSTRUCTION_MAX_FILES:
285
+ skipped.append(rel)
286
+ continue
287
+ try:
288
+ text = path.read_text(encoding="utf-8", errors="replace")
289
+ except OSError:
290
+ continue
291
+ if len(text) > INSTRUCTION_MAX_FILE_CHARS:
292
+ marker = f"\n[... truncated by the client budget: the file is {len(text)} characters]"
293
+ # Keep the marker INSIDE the cap so the hub's identical re-cap
294
+ # cannot shave it off and make the truncation invisible again.
295
+ text = text[: max(INSTRUCTION_MAX_FILE_CHARS - len(marker), 0)] + marker
296
+ remaining = INSTRUCTION_MAX_TOTAL_CHARS - used
297
+ if len(text) > remaining:
298
+ skipped.append(rel)
299
+ continue
300
+ used += len(text)
301
+ out.append({"path": rel, "content": text})
302
+ except Exception: # noqa: BLE001 - collecting must never break the tunnel
303
+ LOGGER.debug("instruction collection failed", exc_info=True)
304
+ if skipped:
305
+ out.append(
306
+ {
307
+ "path": "(omitted by client budget)",
308
+ "content": "More instruction files exist under this root but were not sent "
309
+ "(per-host budgets: %d files, %d total characters). Known names not included:\n%s"
310
+ % (INSTRUCTION_MAX_FILES, INSTRUCTION_MAX_TOTAL_CHARS, "\n".join(f"- {s}" for s in skipped)),
311
+ }
312
+ )
313
+ return out
314
+
315
+
316
+ # ---------- Environment brief (hello environment_brief / context_update) -----
317
+ #
318
+ # A short, cheaply-computed description of the client host so the agent knows
319
+ # where its tools actually run without spending a turn on probing (docs/
320
+ # PROTOCOL.md). Rules: everything is read from local sources with a small
321
+ # timeout, nothing may hang a connect (a hung brief is a skipped line), network
322
+ # reachability is NEVER probed (interfaces/resolvers only), and anything that
323
+ # cannot be verified within the budget is reported "unknown", not guessed.
324
+ # Collected again on every refresh tick, so the hub's own staleness flag is
325
+ # what covers a client that vanishes mid-session.
326
+
327
+ BRIEF_MAX_CHARS = 6 * 1024 # wire comfort; the hub caps the field on its side
328
+ BRIEF_MAX_LINES = 30
329
+
330
+ BRIEF_TOOLS = ("git", "rg", "ctags", "python3", "node", "go", "docker", "kubectl", "sbatch", "ruff", "pytest")
331
+
332
+ _CRED_IN_URL = re.compile(r"//[^/@\s]*:[^/@\s]*@")
333
+
334
+
335
+ def _brief_proc(cmd: List[str], timeout: float = 2.0) -> Optional[str]:
336
+ """Run a helper, return stdout stripped; None on any failure or timeout."""
337
+ import subprocess
338
+
339
+ try:
340
+ done = subprocess.run(
341
+ cmd, capture_output=True, text=True, timeout=timeout,
342
+ stdin=subprocess.DEVNULL, encoding="utf-8", errors="replace",
343
+ )
344
+ except (OSError, subprocess.TimeoutExpired, ValueError):
345
+ return None
346
+ if done.returncode != 0:
347
+ return None
348
+ return done.stdout.strip()
349
+
350
+
351
+ def _strip_git_credentials(url: str) -> str:
352
+ return _CRED_IN_URL.sub("//", url)
353
+
354
+
355
+ def _sudo_state(timeout: float = 1.5) -> str:
356
+ """yes/no/unknown, decided only by a NON-INTERACTIVE check that cannot prompt."""
357
+ import subprocess
358
+
359
+ try:
360
+ done = subprocess.run(
361
+ ["sudo", "-n", "true"], capture_output=True, text=True, timeout=timeout,
362
+ stdin=subprocess.DEVNULL, env={**os.environ, "SUDO_ASKPASS": "/bin/false"},
363
+ )
364
+ except (OSError, subprocess.TimeoutExpired, ValueError):
365
+ return "unknown"
366
+ if done.returncode == 0:
367
+ return "yes"
368
+ # -n makes "password required" an immediate exit, so a real code came back.
369
+ return "no"
370
+
371
+
372
+ def collect_environment_brief(
373
+ root: Optional[Path] = None, instruction_paths: Optional[Sequence[str]] = None
374
+ ) -> str:
375
+ """A short text brief of the client host for the environment-brief frame.
376
+
377
+ Identity, host, git state, what the file tools can write, DNS (unprobed
378
+ reachability), and presence/absence of a curated tool list plus sudo.
379
+ Never raises; a section whose helper fails is simply left out.
380
+ """
381
+ import getpass
382
+ import grp
383
+ import platform
384
+ import shutil
385
+ import socket
386
+ import tempfile
387
+
388
+ lines: List[str] = []
389
+ try:
390
+ root = Path(root) if root is not None else Path(os.getcwd())
391
+
392
+ user = f"uid={os.getuid()} user={getpass.getuser()}"
393
+ try:
394
+ gids = {os.getgid(), *os.getgroups()}
395
+ groups = ",".join(sorted({grp.getgrgid(g).gr_name for g in gids})[:8])
396
+ if groups:
397
+ user += f" groups={groups}"
398
+ except (KeyError, OSError):
399
+ pass
400
+ lines.append(f"user: {user}")
401
+ lines.append(
402
+ "host: "
403
+ f"hostname={socket.gethostname()} os={platform.system().lower()}-{platform.machine()} "
404
+ f"python={platform.python_version()}"
405
+ )
406
+ try:
407
+ sudo = _sudo_state()
408
+ except Exception: # noqa: BLE001
409
+ sudo = "unknown"
410
+ lines.append(f"sudo (non-interactive check): {sudo} (unknown = could not verify)")
411
+
412
+ lines.append(f"cwd: {root}")
413
+ git_root = find_git_root(root)
414
+ if git_root is not None:
415
+ branch = _brief_proc(["git", "-C", str(git_root), "branch", "--show-current"]) or "(detached)"
416
+ porcelain = _brief_proc(["git", "-C", str(git_root), "status", "--porcelain"]) or ""
417
+ dirty = sum(1 for l in porcelain.splitlines() if l and not l.startswith("??"))
418
+ untracked = sum(1 for l in porcelain.splitlines() if l.startswith("??"))
419
+ lines.append(
420
+ f"git: root={git_root} branch={branch} modified={dirty} untracked={untracked}"
421
+ )
422
+ remote = _brief_proc(["git", "-C", str(git_root), "remote", "get-url", "origin"])
423
+ if remote:
424
+ lines.append(f"git remote origin: {_strip_git_credentials(remote)} (credentials redacted)")
425
+
426
+ scratch_root = os.environ.get("MCP_SWITCHBOARD_HARNESS_SCRATCH") or ""
427
+ scratch = scratch_root or (root / ".harness" / "scratch")
428
+ lines.append(
429
+ "file tools can write: the root, scratch "
430
+ f"{scratch} (unless overridden), and temp {tempfile.gettempdir()}; "
431
+ "run_command/run_python are UNCONFINED and can write anything this user can"
432
+ )
433
+ lines.append(
434
+ "harness output cap: "
435
+ f"{os.environ.get('MCP_SWITCHBOARD_HARNESS_MAX_OUTPUT', '100000')} characters per stream"
436
+ )
437
+
438
+ resolvers: List[str] = []
439
+ text = _read(Path("/etc/resolv.conf")) or ""
440
+ for l in text.splitlines():
441
+ if l.strip().startswith("nameserver"):
442
+ parts = l.split()
443
+ if len(parts) > 1:
444
+ resolvers.append(parts[1])
445
+ if len(resolvers) >= 3:
446
+ break
447
+ addrs = _brief_proc(["hostname", "-I"], timeout=1.0) or "?"
448
+ lines.append(
449
+ f"network: addresses={addrs} resolvers={','.join(resolvers) or '?'}"
450
+ " (reachability NOT probed)"
451
+ )
452
+
453
+ present = [t for t in BRIEF_TOOLS if shutil.which(t)]
454
+ absent = [t for t in BRIEF_TOOLS if t not in present]
455
+ if absent:
456
+ lines.append(f"tools present: {', '.join(present) or '-'}; absent: {', '.join(absent)}")
457
+ else:
458
+ lines.append(f"tools present: {', '.join(present)}")
459
+
460
+ if instruction_paths is not None:
461
+ joined = ", ".join(instruction_paths)
462
+ lines.append(f"instruction files loaded: {joined or '(none found under the root)'}")
463
+
464
+ if len(lines) > BRIEF_MAX_LINES:
465
+ lines = lines[: BRIEF_MAX_LINES - 1] + ["... (brief truncated)"]
466
+ except Exception: # noqa: BLE001 - a broken helper must not break the tunnel
467
+ LOGGER.debug("environment brief collection failed", exc_info=True)
468
+ brief = "\n".join(lines)
469
+ return brief[:BRIEF_MAX_CHARS] or ""