Skip to content

A session can rename itself through the live Collins tool socket - #209

Merged
ghackett merged 3 commits into
mainfrom
session-mcp-pr3-socket-service
Aug 8, 2026
Merged

A session can rename itself through the live Collins tool socket#209
ghackett merged 3 commits into
mainfrom
session-mcp-pr3-socket-service

Conversation

@ghackett

@ghackett ghackett commented Aug 8, 2026

Copy link
Copy Markdown
Member

PR 3 of the session MCP tools plan (after #201's ancestry walk and #204's shim + protocol layer): the app-side socket service goes live, launched commands carry --mcp-config, and the first tool — set_session_title — works end to end through a real claude CLI.

What's here

  • collins/mcpserver.py (new): SessionToolService, a Gio.SocketService on the per-instance Unix socket. Fully async on the GLib main loop — no threads; per connection the cycle is strictly read → reply → read, so a peer that floods requests without reading replies stalls only itself. Takes injected list_tools() / dispatch(pid, tool, args) callables, so the whole connection machinery is CI-testable without GTK. Untrusted-peer rules: a first frame that isn't a well-formed hello, or any framing violation, disconnects rather than guesses.
    • One hazard found live: Gio silently truncates a socket path past sun_path's 107 bytes, producing a listener no shim can ever dial (the shim degrades cleanly, so the feature would just silently not work). start() now refuses over-long paths loudly; real paths under $XDG_RUNTIME_DIR sit far below the limit.
  • collins/providers.py: module-level MCP_CONFIG_PATH (set by app.py only once the whole chain is up) plus a supports_mcp_config capability flag mirroring supports_fork. New/resume/fork/continue commands and the chat argv all carry the flag; the attach branch carries nothing by construction (claude attach accepts no flags and joins a process that already has its servers) — tested.
  • collins/app.py: service bring-up in do_startup (one failure path: log, leave MCP_CONFIG_PATH unset, commands go out exactly as before — the tools are conveniences, never load-bearing), the codebase's first do_shutdown to tear it down, and the dispatcher: /proc ancestry walk from the shim's hello pid to the tab whose shell spawned its claude, argument re-validation app-side (the socket is reachable by any local process), and the set_session_title handler.
  • collins/window.py: MainWindow.rename_session_tab() — the rename dialog's save path without the dialog. The rename lands in the manual-name slot, so it permanently stops auto-titling for that session, exactly as a hand rename does.
  • collins/terminal.py: TerminalTab.owns_pid_ancestors() over _candidate_pids() (both the pty's foreground pgrp leader and the spawned child, per the daemon-wrapper note there).
  • Tests: 24 for the service (raw socket clients against a live GLib loop, hostile-peer cases, lifecycle) including two true end-to-end tests driving the real mcp_shim subprocess against the real service — the seam neither module's own tests crossed — plus 7 new provider tests for the flag.

Verified live (headless, CLI 2.1.226)

  • A real claude -p --mcp-config … session listed the tool, called it through --allowedTools, and echoed the service's "Session renamed." reply; dispatch received the shim's pid.
  • A throwaway app instance (fresh COLLINS_APP_ID, isolated data): service up, runtime dir keyed by app id, typed resume command carries the flag, a client fed into the tab's own terminal renamed the session through the socket (screenshots below), and a client not descended from any tab got the clean identity error — the expected shape for daemon-hosted bg jobs until the identity fallback lands.
  • PR 2's deferred question answered: the CLI merges an stdio server's env block into the inherited environment (probe saw both the config var and a parent-env marker). Either behavior works for the shim, but now it's recorded.

Screenshots

Before: the tab's typed command carries --mcp-config. After: a client run from the tab's own shell called set_session_title — tab title and sidebar row both renamed, ok reply visible in the terminal.

Before After
tab open with --mcp-config in the typed resume command tab and sidebar renamed by the MCP tool

Per the spec, v1 pre-allows nothing — every first use goes through the CLI's own permission prompt. Next up: PR 4 (open_in_editor + show_image), PR 5 (notify_user).

🤖 Generated with Claude Code

https://claude.ai/code/session_019ScBAgKFEdAAeQmcYGoGCA

ghackett and others added 2 commits August 8, 2026 13:32
PR 3 of the session MCP tools plan: the app-side socket service goes
live and the first tool works end to end. mcpserver.py owns a
Gio.SocketService on the per-instance Unix socket, fully async on the
main loop (read -> reply -> read per connection, so a peer that stops
reading stalls only itself), with injected list_tools/dispatch so CI
tests the whole connection machinery without GTK. The service refuses
socket paths past sun_path's 107 bytes: Gio silently truncates them,
which produced a listener no shim could ever dial (observed live).

providers.py gains MCP_CONFIG_PATH plus a supports_mcp_config
capability flag, and appends --mcp-config to new/resume/continue/chat
commands - never to `claude attach`, which takes no flags and joins a
process that already has its servers. app.py brings the service up in
do_startup (any failure logs and leaves commands untouched), adds the
codebase's first do_shutdown to tear it down, and dispatches calls by
walking /proc ancestry from the shim's hello pid to the tab whose
shell spawned its claude (TerminalTab.owns_pid_ancestors); processes
not under any tab get a clean identity error. set_session_title lands
in the manual-name slot via MainWindow.rename_session_tab, exactly as
a hand rename does.

Verified against claude CLI 2.1.226 headlessly: the tool listed,
called, and replied through a real -p session; a throwaway app
instance renamed a real tab through the socket, and the CLI merges an
stdio server's env block into the inherited environment (the question
PR 2 deferred).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019ScBAgKFEdAAeQmcYGoGCA
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019ScBAgKFEdAAeQmcYGoGCA
@ghackett

ghackett commented Aug 8, 2026

Copy link
Copy Markdown
Member Author

@claude review

@claude

claude Bot commented Aug 8, 2026

Copy link
Copy Markdown

Claude finished @ghackett's task in 4m 55s —— View job


Review complete

  • Gather context (diff, changed files, related modules)
  • Review collins/mcpserver.py (new socket service)
  • Review collins/providers.py (mcp-config flag)
  • Review collins/app.py (bring-up/shutdown/dispatcher)
  • Review collins/window.py / collins/terminal.py
  • Review tests
  • Post final review

Solid, well-tested PR — the framing, lifecycle, and capability-flag plumbing are careful and the 24+7 new tests cover the protocol edges (bad hellos, oversize lines, concurrent connections, stop-mid-request, and the two real shim↔service end-to-end tests) well. One security gap worth fixing before more tools land on top of this identity mechanism, plus a couple of minor notes.

1. The shim's identity (pid) is self-reported and never verified against the real peer

collins/mcpserver.py:143-155 (_greet) takes pid straight from the client's hello frame — it only checks the type/range (isinstance(pid, int), not a bool, > 0), never that this is the pid of the process actually holding the other end of the socket. mcp_shim.py:132 sends os.getpid() in good faith, but nothing stops any other local process from opening the same socket and lying in that field.

That pid is then load-bearing for authorization: app.py's _mcp_tab_for_pid (app.py:855-871) walks /proc ancestry from it to decide which open Collins tab a call is allowed to act on, and _mcp_dispatch trusts that result completely. The module's own docstring already acknowledges "any local process of the user's can still connect" — but the mitigation described (disconnect on bad framing / a malformed hello) doesn't cover a well-formed hello with a spoofed pid. Concretely: any local process belonging to the same user can declare an arbitrary (real) pid belonging to another tab's claude process and be treated as if it were that tab's shim — bypassing the ownership check entirely, for set_session_title today and for open_in_editor / show_image / notify_user once PR4/PR5 land, which raises the stakes considerably.

Unix domain sockets give you a kernel-verified alternative for exactly this: Gio.SocketConnection.get_socket().get_credentials() returns the real peer pid/uid via SO_PEERCRED (Linux) rather than trusting the payload. Worth authenticating against that instead of (or in addition to) message["pid"].

Fix this →

2. No automated coverage for the dispatcher glue in app.py / window.py

_mcp_tab_for_pid, _mcp_dispatch (app.py:830-880), and MainWindow.rename_session_tab (window.py:3518-3529) have no unit tests — only the PR description's manual/live verification. The service and provider layers are thoroughly covered because they're GTK-free by design; this glue is GTK-bound so it's a harder ask, but the validation → ownership-resolution → error-message branching in _mcp_dispatch is exactly the kind of logic that's easy to regress silently (e.g. the unresolved-session_id and not-launched-from-a-tab error paths). Not a blocker, just a gap worth closing, possibly by factoring the pid→tab resolution into something fakeable without a real Gtk.Application.

Everything else checked out

  • providers.py's _mcp_config_flag() gating (capability flag + MCP_CONFIG_PATH is not None) and the attach-branch exemption are correct and directly tested (test_attach_carries_no_mcp_config confirms ClaudeProvider.resume_command's attach path rebuilds its own command rather than reusing the super() result that already carries the flag).
  • The sun_path 107-byte truncation guard in mcpserver.py:77-78 is a good catch — silent truncation would have been a nasty "feature quietly doesn't work" bug.
  • stop() correctly drops live connections, not just the listener (regression-tested per the PR3/PR204 note in test_stop_closes_established_connections).
  • do_shutdown ordering and the "best-effort, never load-bearing" failure handling in _start_mcp_service look right — a failed bring-up leaves MCP_CONFIG_PATH unset and launched commands unaffected.

Branch: session-mcp-pr3-socket-service

@ghackett
ghackett marked this pull request as ready for review August 8, 2026 17:40
Review follow-ups. The shim's declared pid is load-bearing for
authorization (the dispatcher walks /proc ancestry from it to pick the
tab a call may act on), and any local process of the user's can open
the socket - so _greet now checks the hello pid against the peer's
real pid via Gio.Socket.get_credentials() and drops liars. Credentials
being unreadable also fails the hello: this path is Linux-only already
(the dispatcher reads /proc), so an unanswerable kernel means broken,
not trustworthy.

The dispatcher's branching moves into GTK-free
mcptools.run_tool_call() (validate first and unconditionally, identity
second so a bad call fails identically whoever makes it, then the
handler), with CI tests pinning the order and error strings; app.py
keeps only the widget-touching halves. Re-verified live: the headless
app-instance probe still renames a real tab through the socket, and a
non-tab client still gets the clean identity error.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019ScBAgKFEdAAeQmcYGoGCA
@ghackett

ghackett commented Aug 8, 2026

Copy link
Copy Markdown
Member Author

Both review findings addressed in 5e7c015:

1. Spoofable hello pid → kernel-verified via SO_PEERCRED. _greet now checks the declared pid against connection.get_socket().get_credentials().get_unix_pid() and drops any mismatch. Unreadable credentials also fail the hello — this path is Linux-only already (the dispatcher walks /proc), so an unanswerable kernel means broken, not trustworthy. New tests: a client claiming pid 1 is disconnected, an honest os.getpid() hello is served, and the real-shim end-to-end tests exercise the check for free (the shim's hello carries its own pid).

2. Dispatcher glue coverage. The validate → identity → handler branching moved into GTK-free mcptools.run_tool_call(), so CI now pins the order (validation runs before identity resolution, so a bad call fails identically whoever makes it — no leaking whether a tab owns the caller through the error shape) and the shared error strings. app.py keeps only the widget-touching halves: the pid→tab walk and the set_session_title handler. Six new tests in test_mcptools.py.

Re-verified live after the changes: the headless throwaway-app probe still renames a real tab through the socket, and a non-tab client still gets the clean identity error. Full suite: 1240 passing.

🤖 Generated with Claude Code

https://claude.ai/code/session_019ScBAgKFEdAAeQmcYGoGCA

@ghackett
ghackett merged commit 03ac6cb into main Aug 8, 2026
2 checks passed
@ghackett
ghackett deleted the session-mcp-pr3-socket-service branch August 8, 2026 17:50
ghackett added a commit that referenced this pull request Aug 8, 2026
PR 4 of the [session-mcp-tools
spec](#201): the
`open_in_editor` and `show_image` handlers land behind the socket
service #209 wired up. An agent can now put the file it's talking about
on the user's screen — "I've put the failing test on screen" instead of
hoping they click a path — and pop a screenshot, plot, or render into
the in-app lightbox without launching an external viewer.

## What changed

- **`mcptools.TOOLS`** grows the two schemas: `open_in_editor(path,
line?)` (line is 1-based, minimum 1) and `show_image(path)`. The
existing table-driven validator covers them — wrong types, `line: 0`,
and stray arguments are all rejected app-side, since the socket is
reachable by any local process.
- **`app.py`** adds the two handlers plus `_mcp_resolve_file`: a
relative path resolves against the running agent's cwd first (it may
have cd'd into a worktree), then the tab's project root — the same order
clickable file references use (`terminal._reference_roots`).
- `open_in_editor` requires the file inside the session's project
(`can_open_in_editor`), converts the 1-based line to the editor's
cursor, and opens via the calling tab's own editor.
- `show_image` follows the clicked-image-reference recipe: any readable
image shows, inside the project or not; the lightbox's "Open in Editor"
button only appears when the tab could actually open it.
- **`window.py`** promotes `_open_in_tab_editor` to public
`open_in_tab_editor`: a session-attributed call must land in the calling
session's own tab — the window-level `_open_in_editor` picks a tab by
project membership and may switch tabs, which is the wrong door for an
agent-driven open.

## Screenshots

A staged session called its own tools through the real socket.
`open_in_editor("src/widget.py", line=12)` — cursor at 12:1 in the
status bar:

![open_in_editor result: widget.py open at line 12 in the session's
editor
pane](https://raw.githubusercontent.com/episode6/screenshots/main/collins/session-mcp-pr4/pr4-editor-20260808.png)

`show_image` on a staged render — lightbox over the window, with its
Open in Editor button since the image lives inside the project:

![show_image result: the render floating in the in-app
lightbox](https://raw.githubusercontent.com/episode6/screenshots/main/collins/session-mcp-pr4/pr4-lightbox-20260808.png)

## Verification

- 75 GTK-free tests pass locally (`test_mcptools` + `test_mcp_shim` +
`test_mcpserver`), including new schema/validation coverage for both
tools and the tool-list assertions updated to track the table.
- E2E, headless, against a throwaway app instance driving the **real
socket path** (hello + call frames; identity faked to the open tab,
since the probe isn't a descendant of the tab's shell — the ancestry
walk itself was e2e-verified in #209 and is unchanged here):
- `open_in_editor` with a relative path + line → editor opened at 12:1 ✔
(screenshot)
- `show_image` with an absolute path → lightbox presented ✔ (screenshot)
  - `/etc/hostname` → "That file is outside this session's project" ✔
  - missing file → "No such file: src/nope.py" ✔
  - non-image to `show_image` → "Not an image Collins can display" ✔
  - `line: 0` → "'line' must be at least 1" ✔
  - missing `path` → "Missing required argument: path" ✔

Per the spec, v1 pre-allows nothing — both tools go through the CLI's
normal permission prompts. Next up is PR 5, `notify_user`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_016dyuyFPinBYmRVsRUHYRTQ

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant