diff --git a/README.md b/README.md index ca15f93..5db74b5 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,7 @@ pip install 'grapharc[openai]' # OpenAI and OpenAI-compatible endpoints pip install 'grapharc[ollama]' # a local server pip install 'grapharc[server]' # the FastAPI + SSE HTTP API pip install 'grapharc[otel]' # OpenTelemetry span export +pip install 'grapharc[slack]' # run the CLI from Slack — docs/cookbook/07-slack.md pip install 'grapharc[all]' # every one of the above ``` diff --git a/docs/cookbook/07-slack.md b/docs/cookbook/07-slack.md new file mode 100644 index 0000000..6955754 --- /dev/null +++ b/docs/cookbook/07-slack.md @@ -0,0 +1,119 @@ +# Running the CLI from Slack + +`grapharc` on a laptop, driven from the Slack app on a phone. The bot in +`grapharc.slack` holds one *outbound* Socket Mode connection to Slack, so it +needs no public URL, no open port and no reverse proxy — home Wi‑Fi behind NAT +is enough. A workspace member types `/grapharc metrics t.jsonl r1` (or +mentions the bot in a channel); the bot runs the command on the host and posts +the output back in the thread. + +Nothing in this page is byte-compared by the test suite — Slack is on the +other end of every interesting command. What *is* tested, in +`tests/test_slack_gateway.py`, is everything short of Slack itself: the gate +that decides what text may become an argv, the runner, and the formatter. + +## What the bot will and will not run + +Anyone in the workspace can talk to the bot, so admission is the design, not +an afterthought. The defaults: + +| Reachable from Slack | Refused from Slack | +|---|---| +| `demo`, `run`, `plan`, `models`, `replay`, `diff`, `trace`, `metrics`, `viz` | `agent` (arbitrary tool execution on the host), `serve` | +| Paths that resolve inside the bot's working directory | Any path that escapes it (`trace ../../.env` is refused before a process spawns) | +| The budget, policy and trace flags each command already has | `--registry` (imports an arbitrary module), `--config`, `--json`, `--no-color` | +| — | `--model` / `--reviewer-model`, unless the operator opts in | + +With `--model` off, every reachable command runs the scripted, spend-free +path. The default answer to "can someone in Slack cost me money?" is **no**; +`GRAPHARC_SLACK_ALLOW_MODEL=1` changes that answer deliberately, in the shell +that starts the bot, not from Slack. + +Output is the CLI's piped-mode bytes in a code fence. stdout in the bot is a +pipe, so by the CLI's own contract there is no colour to strip and the bytes +match what `grapharc … | cat` prints on the host. + +## Slack app setup (once, ~5 minutes) + +1. → **Create New App** → *From a manifest*, pick + the workspace, and paste: + + ```yaml + display_information: + name: grapharc + features: + bot_user: + display_name: grapharc + slash_commands: + - command: /grapharc + description: run a grapharc command on the host + usage_hint: "metrics t.jsonl r1" + oauth_config: + scopes: + bot: + - commands + - app_mentions:read + - chat:write + settings: + event_subscriptions: + bot_events: + - app_mention + socket_mode_enabled: true + interactivity: + is_enabled: true + ``` + +2. **Basic Information → App-Level Tokens** → generate one with the + `connections:write` scope. That is `SLACK_APP_TOKEN` (`xapp-…`). +3. **Install App** to the workspace. The bot token on the OAuth page is + `SLACK_BOT_TOKEN` (`xoxb-…`). +4. Invite the bot to a channel: `/invite @grapharc`. + +## Running it + +```bash +uv sync --extra slack # or: pip install 'grapharc[slack]' + +export SLACK_BOT_TOKEN=xoxb-… +export SLACK_APP_TOKEN=xapp-… +mkdir -p ~/grapharc-slack && cd ~/grapharc-slack # the bot's whole world +python -m grapharc.slack +``` + +The startup line states the resolved working directory, the timeout and +whether model flags are on — the three decisions that matter — then blocks +until interrupted. From Slack: + +``` +/grapharc plan "investigate the checkout outage" +/grapharc trace t.jsonl +@grapharc metrics t.jsonl +``` + +Configuration is environment-only, read once at startup: + +| Variable | Default | Meaning | +|---|---|---| +| `SLACK_BOT_TOKEN`, `SLACK_APP_TOKEN` | — (required) | the two tokens from the app page | +| `GRAPHARC_SLACK_WORKDIR` | the bot's cwd | the directory every path must resolve inside | +| `GRAPHARC_SLACK_TIMEOUT` | `120` | seconds one command may run before it is killed | +| `GRAPHARC_SLACK_ALLOW_MODEL` | off | `1` admits `--model`/`--reviewer-model` | +| `GRAPHARC_SLACK_COMMAND` | `/grapharc` | the slash command to answer to | + +The bot reads tokens from the process environment only. The `.env` +upward-directory search that the model gateway performs is deliberately not +used here: a bot that a whole workspace can drive must not discover +credentials in a file the operator did not point it at. + +## The honest caveats + +- **The bot is alive while the process is.** Laptop lid closed means commands + from a phone go unanswered — Slack shows the slash command timing out, and + nothing queues. The same script runs unchanged on any always-on box. +- **Slack's three-second ack.** The bot acks immediately ("running …") and + posts the result when the command finishes; the timeout bounds how long + that can be. +- **The workspace is the trust boundary.** The gate stops path escapes, + module imports and spend, but anyone in the workspace can run every allowed + command against every file in the working directory. Give the bot a + directory that contains nothing you would not show the whole channel. diff --git a/grapharc/slack/__init__.py b/grapharc/slack/__init__.py new file mode 100644 index 0000000..de53d1e --- /dev/null +++ b/grapharc/slack/__init__.py @@ -0,0 +1,48 @@ +"""A Slack front door for the `grapharc` CLI, over Socket Mode. + +The bot holds one outbound WebSocket to Slack, so it runs anywhere with +internet — a laptop behind NAT included. No public URL, no open port. A +workspace member types `/grapharc metrics t.jsonl r1` (or mentions the bot) +from any Slack client, the bot runs the command on the host, and posts the +piped-mode output back in the thread. The piped bytes are already the CLI's +machine interface — colourless, byte-compared against the docs — which is why +the bot posts them verbatim inside a code fence instead of inventing a third +output format. + +The module is layered so everything with behaviour is testable without Slack: + + command.py what Slack text is allowed to become an argv (the gate) + runner.py run an argv against this interpreter's grapharc, with a timeout + format.py turn an exit code and captured output into one Slack message + bot.py slack-bolt wiring; the only file that imports slack_bolt + config.py tokens and limits from the environment, nothing else + +Only `bot.py` needs the `slack` extra, and it imports it lazily — every other +module (and this package) is stdlib-only, so a wheel without the extra still +imports. + +The gate's default is deliberately spend-free: `agent` and `serve` are refused, +`--model` is refused unless the operator opts in, and every path argument must +resolve inside the bot's working directory. Anyone in the workspace can talk +to the bot; the gate is what makes that safe to allow. +""" + +from grapharc.slack.command import ( + ALLOWED_COMMANDS, + SlackCommandError, + parse_command, +) +from grapharc.slack.config import SlackBotConfig, SlackConfigError +from grapharc.slack.format import format_result +from grapharc.slack.runner import CommandResult, run_command + +__all__ = [ + "ALLOWED_COMMANDS", + "CommandResult", + "SlackBotConfig", + "SlackCommandError", + "SlackConfigError", + "format_result", + "parse_command", + "run_command", +] diff --git a/grapharc/slack/__main__.py b/grapharc/slack/__main__.py new file mode 100644 index 0000000..fb44806 --- /dev/null +++ b/grapharc/slack/__main__.py @@ -0,0 +1,43 @@ +"""`python -m grapharc.slack` — start the bot, or say exactly why it cannot. + +A deliberate module entry rather than a `grapharc slack` subcommand: the CLI's +help text is printed verbatim in README.md and byte-compared by the test +suite, and a long-running daemon does not belong in a parser whose every other +command terminates. Exit codes keep the CLI's meaning: 0 on a clean shutdown, +2 when the environment does not describe a runnable bot. +""" + +from __future__ import annotations + +import sys + +from grapharc.slack.command import SlackCommandError +from grapharc.slack.config import SlackBotConfig, SlackConfigError + + +def main() -> int: + try: + config = SlackBotConfig.from_env() + except SlackConfigError as exc: + print(f"error: {exc}", file=sys.stderr) + return 2 + try: + from grapharc.slack.bot import serve + + print( + f"grapharc slack bot: workdir {config.workdir}, " + f"timeout {config.timeout_seconds:.0f}s, " + f"model flags {'on' if config.allow_model else 'off'}", + file=sys.stderr, + ) + serve(config) + except SlackCommandError as exc: # the missing-extra message from build_app + print(f"error: {exc}", file=sys.stderr) + return 2 + except KeyboardInterrupt: + pass + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/grapharc/slack/bot.py b/grapharc/slack/bot.py new file mode 100644 index 0000000..8c8cc9b --- /dev/null +++ b/grapharc/slack/bot.py @@ -0,0 +1,79 @@ +"""slack-bolt wiring: the only module that touches Slack itself. + +Everything with behaviour lives in `command`/`runner`/`format`; what remains +here is `handle_text` (their composition, still import-safe without slack-bolt +and tested that way) and the listener glue. Slack requires an ack within three +seconds, so each listener acks with "running…" first and posts the result when +the command finishes — bolt runs listeners on worker threads, so a slow +command blocks neither the socket nor other requests. + +`slack_bolt` is imported inside `build_app`, not at module top: the wheel-check +imports every module in an environment without the extra, and a user who never +runs the bot should never need it installed. +""" + +from __future__ import annotations + +import re +from typing import Any + +from grapharc.slack.command import SlackCommandError, parse_command, usage_text +from grapharc.slack.config import SlackBotConfig +from grapharc.slack.format import format_result +from grapharc.slack.runner import run_command + +# An app_mention's text arrives as "<@U0BOTID> metrics t.jsonl r1". +_MENTION = re.compile(r"<@[A-Z0-9]+>\s*") + + +def handle_text(text: str, config: SlackBotConfig) -> str: + """Gate, run, format: the whole request path, with Slack stripped away.""" + stripped = _MENTION.sub("", text).strip() + try: + argv = parse_command( + stripped, workdir=config.workdir, allow_model=config.allow_model + ) + except SlackCommandError as exc: + return str(exc) + result = run_command( + argv, workdir=config.workdir, timeout_seconds=config.timeout_seconds + ) + return format_result(result) + + +def build_app(config: SlackBotConfig) -> Any: + """A configured `slack_bolt.App`; raises with the install hint if the extra is absent.""" + try: + from slack_bolt import App + except ImportError: + raise SlackCommandError( + "the Slack bot needs the `slack` extra: uv sync --extra slack " + "(or: pip install 'grapharc[slack]')" + ) from None + + app = App(token=config.bot_token) + + @app.command(config.slash_command) + def _slash(ack: Any, respond: Any, command: dict[str, Any]) -> None: + text = command.get("text", "").strip() + if not text: + ack(usage_text(allow_model=config.allow_model)) + return + ack(f"running `grapharc {text}`…") + respond(handle_text(text, config)) + + @app.event("app_mention") + def _mention(event: dict[str, Any], say: Any) -> None: + say( + handle_text(event.get("text", ""), config), + thread_ts=event.get("thread_ts") or event.get("ts"), + ) + + return app + + +def serve(config: SlackBotConfig) -> None: + """Open the Socket Mode connection and block until interrupted.""" + from slack_bolt.adapter.socket_mode import SocketModeHandler + + SocketModeHandler(build_app(config), config.app_token).start() diff --git a/grapharc/slack/command.py b/grapharc/slack/command.py new file mode 100644 index 0000000..e079698 --- /dev/null +++ b/grapharc/slack/command.py @@ -0,0 +1,171 @@ +"""The gate between Slack text and an argv: what the bot will and will not run. + +Anyone in the workspace can talk to the bot, so this is an admission decision, +not a convenience parser — the same posture as the CLI's own policy layer. The +rules, and why each exists: + +- **Subcommands are allowlisted.** `agent` (arbitrary tool execution on the + host) and `serve` (holds a worker thread forever) are not in the list. +- **Flags are allowlisted per subcommand.** `--registry MODULE:ATTR` imports + an arbitrary module on the host, `--config PATH` swaps the governing file, + and `--json`/`--no-color` fight the bot's own output handling — none are + reachable from Slack. +- **`--model` is refused unless the operator opted in**, because it reaches a + paid backend. Without it every allowed command runs the scripted, spend-free + path; the default answer to "can Slack cost me money?" is no. +- **Every path must resolve inside the bot's working directory.** `trace + ../../.env` is refused before a process is spawned, whether it arrives as a + positional or as a flag value. + +The output is an argv list for `runner.py`, never a shell string — nothing a +user types is ever interpreted by a shell. +""" + +from __future__ import annotations + +import shlex +from dataclasses import dataclass, field +from pathlib import Path + + +class SlackCommandError(Exception): + """The text is not something the bot will run; the message says why.""" + + +@dataclass(frozen=True) +class CommandSpec: + """What one subcommand may be given from Slack.""" + + # flag -> True when its value is a path that must stay inside the workdir + value_flags: dict[str, bool] = field(default_factory=dict) + bool_flags: frozenset[str] = frozenset() + # positional indices (0-based, after the subcommand) that are paths + path_positionals: frozenset[int] = frozenset() + # value flags that reach a paid backend; admitted only with allow_model + model_flags: frozenset[str] = frozenset() + + +_BUDGET = {"--max-tokens": False, "--max-iterations": False, "--max-seconds": False} +_NAMED_RUN = {"--trace": True, "--run-id": False} + +ALLOWED_COMMANDS: dict[str, CommandSpec] = { + "demo": CommandSpec( + value_flags={"--trace": True, "--memory": True, "--memory-backend": False}, + model_flags=frozenset({"--model", "--reviewer-model"}), + ), + "run": CommandSpec( + value_flags={ + **_NAMED_RUN, + "--policy": True, + "--tenant": False, + **_BUDGET, + "--max-concurrency": False, + }, + bool_flags=frozenset({"--check-only"}), + path_positionals=frozenset({0}), + ), + "plan": CommandSpec( + value_flags={ + **_NAMED_RUN, + "--policy": True, + "--tenant": False, + "--max-rounds": False, + "--max-tokens": False, + }, + model_flags=frozenset({"--model"}), + ), + "models": CommandSpec(bool_flags=frozenset({"--check"})), + "replay": CommandSpec(path_positionals=frozenset({0})), + "diff": CommandSpec(path_positionals=frozenset({0})), + "trace": CommandSpec(value_flags={"--run-id": False}, path_positionals=frozenset({0})), + "metrics": CommandSpec(path_positionals=frozenset({0})), + "viz": CommandSpec(path_positionals=frozenset({0})), +} + + +def usage_text(*, allow_model: bool = False) -> str: + """One short message for an empty or unrecognised request.""" + lines = ["I run `grapharc` commands. Allowed here:"] + for name in sorted(ALLOWED_COMMANDS): + lines.append(f"• `{name}`") + lines.append("`agent` and `serve` are not reachable from Slack, nor is `--registry`.") + if not allow_model: + lines.append( + "`--model` is off; the operator can enable it with GRAPHARC_SLACK_ALLOW_MODEL=1." + ) + return "\n".join(lines) + + +def _confined(raw: str, workdir: Path) -> None: + """Refuse a path that escapes the working directory, before anything runs. + + Lexical resolution only — the target need not exist yet (`--trace` names a + file the run will create). + """ + resolved = (workdir / raw).resolve() if not Path(raw).is_absolute() else Path(raw).resolve() + if not resolved.is_relative_to(workdir.resolve()): + raise SlackCommandError(f"path escapes the bot's working directory: `{raw}`") + + +def parse_command(text: str, *, workdir: Path, allow_model: bool = False) -> list[str]: + """Turn Slack text into the argv the bot may run, or raise with the reason.""" + try: + tokens = shlex.split(text) + except ValueError as exc: + raise SlackCommandError(f"could not parse that: {exc}") from None + + if tokens and tokens[0] == "grapharc": + tokens = tokens[1:] + if not tokens: + raise SlackCommandError(usage_text(allow_model=allow_model)) + + name, rest = tokens[0], tokens[1:] + spec = ALLOWED_COMMANDS.get(name) + if spec is None: + raise SlackCommandError( + f"`{name}` is not a command this bot runs.\n" + usage_text(allow_model=allow_model) + ) + + argv = [name] + positional_index = 0 + index = 0 + while index < len(rest): + token = rest[index] + if token.startswith("--"): + flag, eq, inline_value = token.partition("=") + if flag in spec.bool_flags: + if eq: + raise SlackCommandError(f"`{flag}` takes no value") + argv.append(flag) + index += 1 + continue + if flag in spec.model_flags: + if not allow_model: + raise SlackCommandError( + f"`{flag}` reaches a paid backend and is off by default; " + "the operator enables it with GRAPHARC_SLACK_ALLOW_MODEL=1" + ) + is_path = False + elif flag in spec.value_flags: + is_path = spec.value_flags[flag] + else: + raise SlackCommandError(f"`{flag}` is not allowed on `{name}` from Slack") + if eq: + value = inline_value + index += 1 + else: + if index + 1 >= len(rest): + raise SlackCommandError(f"`{flag}` needs a value") + value = rest[index + 1] + index += 2 + if is_path: + _confined(value, workdir) + argv.extend([flag, value]) + continue + if positional_index in spec.path_positionals: + _confined(token, workdir) + argv.append(token) + positional_index += 1 + index += 1 + + return argv diff --git a/grapharc/slack/config.py b/grapharc/slack/config.py new file mode 100644 index 0000000..6a65403 --- /dev/null +++ b/grapharc/slack/config.py @@ -0,0 +1,76 @@ +"""What the bot needs from its environment, read once at startup. + +Tokens come from process environment variables only. The gateway's `.env` +loader is deliberately not used here: it searches parent directories upward +(the subject of issue #20), and a bot that anyone in a Slack workspace can +drive must not pick up credentials from a file the operator did not point it +at. `SLACK_BOT_TOKEN` and `SLACK_APP_TOKEN` are exported in the shell that +starts the bot, and nowhere else. +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass, field +from pathlib import Path + + +class SlackConfigError(Exception): + """The environment does not describe a runnable bot.""" + + +@dataclass(frozen=True) +class SlackBotConfig: + """Everything `bot.py` and the gate need, resolved and validated.""" + + bot_token: str + app_token: str + # Every path a Slack user names must resolve inside this directory. + workdir: Path = field(default_factory=Path.cwd) + # One command's wall clock. Slack acks immediately, so this bounds how + # long a runaway command can hold one of the bot's worker threads. + timeout_seconds: float = 120.0 + # Opt-in: allow `--model` / `--reviewer-model`, which reach paid backends. + allow_model: bool = False + slash_command: str = "/grapharc" + + @classmethod + def from_env(cls, environ: dict[str, str] | None = None) -> SlackBotConfig: + env = os.environ if environ is None else environ + bot_token = env.get("SLACK_BOT_TOKEN", "") + app_token = env.get("SLACK_APP_TOKEN", "") + missing = [ + name + for name, value in ( + ("SLACK_BOT_TOKEN", bot_token), + ("SLACK_APP_TOKEN", app_token), + ) + if not value + ] + if missing: + raise SlackConfigError( + f"{' and '.join(missing)} must be set in the environment that starts the bot" + ) + + workdir = Path(env.get("GRAPHARC_SLACK_WORKDIR", ".")).resolve() + if not workdir.is_dir(): + raise SlackConfigError(f"GRAPHARC_SLACK_WORKDIR is not a directory: {workdir}") + + raw_timeout = env.get("GRAPHARC_SLACK_TIMEOUT", "120") + try: + timeout = float(raw_timeout) + except ValueError: + raise SlackConfigError( + f"GRAPHARC_SLACK_TIMEOUT must be a number of seconds, got {raw_timeout!r}" + ) from None + if timeout <= 0: + raise SlackConfigError("GRAPHARC_SLACK_TIMEOUT must be positive") + + return cls( + bot_token=bot_token, + app_token=app_token, + workdir=workdir, + timeout_seconds=timeout, + allow_model=env.get("GRAPHARC_SLACK_ALLOW_MODEL", "") == "1", + slash_command=env.get("GRAPHARC_SLACK_COMMAND", "/grapharc"), + ) diff --git a/grapharc/slack/format.py b/grapharc/slack/format.py new file mode 100644 index 0000000..7290c93 --- /dev/null +++ b/grapharc/slack/format.py @@ -0,0 +1,67 @@ +"""One `CommandResult` becomes one Slack message. + +The CLI's exit codes are part of its interface (0 did its job, 1 ran and the +answer was negative, 2 could not run), so the header states which of the three +happened instead of a bare number. The captured output is posted verbatim in a +code fence — piped-mode bytes are the CLI's stable form, and inventing a Slack +rendering of them would be a third dialect the docs never promised. + +Slack rejects messages past 40,000 characters; the fence is truncated well +below that, from the top, with a line saying how much was cut. Truncation is +announced, never silent — the reader must know they are not seeing everything. +""" + +from __future__ import annotations + +import shlex + +from grapharc.slack.runner import CommandResult + +# Leaves generous room for the header and the truncation notice. +MAX_FENCE_CHARS = 3500 + +_VERDICTS = { + 0: "did its job", + 1: "ran; the answer was negative", + 2: "could not run", +} + + +def _fence(body: str) -> str: + # A ``` inside the body would end the fence early and spill the rest as + # prose; a zero-width space between the backticks defuses it. + return "```" + body.replace("```", "`​``") + "```" + + +def _truncate(body: str) -> tuple[str, int]: + if len(body) <= MAX_FENCE_CHARS: + return body, 0 + return body[:MAX_FENCE_CHARS], len(body) - MAX_FENCE_CHARS + + +def format_result(result: CommandResult) -> str: + shown = shlex.join(["grapharc", *result.argv]) + if result.exit_code is None: + header = ( + f"`{shown}` was still running after {result.timeout_seconds:.0f}s " + "and was stopped." + ) + else: + verdict = _VERDICTS.get(result.exit_code, f"exited {result.exit_code}") + header = f"`{shown}` — {verdict} ({result.duration_seconds:.1f}s)." + + # Text-mode contract: success speaks on stdout, failure on stderr with an + # empty stdout. Show whichever stream carries the answer; both if both do. + parts = [header] + for label, stream in (("stdout", result.stdout), ("stderr", result.stderr)): + if not stream.strip(): + continue + body, cut = _truncate(stream) + if len(parts) > 1 or label == "stderr": + parts.append(f"{label}:") + parts.append(_fence(body)) + if cut: + parts.append(f"_…{cut} more characters not shown._") + if len(parts) == 1: + parts.append("_(no output)_") + return "\n".join(parts) diff --git a/grapharc/slack/runner.py b/grapharc/slack/runner.py new file mode 100644 index 0000000..54efca0 --- /dev/null +++ b/grapharc/slack/runner.py @@ -0,0 +1,65 @@ +"""Run one admitted argv against this interpreter's grapharc, and say what happened. + +`sys.executable -m grapharc.cli.main` rather than a `grapharc` found on PATH, so the +bot always runs the code it was installed with — a PATH pointing at some other +environment cannot swap the CLI out from under it. The subprocess gets the +bot's working directory as cwd, which is the same directory the gate confined +every path argument to. + +stdout here is a pipe, so by the CLI's own contract the output is the plain +byte-stable form with no colour — exactly what belongs in a Slack code fence. +""" + +from __future__ import annotations + +import subprocess +import sys +import time +from dataclasses import dataclass +from pathlib import Path + + +@dataclass(frozen=True) +class CommandResult: + """One finished (or timed-out) command, everything the formatter needs.""" + + argv: list[str] + exit_code: int | None # None: the timeout fired and the process was killed + stdout: str + stderr: str + duration_seconds: float + timeout_seconds: float + + +def run_command(argv: list[str], *, workdir: Path, timeout_seconds: float) -> CommandResult: + started = time.monotonic() + try: + completed = subprocess.run( + [sys.executable, "-m", "grapharc.cli.main", *argv], + cwd=workdir, + capture_output=True, + text=True, + timeout=timeout_seconds, + ) + except subprocess.TimeoutExpired as exc: + def _decode(stream: bytes | str | None) -> str: + if stream is None: + return "" + return stream.decode(errors="replace") if isinstance(stream, bytes) else stream + + return CommandResult( + argv=argv, + exit_code=None, + stdout=_decode(exc.stdout), + stderr=_decode(exc.stderr), + duration_seconds=time.monotonic() - started, + timeout_seconds=timeout_seconds, + ) + return CommandResult( + argv=argv, + exit_code=completed.returncode, + stdout=completed.stdout, + stderr=completed.stderr, + duration_seconds=time.monotonic() - started, + timeout_seconds=timeout_seconds, + ) diff --git a/pyproject.toml b/pyproject.toml index 1356675..5aa8c67 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -108,9 +108,14 @@ otel = [ api = [ "anthropic>=0.40", ] +# Imported (lazily) by grapharc/slack/bot.py: the Socket Mode bot that runs the +# CLI from a Slack workspace. Everything else in grapharc/slack/ is stdlib-only. +slack = [ + "slack-bolt>=1.20", +] # Everything above. Self-referential so it cannot drift out of sync. all = [ - "grapharc[api,ladybug,mcp,memory,ollama,openai,openrouter,otel,server]", + "grapharc[api,ladybug,mcp,memory,ollama,openai,openrouter,otel,server,slack]", ] [dependency-groups] diff --git a/tests/test_slack_gateway.py b/tests/test_slack_gateway.py new file mode 100644 index 0000000..6fedb61 --- /dev/null +++ b/tests/test_slack_gateway.py @@ -0,0 +1,199 @@ +"""The Slack gate, runner and formatter — everything except Slack itself. + +The layering under test: `command.py` decides what Slack text may become an +argv, `runner.py` runs it against this interpreter's grapharc, `format.py` +turns the result into one message. `bot.py` is glue; its one behaviour worth a +test here (the missing-extra error) is tested by faking the import failure, +so none of this file needs a Slack token or a network. +""" + +from __future__ import annotations + +import sys + +import pytest + +from grapharc.slack.command import SlackCommandError, parse_command, usage_text +from grapharc.slack.config import SlackBotConfig, SlackConfigError +from grapharc.slack.format import MAX_FENCE_CHARS, format_result +from grapharc.slack.runner import CommandResult, run_command + +# --------------------------------------------------------------------------- +# The gate: what text is allowed to become an argv. +# --------------------------------------------------------------------------- + + +def test_a_reading_command_passes_through_verbatim(tmp_path): + argv = parse_command("metrics t.jsonl r1", workdir=tmp_path) + assert argv == ["metrics", "t.jsonl", "r1"] + + +def test_a_leading_grapharc_token_is_tolerated(tmp_path): + assert parse_command("grapharc models", workdir=tmp_path) == ["models"] + + +def test_agent_and_serve_are_refused(tmp_path): + for name in ("agent", "serve"): + with pytest.raises(SlackCommandError, match="not a command this bot runs"): + parse_command(f"{name} whatever", workdir=tmp_path) + + +def test_registry_config_and_json_are_refused(tmp_path): + for flag in ("--registry mod:attr", "--config g.toml", "--json"): + with pytest.raises(SlackCommandError, match="not allowed"): + parse_command(f"run graph.toml {flag}", workdir=tmp_path) + + +def test_model_is_refused_by_default_and_admitted_on_opt_in(tmp_path): + with pytest.raises(SlackCommandError, match="paid backend"): + parse_command("plan 'a goal' --model mock/x", workdir=tmp_path) + argv = parse_command("plan 'a goal' --model mock/x", workdir=tmp_path, allow_model=True) + assert argv == ["plan", "a goal", "--model", "mock/x"] + + +def test_a_path_positional_may_not_escape_the_workdir(tmp_path): + with pytest.raises(SlackCommandError, match="escapes"): + parse_command("trace ../outside.jsonl", workdir=tmp_path) + with pytest.raises(SlackCommandError, match="escapes"): + parse_command("trace /etc/passwd", workdir=tmp_path) + + +def test_a_path_flag_value_may_not_escape_the_workdir_either_form(tmp_path): + with pytest.raises(SlackCommandError, match="escapes"): + parse_command("plan goal --trace ../t.jsonl", workdir=tmp_path) + with pytest.raises(SlackCommandError, match="escapes"): + parse_command("plan goal --trace=../t.jsonl", workdir=tmp_path) + + +def test_a_path_inside_the_workdir_is_admitted_even_absolute(tmp_path): + inside = tmp_path / "runs" / "t.jsonl" + argv = parse_command(f"trace {inside}", workdir=tmp_path) + assert argv == ["trace", str(inside)] + + +def test_a_quoted_goal_survives_as_one_argument(tmp_path): + argv = parse_command('plan "investigate the checkout outage"', workdir=tmp_path) + assert argv == ["plan", "investigate the checkout outage"] + + +def test_empty_text_answers_with_usage_not_a_traceback(tmp_path): + with pytest.raises(SlackCommandError) as excinfo: + parse_command("", workdir=tmp_path) + assert "Allowed here" in str(excinfo.value) + assert "agent" in usage_text() + + +def test_a_value_flag_missing_its_value_is_refused(tmp_path): + with pytest.raises(SlackCommandError, match="needs a value"): + parse_command("trace t.jsonl --run-id", workdir=tmp_path) + + +# --------------------------------------------------------------------------- +# Runner and formatter, end to end against the real CLI. +# --------------------------------------------------------------------------- + + +def test_models_runs_and_formats_as_success(tmp_path): + result = run_command(["models"], workdir=tmp_path, timeout_seconds=60) + assert result.exit_code == 0 + message = format_result(result) + assert "did its job" in message + assert "```" in message + assert "\x1b" not in message, "an escape reached a Slack message" + + +def test_a_missing_graph_formats_as_could_not_run(tmp_path): + result = run_command( + ["run", str(tmp_path / "missing.toml"), "--trace", str(tmp_path / "t.jsonl")], + workdir=tmp_path, + timeout_seconds=60, + ) + assert result.exit_code == 2 + message = format_result(result) + assert "could not run" in message + assert "stderr:" in message + + +def test_the_timeout_kills_the_process_and_says_so(tmp_path): + # Interpreter startup alone exceeds this, so the timeout always fires. + result = run_command(["models"], workdir=tmp_path, timeout_seconds=0.05) + assert result.exit_code is None + assert "was stopped" in format_result(result) + + +def test_truncation_is_announced_never_silent(): + result = CommandResult( + argv=["trace", "t.jsonl"], + exit_code=0, + stdout="x" * (MAX_FENCE_CHARS + 500), + stderr="", + duration_seconds=0.1, + timeout_seconds=60, + ) + message = format_result(result) + assert "500 more characters not shown" in message + + +def test_a_fence_in_the_output_cannot_break_out(): + result = CommandResult( + argv=["trace", "t.jsonl"], + exit_code=0, + stdout="before\n```\nafter", + stderr="", + duration_seconds=0.1, + timeout_seconds=60, + ) + body = format_result(result).split("```", 1)[1] + assert "\n```\n" not in body.rsplit("```", 1)[0] + + +# --------------------------------------------------------------------------- +# Config and the bot's import posture. +# --------------------------------------------------------------------------- + + +def test_missing_tokens_name_every_missing_variable(): + with pytest.raises(SlackConfigError, match="SLACK_BOT_TOKEN and SLACK_APP_TOKEN"): + SlackBotConfig.from_env({}) + + +def test_a_non_numeric_timeout_is_a_named_error_not_a_traceback(tmp_path): + env = { + "SLACK_BOT_TOKEN": "xoxb-x", + "SLACK_APP_TOKEN": "xapp-x", + "GRAPHARC_SLACK_TIMEOUT": "forever", + } + with pytest.raises(SlackConfigError, match="GRAPHARC_SLACK_TIMEOUT"): + SlackBotConfig.from_env(env) + + +def test_config_reads_workdir_timeout_and_model_opt_in(tmp_path): + config = SlackBotConfig.from_env( + { + "SLACK_BOT_TOKEN": "xoxb-x", + "SLACK_APP_TOKEN": "xapp-x", + "GRAPHARC_SLACK_WORKDIR": str(tmp_path), + "GRAPHARC_SLACK_TIMEOUT": "5", + "GRAPHARC_SLACK_ALLOW_MODEL": "1", + } + ) + assert config.workdir == tmp_path + assert config.timeout_seconds == 5.0 + assert config.allow_model + + +def test_handle_text_turns_a_refusal_into_a_message_not_an_exception(tmp_path): + from grapharc.slack.bot import handle_text + + config = SlackBotConfig(bot_token="xoxb-x", app_token="xapp-x", workdir=tmp_path) + reply = handle_text("<@U012345> agent rm -rf /", config) + assert "not a command this bot runs" in reply + + +def test_a_missing_slack_extra_is_an_install_hint_not_an_import_error(monkeypatch, tmp_path): + from grapharc.slack import bot + + monkeypatch.setitem(sys.modules, "slack_bolt", None) + config = SlackBotConfig(bot_token="xoxb-x", app_token="xapp-x", workdir=tmp_path) + with pytest.raises(SlackCommandError, match="slack"): + bot.build_app(config) diff --git a/uv.lock b/uv.lock index 080d67e..eaa209b 100644 --- a/uv.lock +++ b/uv.lock @@ -370,6 +370,7 @@ all = [ { name = "opentelemetry-exporter-otlp-proto-http" }, { name = "opentelemetry-sdk" }, { name = "real-ladybug" }, + { name = "slack-bolt" }, { name = "uvicorn" }, ] api = [ @@ -402,6 +403,9 @@ server = [ { name = "fastapi" }, { name = "uvicorn" }, ] +slack = [ + { name = "slack-bolt" }, +] [package.dev-dependencies] dev = [ @@ -416,7 +420,7 @@ dev = [ requires-dist = [ { name = "anthropic", marker = "extra == 'api'", specifier = ">=0.40" }, { name = "fastapi", marker = "extra == 'server'", specifier = ">=0.115" }, - { name = "grapharc", extras = ["api", "ladybug", "mcp", "memory", "ollama", "openai", "openrouter", "otel", "server"], marker = "extra == 'all'" }, + { name = "grapharc", extras = ["api", "ladybug", "mcp", "memory", "ollama", "openai", "openrouter", "otel", "server", "slack"], marker = "extra == 'all'" }, { name = "langchain-core", specifier = ">=0.3" }, { name = "langchain-openai", marker = "extra == 'ollama'", specifier = ">=0.2" }, { name = "langchain-openai", marker = "extra == 'openai'", specifier = ">=0.2" }, @@ -430,9 +434,10 @@ requires-dist = [ { name = "opentelemetry-sdk", marker = "extra == 'otel'", specifier = ">=1.27" }, { name = "pydantic", specifier = ">=2.7" }, { name = "real-ladybug", marker = "extra == 'ladybug'", specifier = ">=0.15.3" }, + { name = "slack-bolt", marker = "extra == 'slack'", specifier = ">=1.20" }, { name = "uvicorn", marker = "extra == 'server'", specifier = ">=0.32" }, ] -provides-extras = ["openrouter", "openai", "ollama", "server", "mcp", "memory", "ladybug", "otel", "api", "all"] +provides-extras = ["openrouter", "openai", "ollama", "server", "mcp", "memory", "ladybug", "otel", "api", "slack", "all"] [package.metadata.requires-dev] dev = [ @@ -1577,6 +1582,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/9a/8415f2657cbe200f41a4531ccededf135505a92d4a012229121f885b26f9/ruff-0.16.0-py3-none-win_arm64.whl", hash = "sha256:14296fedcd2705c77ab8235439278bbb38f285cf7da5528b00b3e330c3d4872d", size = 11273407, upload-time = "2026-07-23T19:11:28.705Z" }, ] +[[package]] +name = "slack-bolt" +version = "1.30.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "slack-sdk" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e3/4f/5ba15533d66da2e7174334cc0e2805142e5390c9f4c5f31633df78b17006/slack_bolt-1.30.0.tar.gz", hash = "sha256:af38258d41f801ad9c74503090e0f39accd66c49f667f7e55c97fcdb0e51b886", size = 131180, upload-time = "2026-07-15T20:47:33.679Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/ee/1a7a286cf98fa3f4eeffaabc090e82f58f058ab4812aa1d7421d92c2637a/slack_bolt-1.30.0-py2.py3-none-any.whl", hash = "sha256:81f5bc46e79516d23d5e2a31dded6304dd1b8b6b72c0083f2f31d5d801e262c4", size = 235341, upload-time = "2026-07-15T20:47:32.113Z" }, +] + +[[package]] +name = "slack-sdk" +version = "3.43.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/75/a4964eb771a0c74d79ee7a3bee6fb5d9718909dd1b675e80d62a6a0ad90a/slack_sdk-3.43.0.tar.gz", hash = "sha256:0553152e46c4259eb69f7464cdadc35ba4802ca10f9f5a849c92cf03d6c2ba07", size = 252769, upload-time = "2026-06-30T18:04:41.59Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/55/42141b8338d46323d5b3c6095201b044c670c20f898643b322ea9b1543a1/slack_sdk-3.43.0-py2.py3-none-any.whl", hash = "sha256:4b6557c65577fc172f685af218b811f9f3b4909e24cddd839ada09565f10c585", size = 315866, upload-time = "2026-06-30T18:04:39.636Z" }, +] + [[package]] name = "sniffio" version = "1.3.1"