Repository navigation
feat(slack): drive the flagship agent from Slack, set up in the TUI - #3697
kovtcharov-amd merged 9 commits into
Conversation
Until now the flagship agent only answered someone sitting at the machine it runs on. Now you message GAIA from Slack on your phone and it answers from your desktop — reading your files, searching your documents, and uploading results back — and the TUI offers to connect it the first time it notices Slack installed, rather than making you find the feature. `gaia slack` runs its own long-lived process that owns its own `gaia-agent` child over the same stdio transport the TUI uses. It is a peer of the TUI, not a passenger: Slack works whether or not a terminal is open, and a remote turn never lands in the conversation the local user is looking at. The transport-agnostic half lives in `messaging/bridge.py` so Signal (amd#693) is a renderer and a socket, not a second bridge. Security is the part that decides whether this ships at all. Unlike the Telegram adapter, which reaches a tool-less AgentSDK, this drives the real agent — so the argument is not "the tools are absent" but "the dangerous ones are gated and nothing here can ungate them": - Shell, file writes and code execution already pause on TOOLS_REQUIRING_CONFIRMATION. Those pauses render as Block Kit buttons and the click rides the existing `gaia_control` channel, so a message can ask for a dangerous tool but never run one unattended. - Bypass is structurally unreachable. The bridge builds no control message of its own; its only channel is decide(), which accepts three values. - No allowlist, no start. DM-only, workspace-pinned, and the buttons check the allowlist too — a workspace admin reading the thread cannot approve a command on someone else's machine. - Reads stay ungated. An allowlisted user can read what the agent can read. The setup confirmation and the guide say so plainly instead of implying otherwise; `--deny-gated-tools` makes the surface read-only outright. Setup cannot be fully automatic — a Slack app can only be created by a human at api.slack.com, and the manifest API needs a token obtained the same way. So it is one click and two pastes: GAIA generates the manifest, opens the pre-filled create-app URL, validates both tokens against auth.test, and stores them in the OS keyring. The copy never claims otherwise. The offer is shown once and then only when something changed — a skip is reconsidered exactly when Slack appears on a machine that lacked it, which is the "I installed it later" case; `never` is honoured forever and `/slack` always works. Also fixes a packaging bug found on the way: `gaia.messaging` had no __init__.py and was absent from setup.py's package list, so `gaia telegram` ImportErrors on any installed wheel. Tested: 165 new Python tests and 20 new Go tests. Two of them pin contracts a fake cannot: the bridge's wire literals are fed through the real `gaia_agent.stdio.apply_control`, and the adapter's Slack calls are introspected against the installed slack_sdk — `files.upload` was retired in favour of files_upload_v2, and a stub would have kept reporting success straight through that. Full unit suite and the full Go suite pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Request changesThis bridges a Slack DM to the real flagship agent — allowlisted, DM-only, workspace-pinned, with the existing shell/file-write confirmation gate rendered as Allow/Deny buttons. The security design holds up: I found no way for a Slack message or button to reach the agent's The problem is that two of the three headline user-facing behaviours don't actually work, and the tests can't see it because they hand-build the agent's events instead of using the real ones. What needs fixing before merge:
The fix for all three is the same discipline the PR already applied on the inbound side: it pins the control-message constants against the real agent parser, but nothing pins the outbound event field names. One contract test that drives the adapter with events from the real translator would have caught both #1 and #2. Also worth resolving: the CLI reference page wasn't updated for the new command, a mid-turn question from the agent renders as nothing in Slack, and the shipped plan doc still describes a flag that doesn't exist. Real-world evidenceN/A — no evidence bundle was produced for this run, and the PR description shows no captured output for the CLI surfaces it adds. This matters more than usual here. The test plan lists 🔍 Technical details🔴 Critical1.
2.
Needs a decision on where the path actually comes from — 3. Per the
🟡 Important4. No contract test on the outbound event vocabulary (
5.
6. CLAUDE.md: a new CLI command needs both a 🟢 Nits7. 8. The plan doc describes a flag that doesn't exist ( 9. Downloaded Slack files are never cleaned up ( 10. 11. The PR description carries a Strengths
|
…-integration-f0237d # Conflicts: # tui/internal/ui/components/helpoverlay.go # tui/internal/ui/components/helpoverlay_test.go
Three of the bridge's headline behaviours were broken, and the tests could not see it because they fed the adapter event shapes the agent never emits. Replies never streamed: the adapter read `text` from token events, and the translator sends `delta`. Files never came back: it looked for a `file_path` on tool results, which carry only a summary. And an approval prompt nobody answered parked the bridge's only agent forever, since the agent disables its own timeout — every later message just sat "Queued". - Tokens stream from `delta`. An empty final keeps the text that streamed. - A written file is uploaded using the path the write tool was CALLED with, once its result reports no failure. Read tools never trigger an upload. - An approval prompt is denied after ten minutes, or at once if Slack refuses to post it. A click only answers a prompt that is still pending, so a double click or a late one cannot resolve a different prompt. - A mid-run question is posted and the turn cancelled. The stdio wire cannot deliver an answer, and the conversation survives, so a reply continues it. - Shared files are saved to ~/.gaia/slack/inbox under a sanitized name and the agent is told the path. Indexing them in the bridge put the document in an index the agent child never sees. - The bridge serves exactly one member. It drives one agent with one conversation, so a second member would read the first's history and inherit their "Always allow" grants. test_slack_event_contract.py drives the adapter with events from the real SSEOutputHandler and CanonicalTranslator, and fails if the translator emits a type the adapter neither handles nor deliberately ignores. Also removes SLACK_HEALTH_PORT, which nothing served, adds the missing `gaia slack` section to the CLI reference, and brings the guide and plan in line with the shipped design.
`gaia slack status`, which the TUI runs at every launch, checked the bridge with `os.kill(pid, 0)` — on Windows that is a console Ctrl+C, not a probe. Liveness now goes through the daemon's psutil `pid_alive` and also confirms the process is a Slack bridge, so a stale pid file naming an unrelated process is neither reported as running nor sent SIGTERM by `stop`. psutil joins the `slack` extra. CI: - Making `gaia.messaging` a real package let pylint reach telegram.py's two pre-existing unused `context` arguments; they are now `_context`. - The Decline tests used a /bin/sh stub without the Windows skip. - The palette title was never truncated, so at 20 columns it wrapped to two rows. Eight commands after merging /provider pushed the box one row past a 12-row window.
…-integration-f0237d # Conflicts: # tui/internal/ui/chat/model.go # tui/internal/ui/chat/palette.go # tui/internal/ui/components/helpoverlay.go # tui/internal/ui/components/helpoverlay_test.go
A shared file is downloaded with the bot token as a bearer header, from a URL taken from the event payload. Nothing checked that URL was Slack's, and requests follows redirects by default, so a URL or a redirect pointing elsewhere would have carried the token with it. Slack always sends its own file URLs, so this needs a compromised response to matter — but it is cheap to close. The URL must now be https on a slack.com or slack-edge.com host, compared on the parsed hostname so userinfo and ports are stripped first, and a redirect is refused rather than followed. Tests cover a foreign host, plain http, a lookalike suffix, a URL that is really a request to another host after the `@`, and a redirect — and assert no request is made at all in the refused cases. (cherry picked from commit 9ac570e)
|
All three blockers were already fixed — the review landed on The important part is that the two blockers are now pinned by a test that would have caught them. I re-introduced each bug on the current head and confirmed the new contract test fails:
That was the review's actual complaint — not the two field names, but that the tests invented their own event shapes and so couldn't see either bug. The new test builds its events by running the real One nit is deliberately still open: files shared over Slack are kept, not cleaned up. They moved out of the system temp dir into
🔍 Technical detailsVerified against head
Mutation checks were reverted; the working tree is clean and no commit was pushed for this PR. The review's out-of-scope note on |
|
Merge this one before #3698 — #3698 already contains every commit on this branch, so landing #3698 first would leave this PR empty and close it as a no-op. @kovtcharov-amd this ordering is the one call a human needs to make here; everything else on this PR is verified ready. I also pushed two changes so the PR is safe to land on its own:
All five earlier review findings were already fixed on head before I touched it — nothing outstanding. 🔍 Technical detailsStack evidence. Retargeting is not possible. A PR base must exist in the base repo; Findings verified already-fixed on head:
Validation: |
Adding /slack pushed the block to three lines, which the help overlay cannot render correctly: renderCommandsSection replaces the block by position — the label line plus exactly one continuation — so the third line leaked into every filtered render. Nothing conflicted textually. This branch grew the block while amd#3988 independently landed the test that pins its shape, so the defect only appears once the two meet. Reflowed to two lines, both within the panel's 53-column maximum.
…cleanly Both branches reflowed the block to two lines, but placed /slack differently. Same wording here means no conflict when the stack lands.
|
Merging current Worth stating plainly because it's the interesting part: nothing conflicted. This branch grew the Commands block to three lines; I also matched #3698's wording for the same block, so the two land without conflicting with each other. 🔍 Technical detailsThe defect. Before / after: Both lines stay within the panel's 53-column maximum (the widest existing line in Conflict avoidance. #3698 independently reflowed to two lines but put Validation. |
amd#3697 landed on main as a squash, so every Slack file collided with its own earlier self. This branch carries the later version of each — the native TUI setup panel rather than the terminal hand-off, `gaia slack connect`, and the bot-token host guard — so the resolution keeps it.
…teps 1-4a) (amd#3698) **Draft. Steps 1-4a are done. Step 4b — the transport flip itself — was attempted, measured, and reverted; what it actually costs is now written down.** > **Stacked on amd#3697.** The diff against `main` includes the Slack commit. Review that first; this PR's commits are the four after `feat(slack)`. GAIA has two ways to run the flagship agent and was about to grow a third: the TUI spawns it as a stdio child, the daemon knows how to supervise it as an HTTP sidecar but never does, and the Slack bridge spawns a second stdio child. Converging them is subtraction — it deletes the reasons the other paths exist. Reasoning in [`docs/plans/daemon-convergence.mdx`](docs/plans/daemon-convergence.mdx). ## The headline: gated tools now work over the daemon This is the piece the plan originally missed, and it is the safety-critical one. A gated tool could not run over the daemon **at all**: the agent's HTTP stream did not park on a confirmation, it emitted *"this streaming surface cannot collect that yet"* and cancelled the run, because there was nowhere for an answer to come from. On the Go side `SSEClient` implemented neither permission interface, so the chat view could only record intent. For an agent whose gated set is shell, file writes and code execution, that is the difference between usable and refusing its most useful tools. Now: a `tool_decision` endpoint carrying `confirm_id`, a session-scoped `bypass` endpoint, the stdio transport's own `PermissionState` attached per turn so "always" grants and bypass survive the turn boundary, and both Go interfaces implemented. Proven with a live round trip over the real stream — the agent parks in `confirm_tool_execution`, the client answers, the run resumes. ## Also fixed **`gaia-agent --serve` never worked from a pip install.** The console script pointed past the transport dispatcher at `stdio:main`, so the one transport nobody could reach was the one the package documents. The dispatcher now lives in a light `gaia_agent.entry` that imports neither FastAPI nor the stdio graph until it has chosen — pointing it at `server:main` would have made the stdio wire import a web framework (an extras-only dependency) just to learn it was not selected. **Transport parity.** Claude was refused over HTTP while stdio has always taken `--use-claude`; and switching model on a live session answered 409 "start a new session_id", which discards the conversation and every loaded skill. Both fixed, the model switch reusing stdio's own `switch_model` rather than growing a second implementation. ## Why 4b is not here Flipping `catalog.go` to `TransportDaemon` compiles and breaks two packages: `internal/catalog` (3 tests) and the entire `tui/test` e2e suite (9 tests). The blocker is **`--mock`** — a user-facing persistent flag, and the only test double the TUI has. `SetMockBinary` points "every **subprocess** agent" at a binary; a daemon agent has none. The way through is known (`tui/test/fakedaemon_test.go` already serves the daemon control plane against an isolated `GAIA_DAEMON_HOME`; it needs `/ensure`, `/version` and a streaming `/query`), but that is test infrastructure plus new semantics for a documented flag, on the path every user hits at launch. It belongs on its own where it can be verified against a real daemon and sidecar rather than rushed. Written up as §4.4. ## Test plan - [ ] `python -m pytest hub/agents/gaia/python/tests/ -q` — 337 pass - [ ] `cd tui && go test ./...` — all packages pass, e2e suite included - [ ] `gaia-agent --serve --port 8149` then `curl localhost:8149/health` — this is what did not work before - [ ] Revert the console-script pointer; `test_the_console_script_points_at_the_dispatcher` fails. The pre-existing tests all called `server.main` directly, which is why they stayed green through the bug - [ ] `test_a_gated_tool_runs_once_the_decision_arrives` — the live confirmation round trip, the thing §3.4 is about Pre-existing failures in this environment (verified identical on clean `main` in a separate worktree): `test_full_tool_bundles`, `test_gaia_agent::test_manifest_tools_count`, `test_profilespec_characterization` rows 12-14, `test_structured_vlm_extraction`, `test_doc_examples_runnable` — all missing optional extras or a tool-registry count drift, none in this diff. No eval run: transport only, no prompt, tool, or agent-loop changes. ## Evidence Run on this branch, macOS (Apple Silicon). **`gaia slack` — the new CLI surface:** ```console $ gaia slack status Slack app installed on this machine: yes GAIA configured for Slack: no Bridge running (backgrounded): no Slack is installed but not connected. Run `gaia slack setup`. $ gaia slack start --allowed-users U024BE7LH # no tokens stored yet ❌ Slack is not set up on this machine: no GAIA_SLACK_BOT_TOKEN and no GAIA_SLACK_APP_TOKEN. Run `gaia slack setup` to create the app and store its tokens, or export them directly. Docs: https://amd-gaia.ai/docs/guides/slack $ GAIA_SLACK_BOT_TOKEN=xoxb-… GAIA_SLACK_APP_TOKEN=xapp-… gaia slack start --allowed-users U0AAAAAAA,U0BBBBBBB ❌ Slack adapter refused to start: more than one allowed user. This bridge drives a single agent with a single conversation, so every allowed member would share one history — anyone could ask what the others asked or read — and one set of 'Always allow' approvals. Pass exactly one Slack member ID: gaia slack start --allowed-users U024BE7LH Docs: https://amd-gaia.ai/docs/guides/slack $ gaia slack setup --print-url --no-browser https://api.slack.com/apps?new_app=1&manifest_json=%7B%22display_information%22%3A%7B%22name%22%… $ gaia slack decline --never && gaia slack status Won't ask about Slack again. `gaia slack setup` still works. Slack app installed on this machine: yes GAIA configured for Slack: no Bridge running (backgrounded): no ``` **The two new agent routes, against a live `gaia-agent --serve`:** ```console $ curl -s localhost:8149/version # 2.14 — main's 2.13 added GET /memory {"apiVersion":"2.14","agentVersion":"0.2.0"} $ curl -s -X POST .../query/<unknown-run>/tool_decision -d {"decision":"allow"} {"detail":"No run '11111111-1111-4111-8111-111111111111' is in flight. It may have already finished or been cancelled; the decision was not delivered."} $ curl -s -X POST .../query/<run>/tool_decision -d {"decision":"maybe"} # fails closed {"detail":[{"type":"value_error","loc":["body","decision"],"msg":"Value error, decision must be one of allow, deny, always, got 'maybe'","input":"maybe","ctx":{"error":{}}}]} $ curl -s -X POST .../sessions/never-seen/bypass -d {"enabled":true} {"detail":"No session 'never-seen' exists. Send a query on that session first; bypass applies to a conversation, not to the server."} ``` **Not covered:** no Slack workspace is connected here, so `gaia slack start` stops at the token check above. The DM round trip — streamed reply, Allow/Deny buttons, a file coming back — remains unverified against real Slack. --------- Co-authored-by: Kalin Ovtcharov <kalin@Kalins-Mac-mini.local> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
You can message the flagship GAIA agent from a Slack DM and it answers from your desktop — reading your files, streaming its reply, and sending back files it writes. The TUI offers to connect Slack the first time it sees the app installed, and the whole setup is one click plus two pasted tokens. Shell commands, file writes and code execution still pause for approval, now as Allow/Deny buttons in Slack.
The security argument rests on the agent's existing confirmation gate, and no Slack message or button can disarm it. The bridge refuses to start without an allowlist, answers direct messages only, is pinned to one workspace, and serves exactly one member — it drives one agent with one conversation, so a second member would share the first's history and approvals. Reads stay ungated; the guide says so plainly.
Also fixes
gaia telegramon installed wheels, which failed to import becausegaia.messagingwas never packaged.Test plan
python -m pytest tests/unit/messaging -q— includestest_slack_event_contract.py, which drives the adapter with events from the real handler and translator rather than hand-written onescd tui && go test ./...python util/lint.py --allgaia slack statuson a machine with Slack installed reports it and offers setupgaia slack start --allowed-users U0A,U0Brefuses, explaining the shared conversationgaia slack setup, DM the bot, watch the reply stream, approve a gated command, ask for a file back, and leave one prompt unanswered for ten minutes to see it denied