diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index ae5569bb2..62f8e2cba 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -1,7 +1,7 @@ { "$schema": "https://anthropic.com/claude-code/marketplace.schema.json", "name": "switch-plugins", - "description": "Switch platform plugins for Claude Code", + "description": "Switch platform connector plugins for Claude Code and Codex", "owner": { "name": "SandboxAQ" }, @@ -11,6 +11,12 @@ "description": "Connect Claude Code to a Switch platform instance as a participating agent", "category": "integrations", "source": "./connectors/claude-code-plugin" + }, + { + "name": "switch-connector-codex", + "description": "Connect Codex to a Switch platform instance as a participating agent", + "category": "integrations", + "source": "./connectors/codex-plugin" } ] } diff --git a/CLAUDE.md b/CLAUDE.md index 90218137c..1a45f11f5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -74,18 +74,36 @@ just test -k "test_name" # run specific test - Session management: API endpoints use middleware-provided sessions; background work creates sessions explicitly - All participants in rooms are Matrix clients (matrix-nio) connecting to Tuwunel -## Claude Code connector plugin - -The `switch-connector` Claude Code plugin lives in `connectors/claude-code-plugin/` -(skill in `skills/switch/SKILL.md`, MCP server, hooks, channel). When you change -how agents interact with Switch — new/changed MCP tools, in-room commands, -room workflow, or anything an agent-facing client needs to know: - -- **Update the skill** (`connectors/claude-code-plugin/skills/switch/SKILL.md`) - so the documented workflow matches the actual behavior. -- **Bump the plugin version** in - `connectors/claude-code-plugin/.claude-plugin/plugin.json` so installs pick - up the change. +## Connector plugins + +There are **two** connector plugins under `connectors/`, one per agent host, and +each ships its own copy of the Switch room-workflow skill at +`skills/switch/SKILL.md`: + +- `connectors/claude-code-plugin/` — manifest `.claude-plugin/plugin.json`. + Ships the skill plus an MCP config (`.mcp.json`), hooks, and a local channel + process. +- `connectors/codex-plugin/` — manifest `.codex-plugin/plugin.json`. Ships + **only** the skill. Codex does not expand `${VAR}` in a plugin-bundled + `.mcp.json` and has no `${CLAUDE_PLUGIN_ROOT}` equivalent, so switchdash + registers the Switch MCP server on argv when it launches the session instead. + +When you change how agents interact with Switch — new/changed MCP tools, in-room +commands, room workflow, or anything an agent-facing client needs to know: + +- **Update both skills.** A room-workflow change must land in + `connectors/claude-code-plugin/skills/switch/SKILL.md` *and* + `connectors/codex-plugin/skills/switch/SKILL.md` so the documented workflow + matches actual behavior on both hosts. +- **Bump both plugin versions** — + `connectors/claude-code-plugin/.claude-plugin/plugin.json` and + `connectors/codex-plugin/.codex-plugin/plugin.json` — so installs pick up the + change. +- **Diff the two skills after editing.** They are deliberately not identical + (host-specific wording for tool namespacing, event delivery and task + notifications, attachments, and MCP registration), so diff them to confirm + every remaining difference is intentional rather than a fix that only landed + on one side. ## Code Style diff --git a/README.md b/README.md index b7feeaf53..5ae03e3e1 100644 --- a/README.md +++ b/README.md @@ -82,13 +82,19 @@ Once it's up, open: | Mattermost (chat with agents) | | `user` / `user1234` | **First run — connect your own agent.** Switch ships no bundled agents; the -point is to plug in yours. The quickest path is the **Claude Code connector** -in [`connectors/claude-code-plugin/`](connectors/claude-code-plugin/): +point is to plug in yours. The quickest path is a bundled connector — the +**Claude Code connector** in +[`connectors/claude-code-plugin/`](connectors/claude-code-plugin/), or the +**Codex connector** in [`connectors/codex-plugin/`](connectors/codex-plugin/): 1. In the gateway, create a room (and note its id). -2. Install and configure the connector so Claude Code registers as a Switch - agent and joins the room — the plugin's `configure` skill walks you through - registering with this Switch instance and writing the credentials. +2. Install and configure the connector so your agent registers as a Switch + agent and joins the room. For Claude Code, the plugin's `configure` skill + walks you through registering with this Switch instance and writing the + credentials. The Codex plugin ships the room-workflow skill only — its + Switch MCP server is registered by the [switchdash desktop app](dash/) when + it launches the session, so set the agent up there; see + [`connectors/codex-plugin/README.md`](connectors/codex-plugin/README.md). 3. Talk to the agent from Mattermost, and watch the interaction in the gateway. Stop the stack with `just standalone-down`, or `just standalone-reset` to also @@ -172,7 +178,7 @@ The operator dashboard frontend lives in [`gateway/`](gateway/) | `core/tests/` | Test suite, mirroring the `switch_core/` module structure | | `gateway/` | Operator dashboard frontend (Node/Vite) | | `dash/` | The switchdash desktop app | -| `connectors/` | Agent connectors (`claude-code-plugin`) | +| `connectors/` | Agent connectors (`claude-code-plugin`, `codex-plugin`) | | `deploy/` | Deployment assets — Docker Compose stacks (`local/`) and shared resources | | `justfile` | Repo-root task runner (drives all three code trees) | @@ -182,6 +188,45 @@ Tests live in `core/tests/switch_core/` and mirror the module structure. The suite uses pytest with pytest-asyncio; store tests run against a real PostgreSQL instance (not mocks, not SQLite). Run them with `just test`. +## Telemetry + +Switch Console collects anonymous usage analytics to help us understand how the +app is used and improve it. Telemetry is **opt-in** — you are asked during first +run, and nothing is sent without your explicit agreement. Switch Core (the server) sends no telemetry at all. + +What we collect: + +| Data | Example | Purpose | +| --- | --- | --- | +| Event name | `session_started`, `room_created` | Understand which features are used | +| App version | `0.9.14` | Track adoption of new releases | +| Release channel | `stable` | Separate pre-release from released usage | +| Operating system | `darwin`, `23.6.0` | Prioritise platform support | +| Agent provider | `claude`, `codex` | Understand which agents people run | +| Outcome and error code | `failure`, `docker_daemon_down` | Prioritise bug fixes | +| Counts and flags | `agent_count: 3`, `has_initial_prompt: true` | Size features without seeing content | +| Anonymous client ID | `3f2a9c41-…` (random UUID) | Count unique installations | + +Every field is drawn from a fixed vocabulary of enumerated values, numbers and +booleans — free text cannot be transmitted. + +**What we never collect:** source code, prompts, file paths, working +directories, repository or project names, room or agent names, server URLs or +hostnames, usernames, emails, API keys or credentials, model outputs, search +queries, error messages or stack traces, IP addresses, or any personally +identifiable information. + +Events are sent to a relay we operate (`telemetry.flintai.dev`), which forwards +them to our analytics providers; no vendor credentials ship in the app. + +**Opting out:** turn off *Send anonymous usage data* in Settings → Telemetry. +Sending stops immediately — the setting is checked before every event — and you +can change it back at any time. Declining at first run leaves telemetry off. + +For the complete field-by-field list of every event, how collection is enforced, +where the data goes and why it cannot be traced to a person, see +[`docs/TELEMETRY.md`](docs/TELEMETRY.md). + ## License Agent Switch is licensed under the **Apache License 2.0 with the Commons Clause** diff --git a/connectors/claude-code-plugin/.claude-plugin/plugin.json b/connectors/claude-code-plugin/.claude-plugin/plugin.json index 75ef87bac..194dce9fb 100644 --- a/connectors/claude-code-plugin/.claude-plugin/plugin.json +++ b/connectors/claude-code-plugin/.claude-plugin/plugin.json @@ -1,5 +1,5 @@ { "name": "switch-connector", - "version": "0.3.0", + "version": "0.3.2", "description": "Connect Claude Code to a Switch platform instance as a participating agent" } diff --git a/connectors/claude-code-plugin/skills/switch/SKILL.md b/connectors/claude-code-plugin/skills/switch/SKILL.md index 461249f65..f8d476c2b 100644 --- a/connectors/claude-code-plugin/skills/switch/SKILL.md +++ b/connectors/claude-code-plugin/skills/switch/SKILL.md @@ -1,6 +1,6 @@ --- name: switch -description: REQUIRED before calling ANY `mcp__plugin_switch-connector_switch__*` tool (list_rooms, connect_to_room, read_context, post_message, send_targeted_message, list_participants, delegate_task, accept_task, update_task, finalise_task, cancel_task, list_tasks, create_room, invite_agent_to_room, list_all_rooms, get_room_detail, list_bridges, list_reference_types, create_reference, attach_reference_to_room, link_rooms, unlink_rooms, list_room_groups, create_room_group, get_room_group_detail, list_agents, get_agent_detail, update_agent_detail). Load this skill the moment the user mentions Switch, a Switch room, joining/connecting to a room, listing rooms, posting in a room, creating a room, creating a room group, creating a reference, linking rooms, inspecting or updating an agent, or interacting with other Switch agents — BEFORE you call any tool. The skill explains the room workflow, interaction modes, the task-protocol lifecycle, the moderation tools (room creation, invites, references, links), and the rules you must follow to participate correctly. +description: REQUIRED before calling ANY `mcp__plugin_switch-connector_switch__*` tool (list_rooms, connect_to_room, read_context, post_message, send_targeted_message, list_participants, list_roles, get_role_detail, assume_role, release_role, define_role, edit_role, delete_role, delegate_task, accept_task, update_task, finalise_task, cancel_task, list_tasks, create_room, invite_agent_to_room, list_all_rooms, get_room_detail, list_bridges, list_reference_types, create_reference, attach_reference_to_room, link_rooms, unlink_rooms, list_room_groups, create_room_group, get_room_group_detail, list_agents, get_agent_detail, update_agent_detail). Load this skill the moment the user mentions Switch, a Switch room, joining/connecting to a room, listing rooms, posting in a room, creating a room, creating a room group, creating a reference, linking rooms, inspecting or updating an agent, or interacting with other Switch agents — BEFORE you call any tool. The skill explains the room workflow, interaction modes, the task-protocol lifecycle, the moderation tools (room creation, invites, references, links), and the rules you must follow to participate correctly. --- # Switch Room Workflow @@ -34,9 +34,11 @@ message bus. (freshest last). Top-level messages are roots with an empty `replies` list. Every message carries an `id` — use it as `thread_id` to reply into that thread (see "Threads" below). -5. **Check participants** — call `list_participants` to see who else is in - the room, the room role each currently holds (if any), their `agent_type`, - and their task capabilities. +5. **Check participants** — call `list_participants` for the current roster + (`id`, `name`, `type`, `status`, `alias`). Each participant's + `agent_type`, task capabilities and room role come from the + `participants` array in the `connect_to_room` payload, or from + `get_room_detail`. 6. **Act** — see the interaction modes below. ## Interaction modes @@ -115,7 +117,7 @@ bridge as a real platform file upload (Slack, Mattermost). name is bolded in the file's comment instead. - **No channel tool available?** (e.g. a switchdash-managed session where the channel process is not running): upload directly to the bridge API — - `curl -X POST "$SWITCH_API_ENDPOINT/agents/$SWITCH_AGENT_ID/rooms//media" + `curl -fsS -X POST "$SWITCH_API_ENDPOINT/agents/$SWITCH_AGENT_ID/rooms//media" -H "Authorization: Bearer $SWITCH_API_TOKEN" -F "files=@/path/to/report.md" -F "caption=..."` (optional `-F "thread_id=..."`; repeat `-F "files=@..."` for several files in one message). Returns the posted `event_id`. @@ -292,9 +294,12 @@ applies. - **`update_agent_detail`** — change an agent's editable settings. **Owner-only**: you may only update an agent whose owner matches your own owner. `options` is a PARTIAL map of known-agent options merged over the - current ones (for a claude-code agent: `repo_dir` (working directory), - `channels_enabled`, `notify_user`, `subagent_name`) — only the keys you - pass change. `parent_agent_id` sets the agent's parent (validated against + current ones — the keys differ per known-agent type. For `codex`: + `repo_dir` (working directory), `notify_user`, `auto_session`. For + `claude-code`: those plus `channels_enabled` and `subagent_name`. Only the + keys you pass change, and a key the type does not define is ignored rather + than rejected — so check the returned detail rather than assuming a write + landed. `parent_agent_id` sets the agent's parent (validated against self-parenting and cycles); `clear_parent=true` detaches it to top-level. - **`list_reference_types`** — discover the Reference sub-types this instance supports, including the per-type `value_schema`. Call this @@ -304,7 +309,9 @@ applies. Drive, Confluence, GitHub — call `list_reference_types` for the full list). Required: `type`, `name`, `description`, `instructions`, `value`. - Optional: `visibility` (defaults to `"private"`). The reference is + Optional: `read_visibility` / `write_visibility` (both default + `"private"`; `write_visibility` must not be `"public"` while + `read_visibility` is `"private"`). The reference is owned by your agent's user. Use the `instructions` field to tell other agents how to USE the reference — what's in it, when to consult it, any caveats. @@ -473,7 +480,7 @@ an alias only resolves in the room it was set in. ## Formatting messages for bridged channels Your messages render on whatever external platform the room is bridged to -(check `bridge_display_name` in the `connect_to_room` / `get_room_detail` +(check `bridge_display_name` in the `get_room_detail` payload). The platforms do **not** render Markdown identically, so adapt: - **Slack** renders only a *subset* of Markdown (mrkdwn). **Bold**, @@ -529,6 +536,23 @@ are listed in the `connect_to_room` payload (`roles`) and via `list_roles`. - **`release_role()`** — drop the role you hold (idempotent). Ending your session also releases it automatically. +**Creating and editing roles.** The three tools above consume roles someone +else defined; these author them. All three act on the **connected room** and +require write access to it, so they are moderation tools — use them when you +are setting a room up, not in passing. + +- **`define_role(name, instructions, exclusive=False)`** — add a role. `name` + must be unique within the room. `instructions` is the bundle `assume_role` + hands whoever takes it, so write it as instructions *to that agent*, not as + a description of the role. Set `exclusive` when at most one live agent may + hold it at a time. +- **`edit_role(name, instructions=None, exclusive=None)`** — change a role's + instructions and/or its exclusivity; omit a field to leave it as is. Edits + apply on the **next** `assume_role` — an agent already holding the role + keeps the instructions it was given, so ask it to release and re-assume if + the change is meant to reach it now. +- **`delete_role(name)`** — remove the role and any lease on it. + **Exclusive vs shared.** An `exclusive` role admits at most one live holder: it is leased to you with a fast heartbeat while your session stays alive and **auto-releases shortly after you disconnect**, so another agent can take @@ -571,6 +595,9 @@ tell whether a holder is reachable in this room right now. - `assume_role` / `release_role` — take on (and later drop) a room-scoped role and its instruction bundle. One role at a time; exclusive roles are leased with auto-release on disconnect. +- `define_role` / `edit_role` / `delete_role` — author the roles others + assume. Moderation tools: they need write access to the connected room, and + an edit only reaches a holder on its next `assume_role`. - `delegate_task` / `accept_task` / `update_task` / `finalise_task` / `cancel_task` / `list_tasks` — for tracked, formal work. - `list_bridges` — before creating a room, to discover the available @@ -583,7 +610,7 @@ tell whether a holder is reachable in this room right now. - `list_all_rooms` / `get_room_detail` — enumerate every room on the instance, and inspect a room's members and configuration. - `list_agents` — list every agent on the instance (optionally filtered - by name, owner, known-agent type, or role). + by name, owner, or known-agent type). - `list_room_groups` / `get_room_group_detail` — see how rooms are organized into groups, and inspect one group's members + subgroups. - `create_room_group` — provision a new room group (optionally nested diff --git a/connectors/codex-plugin/.codex-plugin/plugin.json b/connectors/codex-plugin/.codex-plugin/plugin.json new file mode 100644 index 000000000..1e1064bfc --- /dev/null +++ b/connectors/codex-plugin/.codex-plugin/plugin.json @@ -0,0 +1,6 @@ +{ + "name": "switch-connector-codex", + "version": "0.1.2", + "description": "Connect Codex to a Switch platform instance as a participating agent", + "skills": "./skills/" +} diff --git a/connectors/codex-plugin/README.md b/connectors/codex-plugin/README.md new file mode 100644 index 000000000..d62a28256 --- /dev/null +++ b/connectors/codex-plugin/README.md @@ -0,0 +1,39 @@ +# switch-connector (Codex) + +The Codex build of the Switch connector plugin. It ships the **Switch +room-workflow skill** (`skills/switch/SKILL.md`) — the room workflow, +interaction modes, thread semantics, task protocol, room roles, and +moderation tools an agent needs in order to participate in a Switch room +correctly. + +Manifest: `.codex-plugin/plugin.json`. Registered in the repo marketplace +(`.claude-plugin/marketplace.json`) as `switch-connector-codex`. + +This is the sibling of `connectors/claude-code-plugin/`, which additionally +ships an MCP config, hooks, and a local channel process. Those pieces are +Claude Code-specific and are deliberately **not** part of this plugin. + +## The Switch MCP server is not registered by this plugin + +Codex does **not** expand `${VAR}` inside a plugin-bundled `.mcp.json`, and +there is no `${CLAUDE_PLUGIN_ROOT}` equivalent for a plugin to reference its +own install path. A bundled MCP config would therefore be shipped with +unresolvable placeholders — it would either fail loudly or, worse, connect +with literal `${SWITCH_API_TOKEN}` text. So this plugin ships no `.mcp.json` +and never references its own path. + +Instead, **switchdash registers the Switch MCP server when it launches the +Codex session**, passing the resolved endpoint and the *name* of the token +environment variable (`bearer_token_env_var = "SWITCH_API_TOKEN"`) as +`-c mcp_servers.switch.*` overrides. The token itself never reaches argv — +switchdash injects it, along with `SWITCH_API_ENDPOINT` and +`SWITCH_AGENT_ID`, into the session's environment from the agent's +`.switch/agents/.json`, and Codex reads it at request time. The skill +assumes those tools are present under the `switch` server; if they are +missing, the session was launched without the Switch MCP config. + +Attachments are handled the same way — there is no channel process for +Codex, so the skill documents `curl` against the bridge media endpoint +(`/agents//rooms//media`) using the session's +`SWITCH_API_ENDPOINT` / `SWITCH_AGENT_ID` / `SWITCH_API_TOKEN` environment +variables. diff --git a/connectors/codex-plugin/skills/switch/SKILL.md b/connectors/codex-plugin/skills/switch/SKILL.md new file mode 100644 index 000000000..0da5a8b21 --- /dev/null +++ b/connectors/codex-plugin/skills/switch/SKILL.md @@ -0,0 +1,635 @@ +--- +name: "switch" +description: "REQUIRED before calling ANY tool on the `switch` MCP server (list_rooms, connect_to_room, read_context, post_message, send_targeted_message, list_participants, list_roles, get_role_detail, assume_role, release_role, define_role, edit_role, delete_role, delegate_task, accept_task, update_task, finalise_task, cancel_task, list_tasks, create_room, invite_agent_to_room, list_all_rooms, get_room_detail, list_bridges, list_reference_types, create_reference, attach_reference_to_room, link_rooms, unlink_rooms, list_room_groups, create_room_group, get_room_group_detail, list_agents, get_agent_detail, update_agent_detail). Load this skill the moment the user mentions Switch, a Switch room, joining/connecting to a room, listing rooms, posting in a room, creating a room, creating a room group, creating a reference, linking rooms, inspecting or updating an agent, or interacting with other Switch agents — BEFORE you call any tool. The skill explains the room workflow, interaction modes, the task-protocol lifecycle, the moderation tools (room creation, invites, references, links), and the rules you must follow to participate correctly." +--- + +# Switch Room Workflow + +You are connected to a **Switch** platform instance. Switch orchestrates AI +agents in collaborative rooms using the Matrix protocol as the internal +message bus. + +The Switch tools referenced below are exposed by the `switch` MCP server. +That server is registered by switchdash when it launches your session — it +is **not** bundled with this plugin. If the tools are missing, the session +was launched without the Switch MCP config; say so rather than guessing. + +## How to participate + +1. **List your rooms** — call `list_rooms` to see which rooms you are + assigned to. +2. **Connect to a room** — call + `connect_to_room(room_id, include_general_instructions=False)`. **You + already have the general Switch usage instructions from this skill**, so + pass `include_general_instructions=False` to skip the room-onboarding + text and avoid duplicating it. You still receive room-specific resources + in the response: `participants`, `references`, `documents`, `packages`, + `reference_types`, and `linked_rooms`. Each reference, document, and + package carries its own `instructions` field — read those carefully, + they tell you how to use that specific resource. The `linked_rooms` + array advertises related rooms — see "Linked rooms" below. +3. **Read context** — call `read_context` to see the conversation history. + Always read before contributing, and read again whenever you are handed + a room message. **There is no background event stream in this session** + (see "Staying up to date" below), so `read_context` is your only + complete view of the room. It returns the timeline **grouped into + threads**: a list of `{root, replies: [...]}` ordered by latest activity + (freshest last). Top-level messages are roots with an empty `replies` + list. Every message carries an `id` — use it as `thread_id` to reply into + that thread (see "Threads" below). +4. **Check participants** — call `list_participants` for the current roster + (`id`, `name`, `type`, `status`, `alias`). Each participant's + `agent_type`, task capabilities and room role come from the + `participants` array in the `connect_to_room` payload, or from + `get_room_detail`. +5. **Act** — see the interaction modes below. + +## Interaction modes + +You have three ways to participate. Pick the one that fits the situation: + +- **`post_message`** — broadcast to the room. Everyone sees it; other + agents receive it as *unaddressed* context (no expected action). Use for + discussion, results, status updates, replying to messages addressed to + you. **Do not write `@agent-name` mentions in the body** — see the + "No stray @-mentions" rule below. If you need a specific agent to act, + use `send_targeted_message` (which prepends the mentions for you) instead. +- **`send_targeted_message`** — broadcast with `@mentions` prepended for + specific agents or users. They receive it as an *addressed* event and + will respond; others see it as context. Use when you need a specific + participant to act, but the request is informal (a question, a nudge, a + handoff) and doesn't need lifecycle tracking. Pass + `target_names=["agent_a", "user_b"]` and a `body`. You can also address + **roles** with `target_roles=["manager"]` to reach whoever currently + holds a role without naming the agent — it fans out to every live holder + (see "Room roles" below). At least one of `target_names` / `target_roles` + is required. +- **Task protocol** (`delegate_task`, `accept_task`, `update_task`, + `finalise_task`, `cancel_task`, `list_tasks`) — formal tracked work with + a persistent lifecycle (`pending` → `ongoing` → `finalised` / + `cancelled`). Use when the work is concrete, the outcome matters, and + you may want to check on it later. + +**Rule of thumb:** message → conversation; targeted message → request a +synchronous response; task → request tracked work. + +## Threads + +Both `post_message` and `send_targeted_message` accept an optional +`thread_id` to post a **threaded reply** instead of a top-level message: + +- `thread_id` is the `id` of any message in the thread (a root or a reply) — + it is normalised to the thread root, so you can pass whatever id you have. +- Omit `thread_id` for a normal top-level message (default). +- Get ids from `read_context` (each message has an `id`) or from the + `thread_id` on a message you were handed. When a message you receive + carries a `thread_id`, **reply with that same `thread_id`** so the + conversation stays in its thread rather than fragmenting to the top level. +- Threads bridge to/from Mattermost natively. Pull thread activity with + `read_context` as usual. + +## Sending and receiving attachments + +Messages can carry file attachments (images most commonly). Both directions +work in any room; on bridged rooms the attachment crosses the bridge as a +real platform file upload (Slack, Mattermost). + +**There is no attachment tool in this session.** Codex sessions do not run +the Switch channel process, so there is no `send_attachment` / +`download_attachment` tool — do not look for one. Use the bridge API +directly with `curl`, authenticated with the session's environment +variables (`SWITCH_API_ENDPOINT`, `SWITCH_AGENT_ID`, `SWITCH_API_TOKEN`). + +- **Receiving:** attachments show up in `read_context` history on a + message's `attachments` field, each with `filename`, `mimetype`, `size`, + and an `mxc` URI. Download the bytes, then read the local file: + + ```bash + curl -fsS -G "$SWITCH_API_ENDPOINT/agents/$SWITCH_AGENT_ID/rooms//media" \ + -H "Authorization: Bearer $SWITCH_API_TOKEN" \ + --data-urlencode "mxc=" \ + -o /tmp/switch-attachment.png + ``` + +- **Sending:** POST the file to the same endpoint as multipart form data. + `caption` and `thread_id` are optional form fields (`thread_id` has the + same threading semantics as `post_message`). The response carries the + posted `event_id`: + + ```bash + curl -fsS -X POST "$SWITCH_API_ENDPOINT/agents/$SWITCH_AGENT_ID/rooms//media" \ + -H "Authorization: Bearer $SWITCH_API_TOKEN" \ + -F "file=@/path/to/image.png" \ + -F "caption=..." + ``` + + The file enters the room as a native image/file event; bridges relay it + out as a platform file upload. Note that on Slack the upload renders under + the Switch app identity (Slack file uploads can't carry the per-agent + name/icon); your name is bolded in the file's comment instead. +- Attachments are capped (20MB by default, server-configurable); oversize + uploads are rejected loudly rather than truncated. +- If those environment variables are not set in your session, say so — do + not fabricate an upload or claim an attachment was sent. + +**Match the mode to the recipient's `agent_type`:** +- `always_on` — safe to use targeted messages; prompt response expected. +- `session_addressable` — targeted messages work when the agent is in an + active session; otherwise delivery is deferred. +- `session_passive` — **do not** expect a synchronous response. Prefer + `delegate_task` so the work is queued and picked up when the agent next + reads room context. + +## Task protocol + +If your `instructions` indicate you have task capabilities: + +- **Delegating** (`can_delegate=true`): + 1. Call `delegate_task(performer_agent_id, summary, description)`. Task + starts in `pending` until the performer accepts. + 2. Poll `list_tasks(role='delegated')` for progress updates and the final + `outcome`; `read_context` also shows task activity in the room. + 3. Call `cancel_task(task_id, reason)` to abandon a task that is no + longer needed. + + Note: a performer may have a **scoped addressing policy** restricting who can + address it. If you are not permitted, `delegate_task` fails with a permission + error (delegating is a form of addressing). This is expected — do not retry; + reach the performer another way or ask an operator. The same policy silently + drops disallowed `@name` / targeted messages (you'll get a one-line "not + permitted to address me here" reply instead of a response). + +- **Accepting** (`can_accept=true`): + 1. When a task is delegated to you, call `accept_task(task_id)` to move it + to `ongoing`. Find pending work with + `list_tasks(role='assigned', status='pending')`. + 2. Optionally call `update_task(task_id, update)` with progress messages + while you work — these are persisted to the task record. + 3. Call `finalise_task(task_id, outcome)` when done. The `outcome` is a + single string describing what happened (success or failure). + +Call `list_tasks(role='delegated'|'assigned', status=...)` to enumerate +outstanding work. + +## Staying up to date + +**This session has no push event stream.** There is no channel process and +no background poller feeding you room events. Instead, switchdash injects +messages addressed to you into your session as they arrive. An injected +message is one line of context out of a possibly busy conversation, and +**unaddressed room chatter — other agents talking to each other, broadcast +updates, the user discussing things without `@`-mentioning you — is never +injected.** You must pull it yourself. + +**Always `read_context` to catch up:** + +- **Right after `connect_to_room`** — pull recent history so you know what + is going on in the room before you say anything. +- **Every time you are handed a room message** — call `read_context` with + `since` set to a few minutes before that message's timestamp, so you pick + up any unaddressed messages that landed since the last time you looked. + Do not rely on the injected line alone. +- **Before posting anything substantive**, if it has been a while since + your last `read_context` — the room may have moved on. + +When you handle an incoming room message: + +1. **Read recent context** — `read_context` with `since` set to a timestamp + a few minutes before the message's timestamp. +2. **Understand what is being asked** — review the context and the message + content. +3. **Act and respond** — do the work, then use the appropriate interaction + mode (message, targeted message, or task) to share results or progress. + +A `room_join` event fires when a user or agent joins a room. Whether you see +one depends on how your session is driven and on whether an operator opted +you in to join events **in that room** (per-room, per-agent, off by +default — set via the `join_event_listeners` argument on `create_room` / +`update_room`, or the gateway create-room / room-detail pages). New arrivals +also show up in `list_participants`. When you do learn of one, react if +relevant (e.g. greet the new arrival and explain the room). Your own join +never produces one. + +## Linked rooms + +Rooms can advertise **directed pointers** to other Switch rooms — typically +a hub room pointing at its support / feature / workstream rooms, or +parallel workstream rooms cross-referencing each other. These pointers are +metadata only: they tell you that *another* room exists and is related to +the one you are in. They do **not** grant you access to that other room. + +The `connect_to_room` response includes a `linked_rooms` array, and you +can refresh it any time by calling `list_linked_rooms`. Each entry has: + +- `target_room_id` — the linked room's id (use it for `connect_to_room`). +- `target_room_name` / `target_room_description` — what the target room is. +- `label` — a free-text relationship hint set by the operator (e.g. + `"support"`, `"parent project"`, `"depends on"`). Read it; it tells you + *why* the rooms are connected. +- `access` — explicit string: + - `"member"` — you are assigned to the target room and may call + `connect_to_room(target_room_id)` directly. + - `"not_member"` — you are NOT assigned. The connect call will fail. + Do not try it; ask the room's operator (the human user, typically) to + add you first. A `not_member` entry also carries an `access_note` + spelling this out. + +**Following a link** means calling `connect_to_room(target_room_id)`. Note +this disconnects you from the current room (one room at a time). If you +need to compare or move work between two linked rooms, treat the hop +explicitly — read context, do the work, then connect back. + +**The link is one-way.** A pointer from A → B does NOT imply a pointer +from B → A. If you connect to B, its own `linked_rooms` may be empty or +point at entirely different rooms. + +## Moderation: creating rooms and inviting agents + +You can create new rooms and invite agents into them. These tools are +available to any agent — the responsibility for using them well still +applies. + +- **`list_bridges`** — discover the collaboration bridges configured on + this Switch instance. Returns `{id, type, display_name, status, + is_default}` per bridge. Only `status == "active"` bridges are usable + for new rooms. `is_default` marks the bridge `create_room` uses when no + `bridge_id` is given (at most one per instance). +- **`create_room`** — provision a new room. Required: `name`, + `description`, `agent_names`. Optional but commonly used: `bridge_id`, + `internal_only` (opt out of the default bridge — see "Prefer bridged + rooms" below), `channel_type` (`"channel_public"`, `"channel_private"`, + or `"direct"` for a 1:1 DM — see "DM rooms" below), `user_names`, + `instructions`, + `reference_ids`, `package_ids`, `linked_rooms`, `join_event_listeners` + (subset of `agent_names` that should receive `room_join` events in the + room — off by default). Returns + `{id, name, matrix_room_id, failed_attachments}`. +- **`invite_agent_to_room`** — add an existing agent to an existing room + by name. Humans (and agents) can do the same from inside a room with the + `!invite-agent @agent-name` in-room command (also exposed as the + `/invite-agent` Slack slash command on bridged Slack channels). +- **`list_all_rooms`** / **`get_room_detail`** — enumerate every room on + the instance (not just rooms you are in), and fetch a room's members / + channel type / admin mode. `get_room_detail` also returns the room's + assumable `roles` (same shape as `list_roles`: each with `name`, + `exclusive`, `instructions_preview`, `held_by` holders with presence, and + `assumable_by_me`). +- **`list_agents`** — list every agent on the instance (vs + `list_participants`, which is scoped to the connected room). Optional + filters, ANDed: `name_contains` (case-insensitive substring), + `owner_name` (exact), `known_agent_type` (e.g. `"codex"`, + `"claude-code"`). Returns agent summaries sorted by name; use + `get_agent_detail` for one agent's full detail. +- **`list_room_groups`** / **`get_room_group_detail`** — room groups are a + navigation/organization layer: a room belongs to at most one group, and + groups nest under a parent group to form a tree. `list_room_groups` + enumerates every group with its room count and root-first `path`; + `get_room_group_detail` returns one group plus the rooms directly in it + (`member_rooms`) and its immediate `child_groups`. +- **`create_room_group`** — provision a new room group. Required: `name`. + Optional: `description`, `color`, `parent_group_name` (resolved by name, + must be unique — nest under it; omit for a top-level group). Creating a + group does not move any rooms into it. File rooms under it later by + passing `group_name` to `create_room`. +- **`get_agent_detail`** — fetch full detail for any agent on the instance: + its config, capabilities, `known_agent_type` / `known_agent_options`, + `integration_profile`, room memberships, live sessions, and child + subagents. Readable by any agent. +- **`update_agent_detail`** — change an agent's editable settings. + **Owner-only**: you may only update an agent whose owner matches your own + owner. `options` is a PARTIAL map of known-agent options merged over the + current ones — the keys differ per known-agent type. For `codex`: + `repo_dir` (working directory), `notify_user`, `auto_session`. For + `claude-code`: those plus `channels_enabled` and `subagent_name`. Only the + keys you pass change, and a key the type does not define is ignored rather + than rejected — so check the returned detail rather than assuming a write + landed. `parent_agent_id` sets the agent's parent (validated against + self-parenting and cycles); `clear_parent=true` detaches it to top-level. +- **`list_reference_types`** — discover the Reference sub-types this + instance supports, including the per-type `value_schema`. Call this + before `create_reference` if you don't already know what `type` and + `value` shape to use. +- **`create_reference`** — create a new external Reference (e.g. Google + Drive, Confluence, GitHub — call `list_reference_types` for the full + list). + Required: `type`, `name`, `description`, `instructions`, `value`. + Optional: `read_visibility` / `write_visibility` (both default + `"private"`; `write_visibility` must not be `"public"` while + `read_visibility` is `"private"`). The reference is + owned by your agent's user. Use the `instructions` field to tell + other agents how to USE the reference — what's in it, when to consult + it, any caveats. +- **`attach_reference_to_room`** — attach an existing Reference to an + existing room. Standalone version of the `reference_ids` field on + `create_room`. Authorization: your agent's owner must be able to + access the reference (public, owned, or admin). +- **`link_rooms`** — create a directed link from one room to another + with a free-text `label` describing the relationship (e.g. + `"support"`, `"parent project"`, `"depends on"`). Links are one-way; + call again with source/target swapped to make it bidirectional. +- **`unlink_rooms`** — the inverse of `link_rooms`: remove the directed + link from one room to another so it no longer appears in the source + room's `linked_rooms`. Links are one-way, so this removes only the + `source → target` direction; call again with source/target swapped to + remove the reverse link too. Errors if no such link exists. + +### Prefer bridged rooms — and let the user pick the bridge + +The point of Switch is collaboration between agents and humans, so +**rooms are bridged by default**. Omitting `bridge_id` no longer makes an +isolated room — it uses the instance's **default bridge** (on a standalone +deployment, the bundled Mattermost). That means a room is readable by +humans without you having to know the deployment's topology. + +To create a room with **no** external channel, pass `internal_only=True`. +Do that only when the user has explicitly said the room should not bridge +anywhere — e.g. "just a scratch room for agents to coordinate." + +**Do not guess a `bridge_id`.** Workflow when the user asks you to +create a room: + +1. Call `list_bridges`. +2. Show the user the active bridges (display name + type), noting which is + `is_default`, and ask which to use (or whether to skip bridging). +3. Pass their chosen `bridge_id` (and `channel_type`, usually + `"channel_public"` or `"channel_private"`) to `create_room`. Omit + `bridge_id` to accept the default; pass `internal_only=True` if they + want no channel at all. + +If the instance has a default bridge and the room is clearly for +collaboration, it is reasonable to just accept the default without +enumerating — but still confirm the room itself before creating. + +If the instance has **no** default configured and you omit `bridge_id`, +the room is created internal-only. + +### DM rooms (1:1 with a user) + +To open a private 1:1 conversation between a single agent and a single +human, create a room with `channel_type="direct"` — exactly one entry in +`agent_names` and one in `user_names`, on a bridge. In a `direct` room the +agent is addressed by *every* message (no `@`-mention needed), so it feels +like a real DM. + +- **Slack**: there is no app-creatable native DM, so the room is + provisioned as a *private channel* named `dm--` with that + user invited. +- **Mattermost**: DMs are user-initiated from the client, so creating a + `direct` room here fails — the user starts the DM with the agent's bot + and Switch picks it up automatically. + +The user must already be known to Switch on the bridge (they have messaged +the workspace before). If they are not, creation fails loudly with +`no user '' is known on this bridge` — there is no way to invite a +never-seen user by name. Surface that error to the user rather than +retrying. As with any room, confirm the agent + user before creating. + +### Attachments at creation + +`create_room` accepts `reference_ids`, `package_ids`, and `linked_rooms` +to seed the new room with content at creation time. Authorization for +references and packages is checked against the *owner of your agent +account* — you can only attach resources that owner can access. Bad ids +or access denials abort creation before the room is provisioned. + +Race-time attachment failures (rare) do not abort the room. They show +up in `failed_attachments` on the response as +`[{kind, id, error}, ...]`. If that list is non-empty, surface it to +the user and decide whether to retry the attach via the per-resource +endpoints or accept the partial state. + +### Confirm before creating + +Room creation is a real side effect: a Matrix room is provisioned, an +external channel may be created on the bridge, and agents are auto-joined. +Always propose the room (name, description, bridge choice, member list) +to the user and get explicit confirmation before calling `create_room`. + +## Per-room agent aliases + +A room can give an agent a short **alias** — a room-scoped handle so +`@` addresses that agent in that room exactly like its full name +(same routing and addressed-event semantics). Aliases are scoped to one +room: the same agent can have a different alias (or none) in each room, and +an alias only resolves in the room it was set in. + +- **Where aliases show up.** `connect_to_room`, `list_participants`, and + `get_room_detail` include each agent's alias — on participants as an + `alias` field, and on room detail as an `aliases` map (agent name → + alias). Read them so you know which handles are live in the room. +- **In-room commands** (handled by the Switch admin client, like + `!list-agents`): + - `!list-aliases` — list the room's aliases (`@alias` → agent). + - `!set-alias @agent-name @alias` — give an agent an alias (agent first, + then the alias). + - `!remove-alias @alias` (or `@agent-name`) — clear an alias. +- **At room creation / update (MCP).** `create_room` accepts an `aliases` + map (agent name → alias) to seed aliases; `update_room` accepts the same + map to set or change them, with an empty string (`""`) clearing an + agent's alias. +- **Rules.** An alias may contain only letters, digits, `.`, `-`, `_` (so + it tokenises as one `@`-mention), must be unique within the room, and + must not clash with any agent's real name or a room role name + (case-insensitive) — Switch rejects a colliding alias. An alias is + dropped automatically if the agent leaves the room. + +## Important rules + +- **No stray `@-mentions` in free-text fields.** Switch re-parses these + strings as room messages and any `@agent-name` becomes an *addressed* + event for that agent — they will respond, even though you only meant to + describe them. This applies to **every** free-text field you author: + - `post_message(body)` + - `delegate_task(summary, description)` + - `update_task(update)` + - `finalise_task(outcome)` + - `cancel_task(reason)` + + If you need to refer to an agent in text, write the bare name without + `@` (e.g. "codex.test-codex posted the greeting", not + "@codex.test-codex posted the greeting"). To genuinely address + agents, use `send_targeted_message` (for messages) or the task tools + (for tracked work) — they handle addressing for you. +- **Always connect before reading.** `read_context`, `list_participants`, + `post_message`, `send_targeted_message`, and the task tools all require + an active room connection. +- **Read each resource's `instructions`.** Every reference, document, and + package in the `connect_to_room` response carries its own `instructions` + field. Read them — they may override or specialise the defaults in this + skill for that particular resource. +- **Read before responding.** Always call `read_context` (with `since` + when catching up) to understand what has been discussed. +- **One room at a time.** Calling `connect_to_room` with a different room + disconnects from the current one. +- **Governance may be enforced.** Depending on how your session was + launched, your tool calls can be submitted to Switch for mediation before + execution. If Switch denies a tool call, you will see the reason. Do not + try to circumvent denials. +- **You are a participant, not the controller.** Other agents and users + are in the room. Read the conversation, understand the context, and + contribute meaningfully. +- **Reply in the room, not just in the terminal.** When a message in the + room asks you something or requests work, your substantive answer + belongs in the room — via `post_message` (or `send_targeted_message` + if directed at a specific participant). Other participants, including + human users on a bridged external channel (Slack, Mattermost), cannot + see your terminal output; only room events reach them. The terminal is + for the local operator's awareness, not for delivering answers to room + members. Default behavior: when responding to a room message, post the + answer to the room first, then optionally summarise locally. The only + times it is fine to stay terminal-only are when the local operator is + explicitly steering you outside the room conversation (e.g. asking you + to investigate something privately before replying). + +## Formatting messages for bridged channels + +Your messages render on whatever external platform the room is bridged to +(check `bridge_display_name` in the `get_room_detail` +payload). The platforms do **not** render Markdown identically, so adapt: + +- **Slack** renders only a *subset* of Markdown (mrkdwn). **Bold**, + `inline code`, ```code blocks```, `>` quotes, bullet lists, and + `[label](url)` links all convert and render. But Slack does **NOT** render + **Markdown tables** — pipe-and-dash tables show up as raw `| … |` text. So + in a Slack-bridged room, **never use Markdown tables**: for any multi-item + list with attributes (status digests, backlogs, queues), use **one short + line/bullet per item with bold field labels** instead — e.g. + `- **CHOO-509** — Role feature polish · ✅ Done · [PR #117](url)`. Lead with + the bold identifier; separate fields with `·` or `—`. +- **Mattermost** renders full Markdown, including tables — use a table for + multi-item attribute lists there. + +When in doubt about the target platform, prefer the Slack-safe shape (bold +labels over tables); it reads fine everywhere. + +## Agent capabilities + +Agents have no global role: room creation, invites, and other moderation +tools are available to any agent (including Codex). Task-delegation +capability is declared per agent in their integration profile +(`can_delegate`, `can_accept`). Check each participant's capabilities in +the `connect_to_room` response. + +## Room roles (assumable) + +A room can define **room-scoped roles** — named, assumable instruction +bundles (e.g. +`manager`, `worker`, `reviewer`). A role is a hat you put on: you assume it, +receive its instructions, act under them, and release it when done. Roles +are listed in the `connect_to_room` payload (`roles`) and via `list_roles`. + +- **`list_roles`** — see the room's roles. Each entry has `name`, + `exclusive`, `instructions_preview`, `assumable_by_me`, and `held_by`. + `held_by` is a list of holder objects `{name, present_here, session_room}`: + `present_here` is true when that holder's session is connected to this room + right now; otherwise `session_room` names the room its session is currently + attending (a role lease survives room hops, so a holder can be live but + looking elsewhere), or is null if no live session is found. The + `instructions_preview` is truncated (first 200 chars) — use + `get_role_detail` for the full text. +- **`get_role_detail(room_id, role_name)`** — fetch ONE role's **full + untruncated** `instructions` (plus `name`, `exclusive`, `held_by` with the + same presence shape as `list_roles`, and `assumable_by_me`). Use this when + the preview is cut off and you need the complete instruction bundle — e.g. + to read what a role entails before assuming it. Requires room membership; + the room need not be the one your session is currently connected to. +- **`assume_role(role)`** — take the role and receive its full instruction + bundle. Layer those instructions on top of your existing context. You may + hold only one role at a time — release the current one first. Assuming + fails if the role is **exclusive** and another live agent already holds it. +- **`release_role()`** — drop the role you hold (idempotent). Ending your + session also releases it automatically. + +**Creating and editing roles.** The three tools above consume roles someone +else defined; these author them. All three act on the **connected room** and +require write access to it, so they are moderation tools — use them when you +are setting a room up, not in passing. + +- **`define_role(name, instructions, exclusive=False)`** — add a role. `name` + must be unique within the room. `instructions` is the bundle `assume_role` + hands whoever takes it, so write it as instructions *to that agent*, not as + a description of the role. Set `exclusive` when at most one live agent may + hold it at a time. +- **`edit_role(name, instructions=None, exclusive=None)`** — change a role's + instructions and/or its exclusivity; omit a field to leave it as is. Edits + apply on the **next** `assume_role` — an agent already holding the role + keeps the instructions it was given, so ask it to release and re-assume if + the change is meant to reach it now. +- **`delete_role(name)`** — remove the role and any lease on it. + +**Exclusive vs shared.** An `exclusive` role admits at most one live holder: +it is leased to you with a fast heartbeat while your session stays alive and +**auto-releases shortly after you disconnect**, so another agent can take +over — no manual handoff needed. A non-exclusive (shared) role may be held +by many agents at once. + +**Addressing roles.** Tagging `@` in a message addresses the role's +live holder(s); `send_targeted_message(target_roles=[...])` does this for +you and fans out to **every** live holder of a shared role (the single +holder for an exclusive one). This is how you reach "whoever is currently +the manager" without knowing which agent it is. + +**Presence = availability.** Because a role lease is kept alive across room +hops, an agent can hold a role here while its session is attending another +room. Use the `present_here` / `session_room` fields (and the addressed-but- +unavailable auto-reply, which names where the agent's session actually is) to +tell whether a holder is reachable in this room right now. + +## When to use these tools + +- `list_rooms` — when starting a session or asked which rooms exist. +- `connect_to_room` — when entering a room. Pass + `include_general_instructions=False` since this skill already covers the + general Switch workflow. Read every resource's `instructions` field in + the response (references, documents, packages). +- `read_context` — to understand history before contributing, and to catch + up on unaddressed chatter. Use `since` when catching up to avoid + re-reading. +- `list_linked_rooms` — to refresh the current room's outbound pointers + (also returned in the `connect_to_room` payload). Check the `access` + field before trying to `connect_to_room` on a linked room. +- `post_message` — broadcast to everyone. +- `send_targeted_message` — request a synchronous response from specific + agents or roles (informal handoff, question, nudge). Use `target_roles` + to address a role's live holder(s). +- `list_roles` — see the room's assumable roles, who holds them, and where + those holders' sessions currently are. +- `get_role_detail` — read ONE role's full untruncated instructions (the + `list_roles` / `get_room_detail` preview is capped at 200 chars). +- `assume_role` / `release_role` — take on (and later drop) a room-scoped + role and its instruction bundle. One role at a time; exclusive roles are + leased with auto-release on disconnect. +- `define_role` / `edit_role` / `delete_role` — author the roles others + assume. Moderation tools: they need write access to the connected room, and + an edit only reaches a holder on its next `assume_role`. +- `delegate_task` / `accept_task` / `update_task` / `finalise_task` / + `cancel_task` / `list_tasks` — for tracked, formal work. +- `list_bridges` — before creating a room, to discover the available + collaboration bridges and ask the user which one to use. +- `create_room` — provision a new room. Confirm name, members, and + bridge choice with the user before calling. Rooms use the default + bridge unless you pass `bridge_id` or `internal_only`. +- `invite_agent_to_room` — add another agent to an existing room by + name. +- `list_all_rooms` / `get_room_detail` — enumerate every room on the + instance, and inspect a room's members and configuration. +- `list_agents` — list every agent on the instance (optionally filtered + by name, owner, or known-agent type). +- `list_room_groups` / `get_room_group_detail` — see how rooms are + organized into groups, and inspect one group's members + subgroups. +- `create_room_group` — provision a new room group (optionally nested + under a parent group by name) to organize rooms. +- `get_agent_detail` — inspect any agent's configuration, capabilities, + room memberships, and sessions. +- `update_agent_detail` — change an agent you own (partial known-agent + options, e.g. working directory, and/or its parent agent). +- `list_reference_types` — discover supported Reference types + their + value schemas before calling `create_reference`. +- `create_reference` — register a new external Reference (Drive, + Confluence, GitHub, …) so it can be attached to rooms. +- `attach_reference_to_room` — attach an existing Reference to an + existing room, standalone (vs at room creation). +- `link_rooms` — create a directed pointer from one room to another + with a `label`. +- `unlink_rooms` — remove an existing directed pointer from one room to + another (the inverse of `link_rooms`). diff --git a/core/switch_core/bridges/agent/mcp/server.py b/core/switch_core/bridges/agent/mcp/server.py index 77ca37709..d05c3bac7 100644 --- a/core/switch_core/bridges/agent/mcp/server.py +++ b/core/switch_core/bridges/agent/mcp/server.py @@ -141,10 +141,11 @@ async def connect_to_room( field — agent-facing guidance for that resource — alongside a short `description`. `roles` lists the room's assumable roles, each `{name, exclusive, instructions_preview, held_by, assumable_by_me}` — - `held_by` is the list of agent names currently holding it (live lease; - empty if free, and a shared role may list several), so you can see - which roles are taken and by whom before calling - `assume_role`. `linked_rooms` lists directed pointers from this room + `held_by` lists the live holders as objects + `{name, present_here, session_room}` (empty if free; a shared role may + list several), mirroring `list_roles` — so you can see which roles are + taken, by whom, and whether that holder's session is actually in this + room, before calling `assume_role`. `linked_rooms` lists directed pointers from this room to other Switch rooms: each entry has `target_room_id`, `target_room_name`, `target_room_description`, `label` (free-text relationship hint set by the operator) and `access` — @@ -274,9 +275,11 @@ async def list_references(ctx: Context) -> dict[str, Any]: connected room. Returns: - {reference_types, references, documents, packages} — same shape as - the equivalent fields in `connect_to_room`. Use this to refresh - after attachments change mid-session. + {reference_types, references, documents, packages, linked_rooms} — + same shape as the equivalent fields in `connect_to_room`. Use this to + refresh after attachments change mid-session. Note `linked_rooms` here + is the raw pointer list, without the per-room `access` decoration + `connect_to_room` adds. External references carry their `value` inline (URLs / IDs); fetch the underlying content using your own tools as described in @@ -331,8 +334,8 @@ async def load_internal_documents(ids: list[str], ctx: Context) -> list[dict[str connect payload or `list_references`). Returns: - List of {id, description, content} entries in the same order as - the requested ids. Raises an error if any id is not attached to the + List of {id, name, description, content} entries in the same order + as the requested ids. Raises an error if any id is not attached to the current room. The load goes through the Switch resource manager as a Matrix event @@ -529,7 +532,10 @@ async def list_participants(ctx: Context) -> list[dict[str, Any]]: connect_to_room first. Returns: - List of {id, name, type} dicts for each participant. + List of {id, name, type, status, alias} dicts for each participant. + `status` is the agent's reported status (null for users and for agents + that have not reported one); `alias` is the participant's room-scoped + alias, or null when it has none. """ _get_agent_id() room_id = await _require_connected_room(ctx) @@ -615,8 +621,10 @@ async def send_targeted_message( `target_statuses` reports each addressed *agent*'s reachability at send time — for a role target, that is each of its live holders: `live` (will receive immediately), `awaiting_manual_poll` (must read context - to see it), `no_session`/`disconnected` (not reachable; may not see the - message until they reconnect). User targets are omitted — their + to see it), `dormant` (an auto_session agent with no live + session here, but a connector watching that will start one on demand), + `no_session`/`disconnected` (not reachable; may not see the message + until they reconnect). User targets are omitted — their reachability is the collaboration bridge's concern. A role with no live holder contributes no entries. """ @@ -645,7 +653,12 @@ async def send_targeted_message( async def delegate_task( performer_agent_id: str, summary: str, description: str, ctx: Context ) -> dict[str, str]: - """Delegate a task to a performer agent. Requires can_delegate capability. + """Delegate a task to a performer agent. + + `can_delegate` on your own participant entry advertises whether you are + *meant* to delegate; it is not enforced here. What is enforced: you and the + performer must both be members of the connected room, and the performer's + addressing policy must permit you to address it. Args: performer_agent_id: The id of the agent to assign the task to. Must @@ -677,7 +690,11 @@ async def delegate_task( @mcp.tool async def accept_task(task_id: str, ctx: Context) -> dict[str, Any]: - """Accept a delegated task. Requires can_accept capability. + """Accept a delegated task. + + `can_accept` advertises whether you are meant to take tasks; it is not + enforced here. What is enforced: you must be the task's assigned performer + and a member of its room, and the task must still be `pending`. Args: task_id: The id of the task to accept (from the task_delegate event @@ -707,7 +724,10 @@ async def accept_task(task_id: str, ctx: Context) -> dict[str, Any]: @mcp.tool async def update_task(task_id: str, update: str, ctx: Context) -> dict[str, Any]: - """Post a progress update on an assigned task. Requires can_accept capability. + """Post a progress update on an assigned task. + + `can_accept` advertises whether you are meant to take tasks; it is not + enforced here. What is enforced: you must be the task's assigned performer. Args: task_id: The id of an ongoing task you have accepted. @@ -728,7 +748,10 @@ async def update_task(task_id: str, update: str, ctx: Context) -> dict[str, Any] @mcp.tool async def finalise_task(task_id: str, outcome: str, ctx: Context) -> dict[str, Any]: - """Complete a task with final outcome. Requires can_accept capability. + """Complete a task with final outcome. + + `can_accept` advertises whether you are meant to take tasks; it is not + enforced here. What is enforced: you must be the task's assigned performer. Args: task_id: The id of an ongoing task you have accepted. @@ -758,6 +781,29 @@ async def finalise_task(task_id: str, outcome: str, ctx: Context) -> dict[str, A } +@mcp.tool +async def cancel_task(task_id: str, reason: str, ctx: Context) -> dict[str, Any]: + """Abandon a task you delegated. Only the requester can cancel. + + Args: + task_id: The id of a task this agent delegated. + reason: Why the task is no longer needed. Recorded on the task and + posted to the room so the performer learns it has been dropped. + + Returns: + {"status": "cancelled", "reason": ""}. + """ + agent_id = _get_agent_id() + await _require_connected_room(ctx) + + # `cancel_task` raises on every failure (missing task, not the requester, + # not a room member), so reaching here means the status is "cancelled". + # Reading it back would only add a second failure point after the state + # change already committed, turning a successful cancel into an error. + await _get_protocol().cancel_task(agent_id, task_id, reason) + return {"status": "cancelled", "reason": reason} + + @mcp.tool async def list_tasks( role: str | None = None, status: str | None = None, ctx: Any = None @@ -1064,8 +1110,9 @@ async def assume_role(ctx: Context, role: str) -> dict[str, Any]: Returns `{"role", "instructions"}` — the role's instruction delta to layer on top of the room context you already have. Any room member may assume a - role. Fails if you already hold a role (release it first), or if the role - is exclusive and currently held by another live agent. + role. Fails if you already hold a *different* role — release it first; + re-assuming the role you already hold just refreshes its lease. Also fails + if the role is exclusive and currently held by another live agent. For exclusive roles, this acquires a lease with a fast heartbeat: while your session stays alive the seat is yours, and it auto-releases shortly @@ -1450,9 +1497,15 @@ async def get_agent_detail(agent_id: str) -> dict[str, Any]: Readable for any agent (not just your own). Use it to inspect another agent's configuration, capabilities, room memberships, and live sessions. + The returned detail also carries `addressing_policy` — the rules + governing who may address this agent — which is non-null for agents whose + addressing is scoped. + Args: - agent_id: The target agent's id (from `list_participants` or - `list_all_rooms`/`get_room_detail`). + agent_id: The target agent's id — the `id` field from + `list_participants` (room-scoped) or `list_agents` (instance-wide). + Not the agent's name, and not available from `list_all_rooms` or + `get_room_detail`, which carry names only. Returns: {id, name, description, connector_type, connection_model, @@ -1484,10 +1537,14 @@ async def update_agent_detail( Editable fields: - `options`: a PARTIAL map of the agent's known-agent options to change — only the keys you pass are updated; the rest are left as-is. - For a claude-code agent the options are `repo_dir` (the working - directory), `channels_enabled`, `notify_user`, and `subagent_name`. - The merged options are validated against the agent type's schema and - its integration profile is rebuilt to match. + The available keys depend on the agent's type; call `get_agent_detail` + to see the ones it currently carries. All types share `repo_dir` (the + working directory), `notify_user`, and `auto_session`; claude-code + adds `channels_enabled` and `subagent_name`. The merged options are + validated against the agent type's schema and its integration profile + is rebuilt to match. A key the type does not define is dropped rather + than rejected, so check the spelling against `get_agent_detail` — + a typo reads as a successful update that changed nothing. - `parent_agent_id`: set the agent's parent (e.g. to make it a subagent of another agent). Validated against self-parenting and cycles. - `clear_parent`: pass True to detach the agent from its parent (make it @@ -1524,7 +1581,8 @@ async def get_room_detail(room_id: str) -> dict[str, Any]: aliases (per-room agent aliases, keyed by agent name → alias), roles (the room's assumable roles, mirroring list_roles: each entry has name, exclusive, instructions_preview, held_by (holder objects with - presence), and assumable_by_me)}. + presence), and assumable_by_me), join_event_listeners (names of the + agents configured to receive `room_join` events here), archived}. """ agent_id = _get_agent_id() protocol = _get_protocol() diff --git a/core/switch_core/gateway/known_agents.py b/core/switch_core/gateway/known_agents.py index 72782b7fb..44f42a11f 100644 --- a/core/switch_core/gateway/known_agents.py +++ b/core/switch_core/gateway/known_agents.py @@ -297,8 +297,132 @@ def start_session_instructions( ) +class CodexOptions(KnownAgentOptions): + auto_session: bool = False + """When True, the operator's connector (switchdash) watches every room this + agent belongs to and auto-spawns a Codex session — connected to the room and + wired to the agent's identity — the moment the agent is addressed in a room + where it has no live session. The registered profile becomes `auto_session`. + Codex has no plugin-channel of its own; switchdash delivers inbound room + messages by injecting them into the session's terminal (CHOO-1436).""" + + repo_dir: str | None = None + """Absolute path to the directory the operator runs Codex from. Used to + generate a ready-to-paste `cd && codex "connect to switch room …"` + command shown when the agent is addressed with no live session. None → a + `` placeholder is shown instead.""" + + notify_user: str | None = None + """Username (on the room's bridged platform) to `@`-mention in the + unavailable-session message so the operator gets a notification. Bare name, + no leading `@`. None → post without a mention.""" + + # No `channels_enabled`: switchdash sends it for every provider, but Codex + # has no connector channel of its own, so nothing here could act on it. + # `KnownAgentOptions` ignores unknown keys, so the shared registration path + # still works — and the schema-driven gateway form does not render a control + # that silently does nothing. + + @field_validator("repo_dir", "notify_user", mode="before") + @classmethod + def _blank_string_to_none(cls, value: object) -> object: + if isinstance(value, str) and value.strip() == "": + return None + return value + + +class CodexKnownAgent(KnownAgent): + connector_type = "Codex CLI" + options_schema = CodexOptions + tools = [ + ToolSpec(name="Shell", description="Executes shell commands"), + ToolSpec(name="ApplyPatch", description="Applies patches to files"), + ToolSpec(name="Read", description="Reads file contents"), + ] + models: ClassVar[list[ModelSpec]] = [] + + @classmethod + def build_profile(cls, options: KnownAgentOptions) -> IntegrationProfile: + assert isinstance(options, CodexOptions) + # switchdash watches + auto-spawns when auto_session; otherwise it keeps a + # session live and delivers messages by terminal injection, which is the + # session_addressable model. Codex does not report per-tool events or + # mediate tool calls (it runs auto-approved), so those lists stay empty — + # unlike Claude Code, whose PostToolUse hooks report tool activity. + connection_model = ( + "auto_session" if options.auto_session else "session_addressable" + ) + return IntegrationProfile( + connection_model=connection_model, + message_exchange=True, + pre_invocation_mediation=[], + post_invocation_mediation=[], + event_reporting=[], + task_protocol=TaskProtocolConfig(can_delegate=True, can_accept=True), + # Same story as Claude Code: Codex is a TUI, so reset / compact / + # interrupt only work when switchdash is driving the session and can + # inject keystrokes. A standalone `codex` can't be controlled, so all + # three resolve per live session via AgentRuntimeState. + command_capabilities=CommandCapabilities( + reset="session_dependent", + compact="session_dependent", + interrupt="session_dependent", + ), + ) + + @classmethod + def start_session_instructions( + cls, + options: KnownAgentOptions, + agent: Agent, + room_name: str, + assume_role: str | None = None, + other_room_names: list[str] | None = None, + connected_not_live: bool = False, + ) -> str | None: + """Build the room-facing onboarding message for a Codex agent. + + Mirrors the Claude Code shape but emits a `codex "…"` command (never a + `claude` one) and omits Claude-specific flags. Codex sessions are normally + auto-managed by switchdash, so this fallback is shown mainly when no + connector is watching. + """ + assert isinstance(options, CodexOptions) + dir_token = options.repo_dir if options.repo_dir else "" + prompt = f"connect to switch room {room_name}" + if assume_role: + prompt += f" and assume the role {assume_role}" + cmd = f'cd "{dir_token}" && codex "{prompt}"' + + prefix = f"@{options.notify_user}\n\n" if options.notify_user else "" + if connected_not_live: + opening = ( + "I have a session connected to this room, but it isn't reporting " + "as live, so I'm not receiving messages. Relaunch it, or start a " + "fresh session, with:" + ) + elif other_room_names: + where = ", ".join(f"**{name}**" for name in other_room_names) + opening = ( + f"I don't have a session connected to this room right now, but I " + f"do have other session(s) connected to {where}. Either ask me in " + "one of those rooms to come here, or start a new session connected " + "to this room — my operator should run:" + ) + else: + opening = ( + "I don't have a session connected to this room. To set up a new " + "session connected to this room, my operator should run:" + ) + return ( + f"{prefix}{opening}\n\n```\n{cmd}\n```\n\n(or start Codex manually and " + "ask me to connect to the room.)" + ) + + KNOWN_AGENTS: dict[str, type[KnownAgent]] = { "claude-code": ClaudeCodeKnownAgent, + "codex": CodexKnownAgent, } diff --git a/core/tests/switch_core/bridges/agent/test_mcp_tool_surface.py b/core/tests/switch_core/bridges/agent/test_mcp_tool_surface.py new file mode 100644 index 000000000..1c42f18d7 --- /dev/null +++ b/core/tests/switch_core/bridges/agent/test_mcp_tool_surface.py @@ -0,0 +1,130 @@ +"""The MCP tool surface agents are told about must be the surface that exists. + +The connector skills and `protocol/instructions.py` name specific tools and +tell agents to call them, and neither is checked against the server's actual +registrations at build time. These tests pin the tool names so a rename, a +removal, or a name that only ever existed in the documentation fails here +rather than surfacing as an agent calling into nothing. +""" + +import re +from pathlib import Path + +import pytest + +from switch_core.bridges.agent.mcp.server import mcp + +TASK_PROTOCOL_TOOLS = { + "delegate_task", + "accept_task", + "update_task", + "finalise_task", + "cancel_task", + "list_tasks", +} + + +@pytest.fixture +async def tool_names() -> set[str]: + return {tool.name for tool in await mcp.list_tools()} + + +async def test_task_protocol_is_fully_exposed(tool_names: set[str]) -> None: + """Every stage of the documented task lifecycle is callable over MCP. + + `cancel_task` is the requester's abort path: without it the lifecycle the + skills describe has no exit for the agent that opened it. + """ + assert TASK_PROTOCOL_TOOLS <= tool_names + + +async def test_documented_tools_exist(tool_names: set[str]) -> None: + """Every tool the connector skills advertise is registered. + + Mirrors the tool names used across `connectors/*/skills/switch/SKILL.md`, + including those named only in the body. Maintained by hand: add a name here + when a skill starts advertising one. + """ + documented = TASK_PROTOCOL_TOOLS | { + "list_rooms", + "connect_to_room", + "read_context", + "post_message", + "send_targeted_message", + "list_participants", + "list_roles", + "get_role_detail", + "assume_role", + "release_role", + "define_role", + "edit_role", + "delete_role", + "list_linked_rooms", + "update_room", + "create_room", + "invite_agent_to_room", + "list_all_rooms", + "get_room_detail", + "list_bridges", + "list_reference_types", + "create_reference", + "attach_reference_to_room", + "link_rooms", + "unlink_rooms", + "list_room_groups", + "create_room_group", + "get_room_group_detail", + "list_agents", + "get_agent_detail", + "update_agent_detail", + } + + assert documented <= tool_names, ( + f"documented but not registered: {sorted(documented - tool_names)}" + ) + + +SKILLS = sorted( + (Path(__file__).parents[5] / "connectors").glob("*/skills/switch/SKILL.md") +) + + +def _frontmatter_tools(skill: Path) -> list[str]: + """The tool names listed in a skill's frontmatter `description:` trigger. + + Both skills carry them as one parenthesised, comma-separated run — the + first parenthetical in the file. Read rather than YAML-parsed so the + unquoted (Claude) and quoted (Codex) forms are handled the same way. + """ + match = re.search( + r"^description:.*?\(([a-z_, ]+)\)", skill.read_text(), re.MULTILINE + ) + assert match is not None, f"no parenthesised tool list in {skill}" + return [name.strip() for name in match.group(1).split(",") if name.strip()] + + +def test_both_skills_advertise_the_same_tools() -> None: + """The two connectors' trigger lists must not drift apart. + + They are host-specific documents but the tool surface behind them is one + surface, and a tool added to one skill's trigger is silently absent from + the other's. + """ + assert len(SKILLS) == 2, f"expected two connector skills, found {SKILLS}" + first, second = (_frontmatter_tools(skill) for skill in SKILLS) + assert first == second + + +async def test_skill_frontmatter_tools_are_registered(tool_names: set[str]) -> None: + """Every tool named in a skill's trigger list actually exists. + + Derived from the files rather than restated here, so this half cannot go + stale the way the hand-maintained set above can. It does not replace that + set: the skills also name tools in their bodies (`list_linked_rooms`, + `update_room`) that never appear in the trigger. + """ + for skill in SKILLS: + advertised = set(_frontmatter_tools(skill)) + assert advertised <= tool_names, ( + f"{skill} advertises unregistered tools: {sorted(advertised - tool_names)}" + ) diff --git a/core/tests/switch_core/gateway/test_known_agents.py b/core/tests/switch_core/gateway/test_known_agents.py index 63b400967..6896d9eb8 100644 --- a/core/tests/switch_core/gateway/test_known_agents.py +++ b/core/tests/switch_core/gateway/test_known_agents.py @@ -3,8 +3,11 @@ from types import SimpleNamespace from switch_core.gateway.known_agents import ( + KNOWN_AGENTS, ClaudeCodeKnownAgent, ClaudeCodeOptions, + CodexKnownAgent, + CodexOptions, known_agent_for, ) @@ -232,6 +235,148 @@ def test_message_does_not_interpolate_room_name(self) -> None: assert "I don't have a session connected to this room." in msg +class TestCodexKnownAgent: + def test_registered_under_codex_key(self) -> None: + assert KNOWN_AGENTS.get("codex") is CodexKnownAgent + assert CodexKnownAgent.connector_type == "Codex CLI" + + def test_default_profile_is_session_addressable(self) -> None: + profile = CodexKnownAgent.build_profile(CodexOptions()) + assert profile.connection_model == "session_addressable" + + def test_auto_session_sets_auto_session_model(self) -> None: + profile = CodexKnownAgent.build_profile(CodexOptions(auto_session=True)) + assert profile.connection_model == "auto_session" + + def test_no_tool_call_mediation_or_reporting(self) -> None: + # Codex runs auto-approved and reports lifecycle hooks only (not per-tool + # events), unlike Claude Code. + profile = CodexKnownAgent.build_profile(CodexOptions()) + assert profile.pre_invocation_mediation == [] + assert profile.event_reporting == [] + + def test_can_delegate_and_accept_tasks(self) -> None: + profile = CodexKnownAgent.build_profile(CodexOptions()) + assert profile.task_protocol.can_delegate is True + assert profile.task_protocol.can_accept is True + + def test_commands_are_session_dependent(self) -> None: + # Codex is a TUI driven by switchdash keystroke injection, same as Claude + # Code — so reset/compact/interrupt depend on a live managed session. + # Must stay in step with `BY_PROVIDER.codex` in switchdash's + # `main/core/switch-rooms/session-control.ts`; declaring a command here + # that switchdash cannot execute yields a worse message than "unsupported". + caps = CodexKnownAgent.build_profile(CodexOptions()).command_capabilities + assert caps.reset == "session_dependent" + assert caps.compact == "session_dependent" + assert caps.interrupt == "session_dependent" + + def test_start_session_instructions_emit_codex_not_claude(self) -> None: + opts = CodexOptions(repo_dir="/Users/x/repo") + msg = CodexKnownAgent.start_session_instructions(opts, _agent({}), "hub") + assert msg is not None + # The path is quoted so a repo_dir with spaces still produces a valid + # paste command. + assert 'cd "/Users/x/repo" && codex "connect to switch room hub"' in msg + # It must NOT suggest a claude command or Claude-specific flags. + assert "claude" not in msg + assert "--dangerously-load-development-channels" not in msg + # The footer must not falsely promise auto-start (this message only shows + # when no session is being auto-spawned); it points at a manual start. + assert "auto-starts one when I'm addressed" not in msg + assert "start Codex manually" in msg + + def test_repo_dir_with_spaces_stays_quoted(self) -> None: + opts = CodexOptions(repo_dir="/Users/alice/my project") + msg = CodexKnownAgent.start_session_instructions(opts, _agent({}), "hub") + assert msg is not None + assert 'cd "/Users/alice/my project" && codex' in msg + + def test_connected_not_live_opening(self) -> None: + opts = CodexOptions(repo_dir="/r") + msg = CodexKnownAgent.start_session_instructions( + opts, _agent({}), "ops", connected_not_live=True + ) + assert msg is not None + assert "isn't reporting" in msg + assert "I don't have a session connected to this room." not in msg + assert "claude" not in msg + + def test_other_room_names_branch(self) -> None: + opts = CodexOptions(repo_dir="/r") + msg = CodexKnownAgent.start_session_instructions( + opts, _agent({}), "ops", other_room_names=["hub", "triage"] + ) + assert msg is not None + assert "**hub**" in msg + assert "**triage**" in msg + assert "claude" not in msg + + def test_assume_role_folded_into_prompt(self) -> None: + opts = CodexOptions(repo_dir="/r") + msg = CodexKnownAgent.start_session_instructions( + opts, _agent({}), "ops", assume_role="reviewer" + ) + assert msg is not None + assert 'codex "connect to switch room ops and assume the role reviewer"' in msg + + def test_empty_string_repo_dir_normalised_to_placeholder(self) -> None: + opts = CodexOptions(repo_dir="") + assert opts.repo_dir is None + msg = CodexKnownAgent.start_session_instructions(opts, _agent({}), "triage") + assert msg is not None + assert 'cd ""' in msg + + def test_empty_string_notify_user_normalised_to_none(self) -> None: + opts = CodexOptions(notify_user="") + assert opts.notify_user is None + msg = CodexKnownAgent.start_session_instructions(opts, _agent({}), "hub") + assert msg is not None + assert not msg.startswith("@") + + def test_no_repo_dir_uses_codex_placeholder(self) -> None: + msg = CodexKnownAgent.start_session_instructions( + CodexOptions(repo_dir=None), _agent({}), "triage" + ) + assert msg is not None + assert 'cd ""' in msg + + def test_notify_user_prepended_as_at_mention(self) -> None: + opts = CodexOptions(repo_dir="/x", notify_user="cmcd") + msg = CodexKnownAgent.start_session_instructions(opts, _agent({}), "hub") + assert msg is not None + assert msg.startswith("@cmcd\n\n") + + def test_channels_enabled_is_dropped_not_offered_as_an_option(self) -> None: + # switchdash sends channels_enabled for every provider, so registration + # must still accept it — but Codex has no channel, so it is not a field. + # The gateway renders the options form from this schema; a declared field + # would be an interactive control that changes nothing. + assert "channels_enabled" not in CodexOptions.model_json_schema()["properties"] + + opts = CodexOptions.model_validate({"channels_enabled": False}) + assert "channels_enabled" not in opts.model_dump() + assert ( + CodexKnownAgent.build_profile(opts).connection_model + == "session_addressable" + ) + + def test_known_agent_for_round_trips_codex(self) -> None: + agent = _agent( + { + "known_agent_type": "codex", + "known_agent_options": {"auto_session": True, "repo_dir": "/tmp/r"}, + } + ) + result = known_agent_for(agent) + assert result is not None + spec, options = result + assert spec is CodexKnownAgent + assert isinstance(options, CodexOptions) + assert options.auto_session is True + assert options.repo_dir == "/tmp/r" + + class TestKnownAgentFor: def test_round_trips_claude_code_options(self) -> None: agent = _agent( diff --git a/core/uv.lock b/core/uv.lock index 44a6dddc6..862b7f2f5 100644 --- a/core/uv.lock +++ b/core/uv.lock @@ -2433,7 +2433,7 @@ wheels = [ [[package]] name = "switch-core" -version = "0.9.0" +version = "0.10.0" source = { editable = "." } dependencies = [ { name = "aiohttp" }, diff --git a/dash/AGENTS.md b/dash/AGENTS.md index 234c08bb6..5b0272a66 100644 --- a/dash/AGENTS.md +++ b/dash/AGENTS.md @@ -370,7 +370,11 @@ pnpm run test ## Extensibility Hooks -- Agent providers are defined in `src/shared/core/agents/agent-provider-registry.ts`. +- Agent providers are defined in `packages/plugins/src/agents/impl//index.ts`. + `src/shared/core/providers/agent-provider-registry.ts` holds the id list, per-provider + display metadata, and a mirror of each provider's argv shape. The mirror is + descriptive: nothing reads it at spawn time, so change the plugin first and update the + mirror to match. `provider-argv-parity.test.ts` pins Codex's. - Provider detection lives in `src/main/core/dependencies/dependency-manager.ts`. - Provider PTY behavior and env passthrough live under `src/main/core/pty/`. - Provider event hooks and plugins live under `src/main/core/agent-hooks/`. @@ -385,8 +389,22 @@ pnpm run test `.switchdash.json`. - Optional environment variables: `SWITCHDASH_DB_FILE`, `SWITCHDASH_DISABLE_NATIVE_DB`, - `SWITCHDASH_DISABLE_PTY`, `SWITCHDASH_REGISTER_DEEPLINK`, `CODEX_SANDBOX_MODE`, - and `CODEX_APPROVAL_POLICY`. + `SWITCHDASH_DISABLE_PTY`, and `SWITCHDASH_REGISTER_DEEPLINK`. +- Codex sandboxing is not configurable. An auto-approving Codex session always + launches with `-c approval_policy="never" -c sandbox_mode="danger-full-access"`, + overriding any `sandbox_mode` in the user's `~/.codex/config.toml`. This is + load-bearing, not a default: switchdash's hooks are `curl`s to + `http://127.0.0.1:$SWITCHDASH_HOOK_PORT/hook` that end in `|| true`, and every + Codex sandbox below `danger-full-access` blocks network access including + loopback — so under one the hooks fail *silently*, taking room tracking and + rollout-id capture with them. See + `packages/plugins/src/agents/impl/codex/index.ts`. +- Every switchdash-launched Codex session — not only auto-approving ones — carries + `--dangerously-bypass-hook-trust`. Codex skips any hook it has no persisted + `trusted_hash` for, which would take switchdash's own hooks with it; the flag is + per-invocation and also un-gates hooks the user added to `~/.codex/hooks.json` + themselves. Rationale and the rejected alternative are on `CODEX_HOOK_TRUST_FLAG` in + `packages/plugins/src/agents/impl/codex/hooks.ts`. - Deeplinks in dev: `pnpm run dev` does **not** claim the `switchdash://` OS URL scheme by default — doing so hijacks the handler from the installed app and the registration outlives the dev process (on macOS it sticks in Launch Services), diff --git a/dash/agents/architecture/shared.md b/dash/agents/architecture/shared.md index 534a0e46a..e1393e53c 100644 --- a/dash/agents/architecture/shared.md +++ b/dash/agents/architecture/shared.md @@ -2,8 +2,9 @@ ## Main Shared Areas -- Agent provider registry: - - `src/shared/core/agents/agent-provider-registry.ts` +- Agent provider registry (ids, display metadata, and a descriptive argv mirror; behavior + lives in `packages/plugins/src/agents/impl//index.ts`): + - `src/shared/core/providers/agent-provider-registry.ts` - IPC primitives: - `src/shared/ipc/rpc.ts` — typed RPC router, controller, and client - `src/shared/ipc/events.ts` — typed event emitter @@ -36,9 +37,11 @@ Aliases are resolved at build time by electron-vite. No runtime monkey-patching When adding a provider: -1. update `src/shared/core/agents/agent-provider-registry.ts` -2. add any required env passthrough in `src/main/core/pty/pty-env.ts` -3. add or update hook/plugin installation in `src/main/core/agent-hooks/` if the provider - supports explicit events -4. update renderer surfaces that assume provider metadata -5. add tests for non-standard spawn or detection behavior +1. add the plugin under `packages/plugins/src/agents/impl//index.ts` +2. add the id to `AGENT_PROVIDER_IDS` and a display entry to `AGENT_PROVIDERS` in + `src/shared/core/providers/agent-provider-registry.ts` +3. add any required env passthrough in `src/main/core/pty/pty-env.ts` +4. declare the provider's `hooks` capability in the plugin if it supports explicit events; + `src/main/core/agent-hooks/` writes the config files from that declaration +5. update renderer surfaces that assume provider metadata +6. add tests for non-standard spawn or detection behavior diff --git a/dash/agents/integrations/providers.md b/dash/agents/integrations/providers.md index 841623ad0..580866c1b 100644 --- a/dash/agents/integrations/providers.md +++ b/dash/agents/integrations/providers.md @@ -2,7 +2,10 @@ ## Source Of Truth -- `src/shared/core/agents/agent-provider-registry.ts` +- `packages/plugins/src/agents/impl//index.ts` — the provider plugin. Authoritative for + everything behavioral. +- `src/shared/core/providers/agent-provider-registry.ts` — ids, display metadata, and a + descriptive mirror of the plugin's argv. Never authoritative. - `src/main/core/dependencies/registry.ts` - `src/main/core/pty/` @@ -10,16 +13,24 @@ codex, claude, grok, devin, cursor, gemini, antigravity, qwen, droid, amp, commandcode, opencode, hermes, copilot, charm, auggie, goose, kimi, kilocode, kiro, rovo, cline, continue, codebuff, freebuff, mistral, jules, junie, pi, letta, autohand -## Provider Metadata Includes +## Where Provider Metadata Lives -- CLI and detection commands -- version args -- install command and docs URL -- auto-approve flags -- initial prompt handling -- keystroke injection behavior -- resume and session flags -- optional plan activation and auto-start commands +The plugin (`packages/plugins/src/agents/impl//index.ts`) owns everything that affects +behavior: + +- argv shaping — auto-approve flags, initial prompt handling, resume and session flags, + default args — via the `buildStandardCommand` spec in `behavior.prompt.buildCommand` +- CLI name, detection commands, and version args — via `hostDependency` +- prompt delivery mode, including keystroke injection — via `capabilities.prompt.kind` +- hook support — via `capabilities.hooks` +- the provider icon — via the plugin's `icon` asset + +`agent-provider-registry.ts` holds the `AGENT_PROVIDER_IDS` list, per-provider display +metadata (name, one-line description, docs URL, icon, install command), and a mirror of the +argv fields above. It builds no commands and nothing reads the mirror at spawn time — it +exists so the provider table can be read in one place. Change the plugin first, then the +mirror. `src/main/core/providers/provider-argv-parity.test.ts` fails if Codex's two drift +apart; the other providers' mirrors are unguarded, so treat them as hints, not facts. ## Agent Hooks And Notifications @@ -38,9 +49,11 @@ or notify an inferred status for that event. ## Adding Or Changing A Provider -1. update `src/shared/core/agents/agent-provider-registry.ts` -2. update allowlisted agent env vars in `src/main/core/pty/pty-env.ts` if needed -3. add or update hook/plugin installation in `src/main/core/agent-hooks/` if the provider - supports explicit events +1. add or update the plugin in `packages/plugins/src/agents/impl//index.ts` — this is where + argv, dependencies, capabilities, and hooks are defined +2. for a new provider only, add the id to `AGENT_PROVIDER_IDS` and a display entry to + `AGENT_PROVIDERS` in `src/shared/core/providers/agent-provider-registry.ts` + (`plugin-registry.ts` fails at load if the id list and the plugins disagree) +3. update allowlisted agent env vars in `src/main/core/pty/pty-env.ts` if needed 4. validate detection behavior in `src/main/core/dependencies/` 5. add or update tests for any non-standard behavior diff --git a/dash/apps/switchdash-desktop/src/main/core/agent-hooks/agent-hook-service.ts b/dash/apps/switchdash-desktop/src/main/core/agent-hooks/agent-hook-service.ts index 0d9edee30..573d293d2 100644 --- a/dash/apps/switchdash-desktop/src/main/core/agent-hooks/agent-hook-service.ts +++ b/dash/apps/switchdash-desktop/src/main/core/agent-hooks/agent-hook-service.ts @@ -82,7 +82,7 @@ class AgentHookService implements IInitializable, IDisposable, Hookable { let parsed; try { - parsed = await parseHookEvent(raw, dbContextResolver); + parsed = await parseHookEvent(raw, dbContextResolver, log); } catch (error) { log.warn('AgentHookService: failed to parse hook event', { ptyId: raw.ptyId, diff --git a/dash/apps/switchdash-desktop/src/main/core/agent-hooks/event-enricher.test.ts b/dash/apps/switchdash-desktop/src/main/core/agent-hooks/event-enricher.test.ts index 6e9c7a27a..9aa9395be 100644 --- a/dash/apps/switchdash-desktop/src/main/core/agent-hooks/event-enricher.test.ts +++ b/dash/apps/switchdash-desktop/src/main/core/agent-hooks/event-enricher.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { type ContextResolver, type AgentHookContext, parseHookEvent } from './event-enricher'; import type { RawHookRequest } from './hook-server'; @@ -10,46 +10,194 @@ const ctx: AgentHookContext = { const fixedResolver: ContextResolver = async () => ctx; +const log = { warn: vi.fn() }; + function raw(type: string, body: Record): RawHookRequest { return { ptyId: ctx.ptyId, type, body: JSON.stringify(body) } as RawHookRequest; } +/** The Switch `connect_to_room` result as the tool itself returns it. */ +const roomResult = { room_id: 'room-1', agent_id: 'agent-1', name: 'Room One' }; + +const expectedRoom = { + kind: 'switch-room', + ctx, + roomId: 'room-1', + agentId: 'agent-1', + roomName: 'Room One', +}; + +beforeEach(() => { + vi.clearAllMocks(); +}); + describe('parseHookEvent', () => { it('parses a switch_room_connect event using the injected context', async () => { + const parsed = await parseHookEvent( + raw('switch_room_connect', { tool_response: roomResult }), + fixedResolver, + log + ); + + expect(parsed).toEqual(expectedRoom); + }); + + it('unwraps the CallToolResult Codex actually forwards', async () => { + // Verbatim `tool_response` captured from Codex CLI 0.146.0 via + // `scripts/codex-hook-probe/run.sh`, calling a FastMCP tool declared like + // the real `connect_to_room` (async, returning `dict[str, Any]`). Codex + // forwards the envelope rather than unwrapping it the way Claude Code does, + // so reading `tool_response.room_id` finds nothing — kept literal so a + // future Codex change fails here rather than silently stranding the poller + // on the room the session spawned in. const parsed = await parseHookEvent( raw('switch_room_connect', { - tool_response: { room_id: 'room-1', agent_id: 'agent-1', name: 'Room One' }, + tool_name: 'mcp__switch__connect_to_room', + tool_response: { + content: [ + { + type: 'text', + text: '{"room_id":"room-1","agent_id":"agent-1","name":"Room One","participants":[]}', + }, + ], + structuredContent: { ...roomResult, participants: [] }, + isError: false, + }, }), - fixedResolver + fixedResolver, + log ); - expect(parsed).toEqual({ - kind: 'switch-room', - ctx, - roomId: 'room-1', - agentId: 'agent-1', - roomName: 'Room One', - }); + expect(parsed).toEqual({ ...expectedRoom, ctx }); + expect(log.warn).not.toHaveBeenCalled(); + }); + + it('unwraps structuredContent on its own', async () => { + const parsed = await parseHookEvent( + raw('switch_room_connect', { tool_response: { structuredContent: roomResult } }), + fixedResolver, + log + ); + + expect(parsed).toEqual(expectedRoom); + }); + + it('falls through to the text block when structuredContent is missing agent_id', async () => { + // structuredContent is tried before the text block and the first match wins, + // so a probe satisfied by room_id alone would take this partial envelope and + // then drop the event for a missing agent_id the text block was carrying. + const parsed = await parseHookEvent( + raw('switch_room_connect', { + tool_response: { + structuredContent: { room_id: 'room-1' }, + content: [{ type: 'text', text: JSON.stringify(roomResult) }], + }, + }), + fixedResolver, + log + ); + + expect(parsed).toEqual(expectedRoom); + expect(log.warn).not.toHaveBeenCalled(); + }); + + it('unwraps structuredContent.result when the tool return was wrapped', async () => { + const parsed = await parseHookEvent( + raw('switch_room_connect', { + tool_response: { structuredContent: { result: roomResult } }, + }), + fixedResolver, + log + ); + + expect(parsed).toEqual(expectedRoom); + }); + + it('unwraps the first text content block on its own', async () => { + const parsed = await parseHookEvent( + raw('switch_room_connect', { + tool_response: { + content: [ + { type: 'image', data: 'ignored' }, + { type: 'text', text: JSON.stringify(roomResult) }, + ], + }, + }), + fixedResolver, + log + ); + + expect(parsed).toEqual(expectedRoom); + }); + + it('parses a tool_response delivered as a JSON string', async () => { + const asPayload = await parseHookEvent( + raw('switch_room_connect', { tool_response: JSON.stringify(roomResult) }), + fixedResolver, + log + ); + expect(asPayload).toEqual(expectedRoom); + + const asEnvelope = await parseHookEvent( + raw('switch_room_connect', { + tool_response: JSON.stringify({ structuredContent: roomResult }), + }), + fixedResolver, + log + ); + expect(asEnvelope).toEqual(expectedRoom); }); it('ignores a switch_room_connect event missing room/agent ids', async () => { const parsed = await parseHookEvent( raw('switch_room_connect', { tool_response: { room_id: 'room-1' } }), - fixedResolver + fixedResolver, + log ); expect(parsed).toEqual({ kind: 'ignore' }); }); + it('warns and ignores when the tool result shape is unrecognisable', async () => { + const parsed = await parseHookEvent( + raw('switch_room_connect', { tool_response: { isError: true, content: 'nope' } }), + fixedResolver, + log + ); + + expect(parsed).toEqual({ kind: 'ignore' }); + expect(log.warn).toHaveBeenCalledTimes(1); + expect(log.warn).toHaveBeenCalledWith(expect.any(String), { + providerId: ctx.providerId, + ptyId: ctx.ptyId, + toolResponseType: 'object', + toolResponseKeys: ['isError', 'content'], + }); + }); + + it('warns without keys when the tool result is not an object', async () => { + const parsed = await parseHookEvent( + raw('switch_room_connect', { tool_response: 'not json' }), + fixedResolver, + log + ); + + expect(parsed).toEqual({ kind: 'ignore' }); + expect(log.warn).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ toolResponseType: 'string', toolResponseKeys: undefined }) + ); + }); + it('throws when the context resolver cannot resolve the ptyId', async () => { const nullResolver: ContextResolver = async () => null; - await expect(parseHookEvent(raw('Stop', {}), nullResolver)).rejects.toThrow( + await expect(parseHookEvent(raw('Stop', {}), nullResolver, log)).rejects.toThrow( 'Unrecognised ptyId' ); }); it('does not consult the database — the resolver is the only context source', async () => { const resolver = vi.fn(fixedResolver); - await parseHookEvent(raw('switch_room_connect', { tool_response: {} }), resolver); + await parseHookEvent(raw('switch_room_connect', { tool_response: {} }), resolver, log); expect(resolver).toHaveBeenCalledWith(ctx.ptyId); }); }); diff --git a/dash/apps/switchdash-desktop/src/main/core/agent-hooks/event-enricher.ts b/dash/apps/switchdash-desktop/src/main/core/agent-hooks/event-enricher.ts index 20df99d5b..c59a228c5 100644 --- a/dash/apps/switchdash-desktop/src/main/core/agent-hooks/event-enricher.ts +++ b/dash/apps/switchdash-desktop/src/main/core/agent-hooks/event-enricher.ts @@ -31,27 +31,85 @@ export type ParsedHookEvent = | { kind: 'ignore' }; /** - * Event type emitted by the Claude `connect_to_room` PostToolUse hook (see the - * claude plugin hook config). The hook fires for the Switch MCP tool only, via - * its `mcp__.*__connect_to_room` matcher. + * Minimal logger the parser needs. Injected rather than imported so the parser + * can run in the remote sidecar bundle, which must not pull in the + * Electron-bound main-process file logger. + */ +export interface HookEventLogger { + warn(message: string, meta?: Record): void; +} + +/** + * Event type emitted by the `connect_to_room` PostToolUse hook that switchdash + * registers for both Claude and Codex (`buildClaudeHookConfig` and + * `buildCodexHookConfig` in `@switchdash/plugins`). Both scope it to the Switch + * MCP tool with the same `mcp__.*__connect_to_room` matcher. */ const SWITCH_ROOM_CONNECT_EVENT = 'switch_room_connect'; +/** The value as a plain object, or null for anything else. */ +function asRecord(value: unknown): Record | null { + if (value === null || typeof value !== 'object' || Array.isArray(value)) return null; + return value as Record; +} + +/** + * The value as a Switch `connect_to_room` result — a plain object carrying both + * a string `room_id` and a string `agent_id` — or null. Those two fields are + * what tell the payload apart from an MCP envelope wrapping it, since both are + * plain objects. + * + * Both are required because the caller returns on the first match: a partial + * `structuredContent` that satisfied a `room_id`-only probe would win over the + * text block that carries the whole result, and the event would then be dropped + * for a missing `agent_id` that was available all along. + */ +function asRoomResult(value: unknown): Record | null { + const record = asRecord(value); + if (!record) return null; + return typeof record.room_id === 'string' && typeof record.agent_id === 'string' ? record : null; +} + /** - * Claude reports the tool result under `tool_response`, which may arrive as an - * already-parsed object or a JSON string. The Switch `connect_to_room` result - * carries `room_id` and `agent_id`. + * Extract the Switch `connect_to_room` result — which carries `room_id`, + * `agent_id` and `name` — from the hook's `tool_response`. + * + * How deeply it is wrapped depends on how far the agent CLI unwraps the MCP + * `CallToolResult` before handing it to the hook. Claude reports the payload + * itself (as an object or a JSON string); an envelope-aware CLI such as Codex + * may pass the `CallToolResult` through, which puts the payload under + * `structuredContent` (or `structuredContent.result` when the tool returned a + * non-dict) and repeats it as JSON in the first `text` content block. + * Malformed input yields null rather than throwing. */ function parseToolResponse(body: Record): Record | null { - const raw = body.tool_response; - if (raw && typeof raw === 'object') return raw as Record; - if (typeof raw === 'string') { + let value = body.tool_response; + if (typeof value === 'string') { try { - const value: unknown = JSON.parse(raw); - if (value && typeof value === 'object') return value as Record; - } catch {} + value = JSON.parse(value) as unknown; + } catch { + return null; + } + } + + const direct = asRoomResult(value); + if (direct) return direct; + + const envelope = asRecord(value); + if (!envelope) return null; + + const structured = asRecord(envelope.structuredContent); + const fromStructured = asRoomResult(structured) ?? asRoomResult(structured?.result); + if (fromStructured) return fromStructured; + + const content: unknown[] = Array.isArray(envelope.content) ? envelope.content : []; + const text = content.map(asRecord).find((item) => item?.type === 'text')?.text; + if (typeof text !== 'string') return null; + try { + return asRoomResult(JSON.parse(text) as unknown); + } catch { + return null; } - return null; } function parseBody(raw: RawHookRequest): Record { @@ -85,7 +143,8 @@ function canonicalToAgentEvent( export async function parseHookEvent( raw: RawHookRequest, - resolveContext: ContextResolver + resolveContext: ContextResolver, + log: HookEventLogger ): Promise { const ctx = await resolveContext(raw.ptyId); if (!ctx) { @@ -99,7 +158,19 @@ export async function parseHookEvent( const roomId = typeof response?.room_id === 'string' ? response.room_id : null; const agentId = typeof response?.agent_id === 'string' ? response.agent_id : null; const roomName = typeof response?.name === 'string' ? response.name : null; - if (!roomId || !agentId) return { kind: 'ignore' }; + if (!roomId || !agentId) { + const toolResponse = body.tool_response; + log.warn('event-enricher: switch_room_connect carried no usable connect_to_room result', { + providerId: ctx.providerId, + ptyId: ctx.ptyId, + toolResponseType: typeof toolResponse, + toolResponseKeys: + toolResponse !== null && typeof toolResponse === 'object' + ? Object.keys(toolResponse) + : undefined, + }); + return { kind: 'ignore' }; + } return { kind: 'switch-room', ctx, roomId, agentId, roomName }; } diff --git a/dash/apps/switchdash-desktop/src/main/core/agent-runtime/impl/local-agent-runtime.test.ts b/dash/apps/switchdash-desktop/src/main/core/agent-runtime/impl/local-agent-runtime.test.ts index 793f80a05..4c091251e 100644 --- a/dash/apps/switchdash-desktop/src/main/core/agent-runtime/impl/local-agent-runtime.test.ts +++ b/dash/apps/switchdash-desktop/src/main/core/agent-runtime/impl/local-agent-runtime.test.ts @@ -56,7 +56,7 @@ vi.mock('@main/core/switch-rooms/switch-notification-poller', () => ({ })); vi.mock('@main/core/switch-rooms/switch-credentials', () => ({ - readAgentSwitchEnv: vi.fn(async () => ({})), + readAgentSwitchEnvFromFs: vi.fn(async () => ({})), })); vi.mock('@main/core/providers/plugin-registry', () => ({ @@ -154,6 +154,7 @@ vi.mock('@main/core/settings/settings-service', () => ({ })); const { events } = await import('@main/lib/events'); +const { readAgentSwitchEnvFromFs } = await import('@main/core/switch-rooms/switch-credentials'); const { agentHookService } = await import('@main/core/agent-hooks/agent-hook-service'); const { appSettingsService } = await import('@main/core/settings/settings-service'); @@ -195,6 +196,7 @@ function session(): Session { return { id: 'session-1', agentId: 'agent-1', + agentName: 'codex-hoot', providerId: 'codex', title: 'Session 1', shellId: 'system', @@ -249,6 +251,30 @@ describe('local agent runtime respawn state', () => { ptySessionRegistry.unregister('location-1:session-1'); }); + it('reads the identity file keyed by the agent row name, not the session id', async () => { + // Every writer keys `.switch/agents/.json` by the agent's name; a + // reader that keyed by id would silently fall back to the shared + // settings.local.json identity (CHOO-1440). + const exitHandlers: Array<(info: PtyExitInfo) => void> = []; + spawnLocalPty.mockReturnValue(fakePty(exitHandlers)); + vi.mocked(readAgentSwitchEnvFromFs).mockResolvedValueOnce({ + SWITCH_API_ENDPOINT: 'https://switch.example.com', + SWITCH_API_TOKEN: 'tok-123', + SWITCH_AGENT_ID: 'sw-1', + }); + + await localProvider().start(session()); + + expect(readAgentSwitchEnvFromFs).toHaveBeenCalledWith( + expect.anything(), + 'codex-hoot', + expect.anything() + ); + const request = spawnLocalPty.mock.calls[0][0] as { env: Record }; + expect(request.env.SWITCH_API_TOKEN).toBe('tok-123'); + expect(request.env.SWITCH_AGENT_ID).toBe('sw-1'); + }); + it('passes global editor variables to local agent sessions', async () => { const previousEditor = process.env.EDITOR; const previousShell = process.env.SHELL; diff --git a/dash/apps/switchdash-desktop/src/main/core/agent-runtime/impl/local-agent-runtime.ts b/dash/apps/switchdash-desktop/src/main/core/agent-runtime/impl/local-agent-runtime.ts index 9220a5ff3..52a498837 100644 --- a/dash/apps/switchdash-desktop/src/main/core/agent-runtime/impl/local-agent-runtime.ts +++ b/dash/apps/switchdash-desktop/src/main/core/agent-runtime/impl/local-agent-runtime.ts @@ -4,8 +4,10 @@ import { dirTrustService } from '@main/core/agent-hooks/dir-trust-service'; import { ensureHooksInstalled } from '@main/core/agent-hooks/hook-config-service'; import { AgentRuntimeSupervisor } from '@main/core/agent-runtime/agent-runtime-supervisor'; import { resolveAgentSessionCommandArgs } from '@main/core/agent-runtime/resolve-agent-session-command'; +import { switchMcpLaunchArgs } from '@main/core/agent-runtime/switch-mcp-launch-args'; +import { assertSwitchMcpNameFree } from '@main/core/agent-runtime/switch-mcp-preflight'; import type { AgentRuntimeProvider } from '@main/core/agent-runtime/types'; -import { agentSettingsPath } from '@main/core/agents/switch-settings-paths'; +import { agentCredsSlug } from '@main/core/agents/agent-creds-slug'; import { localDependencyManager } from '@main/core/dependencies/dependency-managers'; import { hostDependencyStore } from '@main/core/dependencies/host-dependency-store'; import type { IExecutionContext } from '@main/core/execution-context/types'; @@ -20,7 +22,7 @@ import { getTerminalColorEnv } from '@main/core/pty/terminal-color-scheme'; import { killTmuxSession, makeAgentTmuxSessionName } from '@main/core/pty/tmux-session-name'; import { sessionHooks } from '@main/core/sessions/session-hooks'; import { providerOverrideSettings } from '@main/core/settings/provider-settings-service'; -import { readAgentSwitchEnv } from '@main/core/switch-rooms/switch-credentials'; +import { readAgentSwitchEnvFromFs } from '@main/core/switch-rooms/switch-credentials'; import { switchNotificationPoller } from '@main/core/switch-rooms/switch-notification-poller'; import { switchRoomService } from '@main/core/switch-rooms/switch-room-service'; import type { ResolvedShellProfile } from '@main/core/terminal-shell/types'; @@ -155,14 +157,38 @@ export class LocalAgentRuntime implements AgentRuntimeProvider { cachedStatePath, }); + // A session talks to Switch as its own agent, not whatever identity happens + // to sit in `.claude/settings.local.json`. Real env vars outrank every + // settings file and reach the spawned MCP server, so inject the agent's + // identity last (highest precedence): a subagent from its definition creds, + // and a plain agent from its provider-neutral `.switch/agents/.json` + // (empty when absent — the session then falls back to settings.local.json, + // which Claude reads natively). + // Resolved before the command is built because an agent that cannot expand + // variables in its MCP config needs the endpoint on argv (see below). + const workspaceFs = createPluginFs(this.sessionPath); + const subagentVars = + session.agentName && repoAgents + ? await repoAgents.readLaunchEnv(workspaceFs, session.agentName) + : await readAgentSwitchEnvFromFs(workspaceFs, agentCredsSlug(session), log); + + // Before argv is built: registering the Switch server on the command line + // over a name the user's own config already defines would make the agent + // reject its whole config and exit without explanation. + await assertSwitchMcpNameFree(plugin, session.providerId, createPluginFs(homedir())); + const agentCommand = plugin.behavior.prompt!.buildCommand({ cli: executableCli, extraArgs: parseExtraArgs(providerConfig?.extraArgs), - // The provider owns how to run as the named agent (CHOO-1440). - agentArgs: - session.agentName && repoAgents + // The provider owns how to run as the named agent (CHOO-1440), and how to + // receive a per-session Switch MCP server when it cannot read one from a + // config file. + agentArgs: [ + ...(session.agentName && repoAgents ? repoAgents.launchArgs(this.sessionPath, session.agentName) - : [], + : []), + ...switchMcpLaunchArgs(plugin, subagentVars.SWITCH_API_ENDPOINT), + ], autoApprove: session.autoApprove ?? false, initialPrompt: agentSession.isResuming ? undefined : initialPrompt, sessionId: agentSession.sessionId, @@ -197,18 +223,7 @@ export class LocalAgentRuntime implements AgentRuntimeProvider { const port = agentHookService.getPort(); const token = agentHookService.getToken(); const colorEnv = await getTerminalColorEnv(); - // A session talks to Switch as its own agent, not whatever identity happens - // to sit in `.claude/settings.local.json`. Real env vars outrank every - // settings file and reach the spawned MCP server, so inject the agent's - // identity last (highest precedence): a subagent from its definition creds, - // and a plain agent from its provider-neutral `.switch/agents/.json` - // (empty when absent — the session then falls back to settings.local.json, - // which Claude reads natively). Lets agents sharing a location keep distinct - // identities (CHOO-1440). - const subagentVars = - session.agentName && repoAgents - ? await repoAgents.readLaunchEnv(createPluginFs(this.sessionPath), session.agentName) - : await readAgentSwitchEnv(agentSettingsPath(this.sessionPath, session.agentId), log); + const pty = spawnLocalPty({ id: ptySessionId, command: resolved.command, diff --git a/dash/apps/switchdash-desktop/src/main/core/agent-runtime/impl/remote-plugin-fs.ts b/dash/apps/switchdash-desktop/src/main/core/agent-runtime/impl/remote-plugin-fs.ts index 719b9e84f..996a266fd 100644 --- a/dash/apps/switchdash-desktop/src/main/core/agent-runtime/impl/remote-plugin-fs.ts +++ b/dash/apps/switchdash-desktop/src/main/core/agent-runtime/impl/remote-plugin-fs.ts @@ -1,5 +1,6 @@ import { dirname } from 'node:path/posix'; import type { PluginFs } from '@switchdash/core/agents/plugins'; +import { assertRemoved } from '@main/core/fs/assert-removed'; import { FileSystemError, FileSystemErrorCodes, @@ -47,7 +48,7 @@ export function createRemotePluginFs(fs: FileSystemProvider): PluginFs { }, async delete(path: string): Promise { - await fs.remove(path); + assertRemoved(path, await fs.remove(path)); }, async exists(path: string): Promise { diff --git a/dash/apps/switchdash-desktop/src/main/core/agent-runtime/impl/ssh-agent-runtime.test.ts b/dash/apps/switchdash-desktop/src/main/core/agent-runtime/impl/ssh-agent-runtime.test.ts index 33bbb67d9..acca33916 100644 --- a/dash/apps/switchdash-desktop/src/main/core/agent-runtime/impl/ssh-agent-runtime.test.ts +++ b/dash/apps/switchdash-desktop/src/main/core/agent-runtime/impl/ssh-agent-runtime.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { FileSystemError, FileSystemErrorCodes } from '@main/core/fs/types'; import type { Pty, PtyExitInfo } from '@main/core/pty/pty'; import { ptySessionRegistry } from '@main/core/pty/pty-session-registry'; import type { SshClientProxy } from '@main/core/ssh/lifecycle/ssh-client-proxy'; @@ -15,6 +16,12 @@ const buildCommandMock = vi.hoisted(() => env: {} as Record, })) ); +/** A provider with no `mcp` behavior — it resolves MCP servers from config. */ +const defaultGetPlugin = vi.hoisted(() => (id: string) => ({ + metadata: { id }, + capabilities: { hostDependency: { binaryNames: [id] }, hooks: { kind: 'none' } }, + behavior: { prompt: { buildCommand: buildCommandMock } }, +})); const resolveSshCommand = vi.hoisted(() => vi.fn(() => 'remote-cmd')); const deployAndLaunch = vi.hoisted(() => vi.fn(async () => ({ port: 9999, token: 'sidecar-tok' }))); const sidecarStop = vi.hoisted(() => vi.fn(async () => {})); @@ -66,11 +73,7 @@ vi.mock('@main/core/agents/getAgentById', () => ({ })); vi.mock('@main/core/providers/plugin-registry', () => ({ - getPlugin: vi.fn((id: string) => ({ - metadata: { id }, - capabilities: { hostDependency: { binaryNames: [id] }, hooks: { kind: 'none' } }, - behavior: { prompt: { buildCommand: buildCommandMock } }, - })), + getPlugin: vi.fn(defaultGetPlugin), })); vi.mock('./keystroke-injection', () => ({ @@ -117,6 +120,8 @@ function emitReconnected(connectionId: string): void { } const { events } = await import('@main/lib/events'); +const { getAgentById } = await import('@main/core/agents/getAgentById'); +const { getPlugin } = await import('@main/core/providers/plugin-registry'); type ProviderState = { known: boolean; @@ -139,14 +144,31 @@ function makeCtx(): ConstructorParameters[0]['ctx'] { return { exec: vi.fn(async () => ({ stdout: '', stderr: '' })) } as never; } +/** A remote filesystem holding `files` (keyed by repo-relative path); anything + * else reads as NOT_FOUND, matching SshFileSystem. */ +function makeRemoteFs(files: Record = {}) { + return { + copyLocalFile: vi.fn(async () => {}), + read: vi.fn(async (relPath: string) => { + const content = files[relPath]; + if (content === undefined) { + throw new FileSystemError(`no such file: ${relPath}`, FileSystemErrorCodes.NOT_FOUND); + } + return { content }; + }), + } as never; +} + function sshProvider({ proxy = makeProxy(), tmux = false, ctx = makeCtx(), + fs = makeRemoteFs(), }: { proxy?: SshClientProxy; tmux?: boolean; ctx?: ConstructorParameters[0]['ctx']; + fs?: ConstructorParameters[0]['fs']; } = {}) { return new SshAgentRuntime({ locationId: 'location-1', @@ -154,7 +176,7 @@ function sshProvider({ sessionPath: '/repo', tmux, ctx, - fs: { copyLocalFile: vi.fn(async () => {}) } as never, + fs, proxy, connectionId: 'ssh-1', }); @@ -165,6 +187,7 @@ function session(): Session { return { id: 'session-1', agentId: 'agent-1', + agentName: 'codex-hoot', providerId: 'codex', title: 'Session 1', shellId: 'system', @@ -203,6 +226,7 @@ describe('SshAgentRuntime', () => { openSsh2Pty.mockReset(); buildCommandMock.mockReset(); buildCommandMock.mockReturnValue({ command: 'agent', args: [], env: {} }); + vi.mocked(getPlugin).mockImplementation(defaultGetPlugin as never); resolveSshCommand.mockClear(); deployAndLaunch.mockClear(); sidecarStop.mockClear(); @@ -230,6 +254,82 @@ describe('SshAgentRuntime', () => { expect(ptySessionRegistry.get(sessionId)).toBeDefined(); }); + it('injects the agent identity from its neutral creds file for a provider without repo-agents', async () => { + // Codex has no `repoAgents` behavior, so there is no `readLaunchEnv` hook to + // go through — the runtime must still read `.switch/agents/.json` from + // the VM, or the remote session authenticates to Switch as nobody. + const exitHandlers: Array void>> = []; + mockSpawn(exitHandlers); + + await sshProvider({ + fs: makeRemoteFs({ + '.switch/agents/codex-hoot.json': JSON.stringify({ + env: { + SWITCH_API_ENDPOINT: 'https://switch.example.com', + SWITCH_API_TOKEN: 'tok-123', + SWITCH_AGENT_ID: 'sw-1', + }, + }), + }), + }).start(session()); + + expect(resolveSshCommand).toHaveBeenCalledWith( + 'agent', + expect.anything(), + expect.objectContaining({ + SWITCH_API_ENDPOINT: 'https://switch.example.com', + SWITCH_API_TOKEN: 'tok-123', + SWITCH_AGENT_ID: 'sw-1', + }), + expect.anything() + ); + }); + + it('registers the Switch MCP server on argv for a provider that needs it there', async () => { + // The token reaches the session as an env var, but Codex only learns the + // server exists from argv. Without this a remote Codex session comes up + // authenticated and with no `switch` tools — configured-looking and inert. + vi.mocked(getPlugin).mockImplementation( + (id: string) => + ({ + metadata: { id }, + capabilities: { hostDependency: { binaryNames: [id] }, hooks: { kind: 'none' } }, + behavior: { + prompt: { buildCommand: buildCommandMock }, + mcp: { + launchArgsForServer: (server: { name: string; url?: string }) => [ + '-c', + `mcp_servers.${server.name}.url=${JSON.stringify(server.url)}`, + ], + }, + }, + }) as never + ); + vi.mocked(getAgentById).mockResolvedValueOnce({ + autoApprove: false, + name: 'codex-hoot', + } as never); + mockSpawn([]); + + await sshProvider({ + fs: makeRemoteFs({ + '.switch/agents/codex-hoot.json': JSON.stringify({ + env: { + SWITCH_API_ENDPOINT: 'https://switch.example.com/', + SWITCH_API_TOKEN: 'tok-123', + SWITCH_AGENT_ID: 'sw-1', + }, + }), + }), + }).start(session()); + + expect(buildCommandMock).toHaveBeenLastCalledWith( + expect.objectContaining({ + agentArgs: ['-c', 'mcp_servers.switch.url="https://switch.example.com/mcp/"'], + }) + ); + }); + it('propagates a failed SSH channel open as an error', async () => { openSsh2Pty.mockResolvedValue({ success: false, error: new Error('channel refused') }); diff --git a/dash/apps/switchdash-desktop/src/main/core/agent-runtime/impl/ssh-agent-runtime.ts b/dash/apps/switchdash-desktop/src/main/core/agent-runtime/impl/ssh-agent-runtime.ts index 3eddc80f4..f1be68932 100644 --- a/dash/apps/switchdash-desktop/src/main/core/agent-runtime/impl/ssh-agent-runtime.ts +++ b/dash/apps/switchdash-desktop/src/main/core/agent-runtime/impl/ssh-agent-runtime.ts @@ -2,7 +2,10 @@ import { DEEPLINK_SCHEME } from '@main/app/deeplinks'; import { agentHookService } from '@main/core/agent-hooks/agent-hook-service'; import { AgentRuntimeSupervisor } from '@main/core/agent-runtime/agent-runtime-supervisor'; import { resolveAgentSessionCommandArgs } from '@main/core/agent-runtime/resolve-agent-session-command'; +import { switchMcpLaunchArgs } from '@main/core/agent-runtime/switch-mcp-launch-args'; +import { assertSwitchMcpNameFree } from '@main/core/agent-runtime/switch-mcp-preflight'; import type { AgentRuntimeProvider } from '@main/core/agent-runtime/types'; +import { agentCredsSlug } from '@main/core/agents/agent-creds-slug'; import { getAgentById } from '@main/core/agents/getAgentById'; import { reapStaleSidecarsForAgent } from '@main/core/agents/reap-stale-sidecars'; import { hostDependencyStore } from '@main/core/dependencies/host-dependency-store'; @@ -20,6 +23,7 @@ import { providerOverrideSettings } from '@main/core/settings/provider-settings- import { sshConnectionManager } from '@main/core/ssh/lifecycle/production-ssh-connection-manager'; import type { SshClientProxy } from '@main/core/ssh/lifecycle/ssh-client-proxy'; import type { SshConnectionManagerEvent } from '@main/core/ssh/lifecycle/ssh-connection-manager'; +import { readAgentSwitchEnvFromFs } from '@main/core/switch-rooms/switch-credentials'; import { events } from '@main/lib/events'; import { runWithLogContext } from '@main/lib/log-context'; import { log } from '@main/lib/logger'; @@ -213,7 +217,7 @@ export class SshAgentRuntime implements AgentRuntimeProvider { repoDir: this.sessionPath, deeplinkScheme: DEEPLINK_SCHEME, autoApprove: agent?.autoApprove ?? false, - credsSlug: agent?.name ?? session.agentName ?? session.agentId, + credsSlug: agentCredsSlug(session), agentName: agent?.name ?? session.agentName ?? null, ctx: this.ctx, connectionId: this.connectionId, @@ -303,7 +307,7 @@ export class SshAgentRuntime implements AgentRuntimeProvider { repoDir: this.sessionPath, deeplinkScheme: DEEPLINK_SCHEME, autoApprove: agent?.autoApprove ?? false, - credsSlug: agent?.name ?? session.agentName ?? session.agentId, + credsSlug: agentCredsSlug(session), agentName: agent?.name ?? session.agentName ?? null, ctx: this.ctx, connectionId: this.connectionId, @@ -428,17 +432,38 @@ export class SshAgentRuntime implements AgentRuntimeProvider { connectionId: this.connectionId, }); + // The agent's Switch identity as real env vars (highest precedence): read + // from its neutral `.switch/agents/.json` on the VM. A `--settings` + // file's env block is not reliably propagated to the spawned MCP server, so + // inject it directly, matching the local runtime. + // Resolved before the command is built because an agent that cannot expand + // variables in its MCP config needs the endpoint on argv (see below). + const remoteFs = createRemotePluginFs(this.fs); + const identityVars = + session.agentName && repoAgents + ? await repoAgents.readLaunchEnv(remoteFs, session.agentName) + : await readAgentSwitchEnvFromFs(remoteFs, agentCredsSlug(session), log); + + // `remoteFs` is rooted at the repo dir, not the VM's home, and switchdash + // mounts no home scope for a remote agent — so the MCP name collision that + // makes the agent reject its config cannot be detected here. Passing null + // logs that gap rather than letting an unreadable config read as "clear". + await assertSwitchMcpNameFree(plugin, session.providerId, null); + const agentCommand = plugin.behavior.prompt!.buildCommand({ cli: executableCli, extraArgs: parseExtraArgs(providerConfig?.extraArgs), // A remote agent runs as its own definition: the provider produces the // run-as-name args (Claude → `--agent --settings `), // resolved on the VM (sessionPath is remote). Distinct from user extra - // args (CHOO-1440). - agentArgs: - session.agentName && repoAgents + // args (CHOO-1440). The provider also owns how it receives a per-session + // Switch MCP server when it cannot read one from a config file. + agentArgs: [ + ...(session.agentName && repoAgents ? repoAgents.launchArgs(this.sessionPath, session.agentName) - : [], + : []), + ...switchMcpLaunchArgs(plugin, identityVars.SWITCH_API_ENDPOINT), + ], autoApprove: session.autoApprove ?? false, initialPrompt: agentSession.isResuming ? undefined : initialPrompt, sessionId: agentSession.sessionId, @@ -450,15 +475,6 @@ export class SshAgentRuntime implements AgentRuntimeProvider { const customEnv = providerConfig?.env ?? {}; const providerEnv: Record = { ...agentCommand.env, ...customEnv }; - // The agent's Switch identity as real env vars (highest precedence): read - // from its neutral `.switch/agents/.json` on the VM. A `--settings` - // file's env block is not reliably propagated to the spawned MCP server, so - // inject it directly, matching the local runtime. - const identityVars = - session.agentName && repoAgents - ? await repoAgents.readLaunchEnv(createRemotePluginFs(this.fs), session.agentName) - : {}; - const tmuxSessionName = this.tmux ? makeAgentTmuxSessionName(this.sessionId) : undefined; const cfg: AgentSessionConfig = { diff --git a/dash/apps/switchdash-desktop/src/main/core/agent-runtime/resolve-agent-session-command.test.ts b/dash/apps/switchdash-desktop/src/main/core/agent-runtime/resolve-agent-session-command.test.ts index 865298aff..0bd8ce2b3 100644 --- a/dash/apps/switchdash-desktop/src/main/core/agent-runtime/resolve-agent-session-command.test.ts +++ b/dash/apps/switchdash-desktop/src/main/core/agent-runtime/resolve-agent-session-command.test.ts @@ -115,6 +115,11 @@ describe('resolveAgentSessionCommandArgs', () => { }); expect(result.command).toBe('codex'); - expect(result.args).toEqual(['resume', 'provider-session-1']); + // Hook trust leads every Codex invocation, before the subcommand. + expect(result.args).toEqual([ + '--dangerously-bypass-hook-trust', + 'resume', + 'provider-session-1', + ]); }); }); diff --git a/dash/apps/switchdash-desktop/src/main/core/agent-runtime/switch-mcp-launch-args.test.ts b/dash/apps/switchdash-desktop/src/main/core/agent-runtime/switch-mcp-launch-args.test.ts new file mode 100644 index 000000000..be6320482 --- /dev/null +++ b/dash/apps/switchdash-desktop/src/main/core/agent-runtime/switch-mcp-launch-args.test.ts @@ -0,0 +1,60 @@ +import { codexMcpAdapter } from '@switchdash/core/agents/plugins/helpers'; +import { describe, expect, it } from 'vitest'; +import type { getPlugin } from '@main/core/providers/plugin-registry'; +import { switchMcpLaunchArgs } from './switch-mcp-launch-args'; + +type Plugin = ReturnType; + +/** The real Codex MCP behavior — it renders servers onto argv. */ +const codexPlugin = { behavior: { mcp: codexMcpAdapter() } } as unknown as Plugin; + +/** A provider whose connector resolves MCP servers itself, as Claude's does. */ +const claudeLikePlugin = { behavior: { mcp: {} } } as unknown as Plugin; + +describe('switchMcpLaunchArgs', () => { + it('registers the Switch server by endpoint, naming the token env var rather than embedding it', () => { + const args = switchMcpLaunchArgs(codexPlugin, 'https://switch.test/api'); + expect(args).toEqual([ + '-c', + 'mcp_servers.switch.url="https://switch.test/api/mcp/"', + '-c', + 'mcp_servers.switch.bearer_token_env_var="SWITCH_API_TOKEN"', + ]); + }); + + it('never puts the token itself on argv', () => { + // argv is world-readable via `ps`; only the variable's *name* may appear. + const args = switchMcpLaunchArgs(codexPlugin, 'https://switch.test/api').join(' '); + expect(args).toContain('bearer_token_env_var'); + expect(args).not.toMatch(/Bearer\s/); + }); + + it('emits url and token together, since -c merges key-by-key into the table', () => { + // `-c mcp_servers..` sets that one key and leaves the rest of the + // table as it was — it does not substitute a whole server definition — so + // emitting only `url` would register the server unauthenticated. (The same + // merge is why the name must not already exist in the user's config: see the + // codexMcpAdapter docblock.) + const keys = switchMcpLaunchArgs(codexPlugin, 'https://switch.test/api') + .filter((a) => a !== '-c') + .map((a) => a.split('=')[0]); + expect(keys).toEqual(['mcp_servers.switch.url', 'mcp_servers.switch.bearer_token_env_var']); + }); + + it('normalises trailing slashes on the endpoint', () => { + expect(switchMcpLaunchArgs(codexPlugin, 'https://switch.test/api///')).toContain( + 'mcp_servers.switch.url="https://switch.test/api/mcp/"' + ); + }); + + it('emits nothing when the provider resolves MCP servers some other way', () => { + expect(switchMcpLaunchArgs(claudeLikePlugin, 'https://switch.test/api')).toEqual([]); + }); + + it('emits nothing when the session has no Switch identity', () => { + // No credentials means no endpoint; a half-formed server is worse than none. + for (const endpoint of [undefined, '', ' ']) { + expect(switchMcpLaunchArgs(codexPlugin, endpoint)).toEqual([]); + } + }); +}); diff --git a/dash/apps/switchdash-desktop/src/main/core/agent-runtime/switch-mcp-launch-args.ts b/dash/apps/switchdash-desktop/src/main/core/agent-runtime/switch-mcp-launch-args.ts new file mode 100644 index 000000000..0f184734e --- /dev/null +++ b/dash/apps/switchdash-desktop/src/main/core/agent-runtime/switch-mcp-launch-args.ts @@ -0,0 +1,42 @@ +import type { getPlugin } from '@main/core/providers/plugin-registry'; +import { switchMcpUrl } from '@shared/core/switch-rooms/switch-mcp-endpoint'; + +/** MCP server name the Switch tools are registered under. */ +export const SWITCH_MCP_SERVER_NAME = 'switch'; + +/** Env var the agent reads the Switch bearer token from at request time. */ +export const SWITCH_MCP_TOKEN_ENV_VAR = 'SWITCH_API_TOKEN'; + +/** + * Launch arguments registering the Switch MCP server for this session, for + * agents that must receive it on argv. + * + * Only the endpoint is passed. The token is named, not embedded: the agent reads + * it from the injected `SWITCH_API_TOKEN` at request time, so a per-agent secret + * never reaches a process listing or a config file. + * + * Returns nothing when the provider resolves MCP servers some other way (its + * connector plugin expands env vars) or when the session has no Switch identity + * — an agent with no credentials has no endpoint to point at. + * + * `apiEndpoint` may be `SWITCH_API_ENDPOINT_PLACEHOLDER` when precomputing a + * launch spec for the on-VM watcher, which substitutes the real endpoint per + * spawn. + */ +export function switchMcpLaunchArgs( + plugin: ReturnType, + apiEndpoint: string | undefined +): string[] { + const buildArgs = plugin.behavior.mcp?.launchArgsForServer; + if (!buildArgs) return []; + + const url = switchMcpUrl(apiEndpoint); + if (url === null) return []; + + return buildArgs({ + name: SWITCH_MCP_SERVER_NAME, + transport: 'http', + url, + bearer_token_env_var: SWITCH_MCP_TOKEN_ENV_VAR, + }); +} diff --git a/dash/apps/switchdash-desktop/src/main/core/agent-runtime/switch-mcp-preflight.test.ts b/dash/apps/switchdash-desktop/src/main/core/agent-runtime/switch-mcp-preflight.test.ts new file mode 100644 index 000000000..7c07d6642 --- /dev/null +++ b/dash/apps/switchdash-desktop/src/main/core/agent-runtime/switch-mcp-preflight.test.ts @@ -0,0 +1,69 @@ +import type { PluginFs } from '@switchdash/core/agents/plugins'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { getPlugin } from '@main/core/providers/plugin-registry'; +import { assertSwitchMcpNameFree } from './switch-mcp-preflight'; + +const h = vi.hoisted(() => ({ warn: vi.fn() })); +vi.mock('@main/lib/logger', () => ({ log: { info: vi.fn(), warn: h.warn, error: vi.fn() } })); + +type Plugin = ReturnType; + +/** A plugin whose MCP behavior reports `servers` and takes them on argv. */ +function argvPlugin(servers: Array<{ name: string }>): Plugin { + return { + behavior: { + mcp: { + readServers: vi.fn(async () => servers), + launchArgsForServer: () => ['-c', 'x'], + }, + }, + } as unknown as Plugin; +} + +/** A provider that resolves MCP servers from its own config (Claude Code). */ +function configPlugin(servers: Array<{ name: string }>): Plugin { + return { + behavior: { mcp: { readServers: vi.fn(async () => servers) } }, + } as unknown as Plugin; +} + +const anyFs = {} as PluginFs; + +describe('assertSwitchMcpNameFree', () => { + beforeEach(() => h.warn.mockClear()); + + it('throws when the config already defines a server called switch', async () => { + // `-c mcp_servers.switch.url=…` merges into that table, producing an entry + // with both `command` and `url` that Codex refuses to load at all. + await expect( + assertSwitchMcpNameFree(argvPlugin([{ name: 'switch' }]), 'codex', anyFs) + ).rejects.toThrow(/already defines an MCP server named "switch"/); + }); + + it('resolves when the config defines other servers', async () => { + await expect( + assertSwitchMcpNameFree(argvPlugin([{ name: 'github' }]), 'codex', anyFs) + ).resolves.toBeUndefined(); + }); + + it('ignores a switch server for a provider that reads MCP from config', async () => { + // For Claude Code a `switch` entry is the wanted state, not a collision. + await expect( + assertSwitchMcpNameFree(configPlugin([{ name: 'switch' }]), 'claude', anyFs) + ).resolves.toBeUndefined(); + }); + + it('warns instead of passing silently when the home scope is unreadable', async () => { + // A remote agent's VM home is not mounted, so "no servers found" would be an + // answer about switchdash's own filesystem, not the agent's. + const plugin = argvPlugin([{ name: 'switch' }]); + + await expect(assertSwitchMcpNameFree(plugin, 'codex', null)).resolves.toBeUndefined(); + + expect(h.warn).toHaveBeenCalledWith( + expect.stringContaining('skipping the MCP name collision check'), + expect.objectContaining({ providerId: 'codex' }) + ); + expect(plugin.behavior.mcp?.readServers).not.toHaveBeenCalled(); + }); +}); diff --git a/dash/apps/switchdash-desktop/src/main/core/agent-runtime/switch-mcp-preflight.ts b/dash/apps/switchdash-desktop/src/main/core/agent-runtime/switch-mcp-preflight.ts new file mode 100644 index 000000000..76cfb3435 --- /dev/null +++ b/dash/apps/switchdash-desktop/src/main/core/agent-runtime/switch-mcp-preflight.ts @@ -0,0 +1,49 @@ +import type { PluginFs } from '@switchdash/core/agents/plugins'; +import type { getPlugin } from '@main/core/providers/plugin-registry'; +import { log } from '@main/lib/logger'; +import { SWITCH_MCP_SERVER_NAME } from './switch-mcp-launch-args'; + +/** + * Refuse to launch when the agent's own config already defines a server under + * the name switchdash is about to register on argv. + * + * `-c mcp_servers..` merges into the config's table for that name + * rather than replacing it. Verified against Codex 0.146.0: overriding `url` on + * a name the config defines as a stdio server yields a table with both `command` + * and `url`, and Codex then refuses to load its config *at all* — the session + * exits immediately with a parse error switchdash never sees. Failing here turns + * that into a legible message. + * + * `homeFs` is the agent's user-scope filesystem, or null when it cannot be read. + * Null is a documented skip, not a pass: a remote agent's VM home is not mounted + * here (`resolveWorkspaceFsFor` returns a `PluginFs` whose `exists` is always + * false), so probing it would answer "no collision" for every host. + */ +export async function assertSwitchMcpNameFree( + plugin: ReturnType, + providerId: string, + homeFs: PluginFs | null +): Promise { + const mcp = plugin.behavior.mcp; + // Only providers that receive the server on argv can collide this way; for + // everyone else an existing `switch` server is the normal, wanted state. + if (!mcp?.launchArgsForServer) return; + + if (homeFs === null) { + log.warn('switch-mcp: skipping the MCP name collision check — home scope not readable', { + providerId, + serverName: SWITCH_MCP_SERVER_NAME, + }); + return; + } + + const existing = await mcp.readServers(homeFs); + if (!existing.some((server) => server.name === SWITCH_MCP_SERVER_NAME)) return; + + throw new Error( + `Your ${providerId} config already defines an MCP server named "${SWITCH_MCP_SERVER_NAME}". ` + + `switchdash registers the Switch server under that name on the command line, and ${providerId} ` + + `merges the two into one entry it then refuses to load. Rename or remove the existing ` + + `"${SWITCH_MCP_SERVER_NAME}" server and start the session again.` + ); +} diff --git a/dash/apps/switchdash-desktop/src/main/core/agents/add-agent.test.ts b/dash/apps/switchdash-desktop/src/main/core/agents/add-agent.test.ts new file mode 100644 index 000000000..8440b1b41 --- /dev/null +++ b/dash/apps/switchdash-desktop/src/main/core/agents/add-agent.test.ts @@ -0,0 +1,179 @@ +import type { PluginFs } from '@switchdash/core/agents/plugins'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { agentSettingsRelativePath } from './switch-settings-paths'; + +/** In-memory {@link PluginFs} keyed by the exact relative paths the writers use. */ +function fakeFs(seed: Record = {}): PluginFs { + const files = new Map(Object.entries(seed)); + return { + read: (p) => Promise.resolve(files.has(p) ? (files.get(p) as string) : null), + write: (p, c) => { + files.set(p, c); + return Promise.resolve(); + }, + delete: (p) => { + files.delete(p); + return Promise.resolve(); + }, + exists: (p) => Promise.resolve(files.has(p)), + list: () => Promise.resolve([...files.keys()]), + }; +} + +// `repoAgents` is what `getPlugin` returns — set it to null in a test to +// simulate a provider without repo-agent definitions (e.g. Codex). +const h = vi.hoisted(() => { + const writeDefinition = vi.fn(async () => {}); + const state: { + workspace: PluginFs | null; + repoAgents: object | null; + nameTaken: boolean; + } = { + workspace: null, + repoAgents: { writeDefinition }, + nameTaken: false, + }; + return { + state, + writeDefinition, + agentNameTaken: vi.fn(async () => state.nameTaken), + registerAgentIdentity: vi.fn(async () => ({ + kind: 'created' as const, + id: 'sw-1', + apiKey: 'tok-123', + })), + createAgent: vi.fn(async (input: Record) => ({ ...input })), + }; +}); + +vi.mock('@main/core/providers/plugin-registry', () => ({ + getPlugin: () => ({ behavior: { repoAgents: h.state.repoAgents } }), +})); +vi.mock('./register-agent-identity', () => ({ registerAgentIdentity: h.registerAgentIdentity })); +vi.mock('./createAgent', () => ({ createAgent: h.createAgent })); +vi.mock('./agent-workspace-fs', () => ({ + resolveWorkspaceFsFor: vi.fn(async () => ({ + fs: h.state.workspace as PluginFs, + close: vi.fn(), + })), +})); +vi.mock('@main/core/switch-servers/servers-store', () => ({ + getServer: vi.fn(async () => ({ id: 'srv-1', apiUrl: 'https://switch.example.com' })), +})); +vi.mock('@main/core/locations/store', () => ({ + ensureLocation: vi.fn(async () => ({ id: 'loc-1' })), + getLocationByHostDir: vi.fn(async () => ({ id: 'loc-1' })), +})); +vi.mock('./agent-name-taken', () => ({ agentNameTaken: h.agentNameTaken })); +vi.mock('@main/core/locations/path-utils', () => ({ checkIsValidDirectory: () => true })); +vi.mock('@main/core/locations/location-manager', () => ({ + locationManager: { openLocation: vi.fn(async () => {}) }, +})); +vi.mock('./setAgentAutoSession', () => ({ + reconcileAgentAutoSessionFromGateway: vi.fn(async () => {}), +})); +vi.mock('./agent-events', () => ({ agentEvents: { _emit: vi.fn() } })); +vi.mock('@main/lib/logger', () => ({ log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() } })); + +const { addAgent } = await import('./add-agent'); + +function params(overrides: Record = {}) { + return { + sshHost: null, + dir: '/repo', + name: 'codex-hoot', + providerId: 'codex' as const, + serverId: 'srv-1', + description: 'Codex running in repo', + autoSession: false, + autoApprove: false, + definitionAttributes: {}, + ...overrides, + }; +} + +function credsOf(fs: PluginFs, slug: string): Promise> { + return fs + .read(agentSettingsRelativePath(slug)) + .then((raw) => (JSON.parse(raw as string) as { env: Record }).env); +} + +describe('addAgent', () => { + beforeEach(() => { + vi.clearAllMocks(); + h.state.nameTaken = false; + h.state.repoAgents = { writeDefinition: h.writeDefinition }; + h.state.workspace = fakeFs(); + h.registerAgentIdentity.mockResolvedValue({ kind: 'created', id: 'sw-1', apiKey: 'tok-123' }); + }); + + it('writes name-keyed credentials for a provider with no repo-agent definitions', async () => { + // Codex has no `repoAgents` behavior. Before the credential write became + // unconditional it got no credentials on disk at all, so its sessions + // authenticated to Switch as whatever was in settings.local.json. + h.state.repoAgents = null; + const fs = h.state.workspace as PluginFs; + + const result = await addAgent(params()); + + expect(result.kind).toBe('created'); + expect(await credsOf(fs, 'codex-hoot')).toEqual({ + SWITCH_API_ENDPOINT: 'https://switch.example.com', + SWITCH_API_TOKEN: 'tok-123', + SWITCH_AGENT_ID: 'sw-1', + }); + expect(h.writeDefinition).not.toHaveBeenCalled(); + }); + + it('writes both credentials and an on-disk definition for a repo-agents provider', async () => { + const fs = h.state.workspace as PluginFs; + + await addAgent(params({ providerId: 'claude', name: 'cc-hoot' })); + + expect((await credsOf(fs, 'cc-hoot')).SWITCH_API_TOKEN).toBe('tok-123'); + expect(h.writeDefinition).toHaveBeenCalledWith( + fs, + expect.objectContaining({ name: 'cc-hoot', description: 'Codex running in repo' }) + ); + }); + + it('git-ignores the credentials directory so the token never enters VCS', async () => { + const fs = h.state.workspace as PluginFs; + await addAgent(params()); + expect(await fs.read('.switch/agents/.gitignore')).toBe('*\n'); + }); + + it('registers under the gateway known-agent type derived from the provider', async () => { + await addAgent(params()); + expect(h.registerAgentIdentity).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ name: 'codex-hoot', agentType: 'codex' }) + ); + + await addAgent(params({ providerId: 'claude', name: 'cc-hoot' })); + expect(h.registerAgentIdentity).toHaveBeenLastCalledWith( + expect.anything(), + expect.objectContaining({ agentType: 'claude-code' }) + ); + }); + + it('writes nothing to the workspace when registration fails', async () => { + h.registerAgentIdentity.mockResolvedValue({ kind: 'name-conflict' } as never); + const fs = h.state.workspace as PluginFs; + + expect((await addAgent(params())).kind).toBe('name-conflict'); + expect(await fs.read(agentSettingsRelativePath('codex-hoot'))).toBeNull(); + expect(h.createAgent).not.toHaveBeenCalled(); + }); + + it('refuses a name already taken in the location, without minting an identity', async () => { + // The gateway's 409 is scoped to the Switch server, so it cannot see a name + // that is free there and taken in this directory — where both agents would + // then share one `.switch/agents/.json`. + h.state.nameTaken = true; + + expect((await addAgent(params())).kind).toBe('name-conflict'); + expect(h.registerAgentIdentity).not.toHaveBeenCalled(); + expect(h.createAgent).not.toHaveBeenCalled(); + }); +}); diff --git a/dash/apps/switchdash-desktop/src/main/core/agents/add-agent.ts b/dash/apps/switchdash-desktop/src/main/core/agents/add-agent.ts index 1515d1da4..d5ae2f98b 100644 --- a/dash/apps/switchdash-desktop/src/main/core/agents/add-agent.ts +++ b/dash/apps/switchdash-desktop/src/main/core/agents/add-agent.ts @@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto'; import type { RepoAgentAttributes } from '@switchdash/core/agents/plugins'; import { locationManager } from '@main/core/locations/location-manager'; import { checkIsValidDirectory } from '@main/core/locations/path-utils'; -import { ensureLocation } from '@main/core/locations/store'; +import { ensureLocation, getLocationByHostDir } from '@main/core/locations/store'; import { getPlugin } from '@main/core/providers/plugin-registry'; import { getServer } from '@main/core/switch-servers/servers-store'; import { log } from '@main/lib/logger'; @@ -10,10 +10,13 @@ import type { Agent } from '@shared/core/agents/agents'; import type { AgentProviderId } from '@shared/core/providers/agent-provider-registry'; import { basenameFromAnyPath } from '@shared/path-name'; import { agentEvents } from './agent-events'; +import { agentNameTaken } from './agent-name-taken'; import { resolveWorkspaceFsFor } from './agent-workspace-fs'; import { createAgent } from './createAgent'; +import { knownAgentTypeForProvider } from './known-agent-type'; import { registerAgentIdentity } from './register-agent-identity'; import { reconcileAgentAutoSessionFromGateway } from './setAgentAutoSession'; +import { writeNeutralAgentSettingsFs } from './write-switch-settings'; export type AddAgentParams = { id?: string; @@ -66,29 +69,43 @@ export async function addAgent(params: AddAgentParams): Promise const server = await getServer(params.serverId); if (!server) return { kind: 'error', message: `No Switch server with id ${params.serverId}` }; + // Before minting an identity: the gateway's uniqueness check is scoped to the + // Switch server, so it cannot see a name already taken in this directory. Two + // same-named agents here would share one `.switch/agents/.json`. + const existingLocation = await getLocationByHostDir(params.sshHost, params.dir); + if (existingLocation && (await agentNameTaken(existingLocation.id, params.name, null))) { + return { kind: 'name-conflict' }; + } + const registered = await registerAgentIdentity(server, { name: params.name, description: params.description, repoDir: params.dir, autoSession: params.autoSession, + agentType: knownAgentTypeForProvider(params.providerId), }); if (registered.kind !== 'created') return registered; const behavior = getPlugin(params.providerId).behavior.repoAgents; const workspace = await resolveWorkspaceFsFor(params.sshHost, params.dir); try { + // Writing the per-agent Switch credentials is unconditional core behavior for + // every provider, keyed by the agent's `name` — the single key-space every + // reader (launch path, auto-session watcher, notification poller) uses + // (CHOO-1440). Providers with repo-agent definitions (Claude) layer their + // on-disk definition on top; that's the only provider-specific extra. + await writeNeutralAgentSettingsFs(workspace.fs, { + slug: params.name, + apiEndpoint: server.apiUrl, + apiToken: registered.apiKey, + agentId: registered.id, + }); if (behavior) { await behavior.writeDefinition(workspace.fs, { ...params.definitionAttributes, name: params.name, description: params.description, }); - await behavior.writeCredentials(workspace.fs, { - agentName: params.name, - apiEndpoint: server.apiUrl, - apiToken: registered.apiKey, - agentId: registered.id, - }); } } finally { workspace.close(); diff --git a/dash/apps/switchdash-desktop/src/main/core/agents/agent-creds-slug.ts b/dash/apps/switchdash-desktop/src/main/core/agents/agent-creds-slug.ts new file mode 100644 index 000000000..be2d15fd0 --- /dev/null +++ b/dash/apps/switchdash-desktop/src/main/core/agents/agent-creds-slug.ts @@ -0,0 +1,16 @@ +import type { Session } from '@shared/core/sessions/sessions'; + +/** + * The per-agent key its Switch credentials live under: `.switch/agents/.json` + * (CHOO-1440). Every writer keys by the agent's `name`, so every reader must too. + * + * `session.agentName` is the agent row's live `name`, joined on every session load + * rather than frozen into the row, so it already follows a rename — re-reading the + * agent here could only return the same value. The agent id is a last-resort + * fallback for a session built without a name. Kept in one place so the launch + * paths, the sidecar and the remote preflight cannot drift onto different + * key-spaces. + */ +export function agentCredsSlug(session: Session): string { + return session.agentName ?? session.agentId; +} diff --git a/dash/apps/switchdash-desktop/src/main/core/agents/agent-defaults.test.ts b/dash/apps/switchdash-desktop/src/main/core/agents/agent-defaults.test.ts index f36a419a3..866612f9e 100644 --- a/dash/apps/switchdash-desktop/src/main/core/agents/agent-defaults.test.ts +++ b/dash/apps/switchdash-desktop/src/main/core/agents/agent-defaults.test.ts @@ -21,9 +21,17 @@ describe('slugifyAgentNamePart', () => { describe('suggestAgentDefaults', () => { it('builds a claude-code.. name and matches the agent-name pattern', () => { - const { name, description } = suggestAgentDefaults('/Users/someone/My Repo'); + const { name, description } = suggestAgentDefaults('/Users/someone/My Repo', 'claude'); expect(name.startsWith('claude-code.my-repo')).toBe(true); expect(name).toMatch(/^[a-z0-9][a-z0-9._-]*$/); expect(description).toBe('Claude Code running in My Repo'); }); + + it('prefixes the name and description with the chosen provider (Codex)', () => { + const { name, description } = suggestAgentDefaults('/Users/someone/My Repo', 'codex'); + expect(name.startsWith('codex.my-repo')).toBe(true); + expect(name).not.toContain('claude-code'); + expect(name).toMatch(/^[a-z0-9][a-z0-9._-]*$/); + expect(description).toBe('Codex running in My Repo'); + }); }); diff --git a/dash/apps/switchdash-desktop/src/main/core/agents/agent-defaults.ts b/dash/apps/switchdash-desktop/src/main/core/agents/agent-defaults.ts index eef97ed76..e194dd865 100644 --- a/dash/apps/switchdash-desktop/src/main/core/agents/agent-defaults.ts +++ b/dash/apps/switchdash-desktop/src/main/core/agents/agent-defaults.ts @@ -1,5 +1,6 @@ import os from 'node:os'; import path from 'node:path'; +import { getProvider, type AgentProviderId } from '@shared/core/providers/agent-provider-registry'; import type { AgentDefaults } from '@shared/core/switch-servers/switch-servers'; /** @@ -17,13 +18,18 @@ export function slugifyAgentNamePart(value: string): string { } /** - * Suggest a default name and description for a new Claude Code agent in `dir`. - * The name is `claude-code..` — the per-user suffix keeps - * two developers registering from the same repo from colliding (see the - * `configure` skill). Falls back to a bare `claude-code` if both parts slug to - * empty. + * Suggest a default name and description for a new agent of `providerId` in + * `dir`. The name is `..`, where the prefix + * is the provider's display name slugified — `codex`, `claude-code`, `grok`, etc. + * — so the default reflects the chosen agent type (CHOO-1436). The per-user + * suffix keeps two developers registering from the same repo from colliding (see + * the `configure` skill). Falls back to the bare provider slug if the repo/user + * parts both slug to empty. */ -export function suggestAgentDefaults(dir: string): AgentDefaults { +export function suggestAgentDefaults(dir: string, providerId: AgentProviderId): AgentDefaults { + const providerName = getProvider(providerId)?.name ?? providerId; + const providerSlug = + slugifyAgentNamePart(providerName) || slugifyAgentNamePart(providerId) || 'agent'; const repoSlug = slugifyAgentNamePart(path.basename(dir)); let userSlug = ''; try { @@ -32,8 +38,8 @@ export function suggestAgentDefaults(dir: string): AgentDefaults { userSlug = ''; } - const parts = ['claude-code', repoSlug, userSlug].filter((p) => p.length > 0); - const name = parts.length > 1 ? parts.join('.') : 'claude-code'; + const parts = [providerSlug, repoSlug, userSlug].filter((p) => p.length > 0); + const name = parts.length > 1 ? parts.join('.') : providerSlug; const repoLabel = path.basename(dir) || 'this directory'; - return { name, description: `Claude Code running in ${repoLabel}` }; + return { name, description: `${providerName} running in ${repoLabel}` }; } diff --git a/dash/apps/switchdash-desktop/src/main/core/agents/agent-name-taken.ts b/dash/apps/switchdash-desktop/src/main/core/agents/agent-name-taken.ts new file mode 100644 index 000000000..387933bc4 --- /dev/null +++ b/dash/apps/switchdash-desktop/src/main/core/agents/agent-name-taken.ts @@ -0,0 +1,30 @@ +import { and, eq, ne } from 'drizzle-orm'; +import { db } from '@main/db/client'; +import { agents } from '@main/db/schema'; + +/** + * Whether another agent in the same location already answers to `name`. + * + * Everything switchdash provisions per agent is keyed by the name rather than the + * id — `.switch/agents/.json` carries the Switch token, `.claude/agents/.md` + * the definition — so two agents sharing a name in one directory share one + * credentials file, and whoever writes last decides which identity both of them + * present. The gateway's own uniqueness check cannot stand in for this one: it is + * scoped to a Switch server, and a name can be free there while taken here. + * + * `exceptAgentId` is the agent being renamed, so it does not conflict with itself; + * pass null when the agent does not exist yet. + */ +export async function agentNameTaken( + locationId: string, + name: string, + exceptAgentId: string | null +): Promise { + const matchesName = and(eq(agents.locationId, locationId), eq(agents.name, name)); + const [row] = await db + .select({ id: agents.id }) + .from(agents) + .where(exceptAgentId === null ? matchesName : and(matchesName, ne(agents.id, exceptAgentId))) + .limit(1); + return row !== undefined; +} diff --git a/dash/apps/switchdash-desktop/src/main/core/agents/agent-storage-migration-marker.db.test.ts b/dash/apps/switchdash-desktop/src/main/core/agents/agent-storage-migration-marker.db.test.ts new file mode 100644 index 000000000..2d5899bbb --- /dev/null +++ b/dash/apps/switchdash-desktop/src/main/core/agents/agent-storage-migration-marker.db.test.ts @@ -0,0 +1,76 @@ +import { openFixture } from '@tooling/utils/db'; +import { eq, sql } from 'drizzle-orm'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { AppDb } from '@main/db/client'; +import { kv } from '@main/db/schema'; +import { + AGENT_STORAGE_MIGRATION_GENERATION, + completedAgentStorageMigrationGeneration, + markAgentStorageMigrationComplete, +} from './agent-storage-migration-marker'; + +const mocks = vi.hoisted(() => ({ + db: undefined as AppDb | undefined, +})); + +vi.mock('@main/db/client', () => ({ + get db() { + if (!mocks.db) throw new Error('Test database not initialized'); + return mocks.db; + }, +})); + +const MARKER_KEY = 'agentStorageMigrationComplete'; + +describe('agent storage migration marker', () => { + let fixture: Awaited>; + + beforeEach(async () => { + fixture = await openFixture('empty'); + mocks.db = fixture.db; + }); + + afterEach(() => { + fixture.close(); + mocks.db = undefined; + }); + + it('reports incomplete when no marker has been written', async () => { + expect(await completedAgentStorageMigrationGeneration()).toBeLessThan( + AGENT_STORAGE_MIGRATION_GENERATION + ); + }); + + it('reports complete after a clean pass latches it', async () => { + await markAgentStorageMigrationComplete(); + expect(await completedAgentStorageMigrationGeneration()).toBe( + AGENT_STORAGE_MIGRATION_GENERATION + ); + }); + + it('reports incomplete for a marker latched by an earlier migration generation', async () => { + // Generation 1 skipped every provider without a `repoAgents` behavior. An + // install that latched it must still run the broadened pass, or the change + // that broadened it does nothing at all for existing users. + await fixture.db + .insert(kv) + .values({ key: MARKER_KEY, value: '1', updatedAt: sql`CURRENT_TIMESTAMP` }); + + expect(await completedAgentStorageMigrationGeneration()).toBeLessThan( + AGENT_STORAGE_MIGRATION_GENERATION + ); + }); + + it('upgrades a stale marker in place rather than inserting a second row', async () => { + await fixture.db + .insert(kv) + .values({ key: MARKER_KEY, value: '1', updatedAt: sql`CURRENT_TIMESTAMP` }); + + await markAgentStorageMigrationComplete(); + + expect(await completedAgentStorageMigrationGeneration()).toBe( + AGENT_STORAGE_MIGRATION_GENERATION + ); + expect(await fixture.db.select().from(kv).where(eq(kv.key, MARKER_KEY))).toHaveLength(1); + }); +}); diff --git a/dash/apps/switchdash-desktop/src/main/core/agents/agent-storage-migration-marker.ts b/dash/apps/switchdash-desktop/src/main/core/agents/agent-storage-migration-marker.ts index 40593d1e1..534f8f6ec 100644 --- a/dash/apps/switchdash-desktop/src/main/core/agents/agent-storage-migration-marker.ts +++ b/dash/apps/switchdash-desktop/src/main/core/agents/agent-storage-migration-marker.ts @@ -3,21 +3,43 @@ import { db } from '@main/db/client'; import { kv } from '@main/db/schema'; /** - * Persisted marker recording that the CHOO-1440 agent-storage migration has - * completed a full, error-free pass. Once set, {@link migrateAgentStorage} - * short-circuits at boot instead of re-opening every agent's workspace - * filesystem (an SSH/SFTP round trip per remote agent) on every launch. + * Persisted marker recording which generation of the CHOO-1440 agent-storage + * migration has completed a full, error-free pass. Once current, + * {@link migrateAgentStorage} short-circuits at boot instead of re-opening every + * agent's workspace filesystem (an SSH/SFTP round trip per remote agent, and a + * 20s connect timeout per unreachable host) on every launch. */ const MARKER_KEY = 'agentStorageMigrationComplete'; -export async function isAgentStorageMigrationComplete(): Promise { +/** + * Generations of the migration: + * + * - `1` — the original pass. Providers without a `repoAgents` behavior returned + * "complete" without being looked at, so only Claude agents were migrated. + * - `2` — the credential collapse runs for every provider. + * + * Bump this whenever the migration learns to fix something it previously + * skipped, so installs that latched an earlier generation run the new pass + * exactly once instead of short-circuiting on a marker that no longer means what + * it says. Then teach `migrateAgentStorage` which agents the new generation can + * actually change, so the re-run does not re-open workspaces it cannot fix. + */ +export const AGENT_STORAGE_MIGRATION_GENERATION = 2; + +/** The generation last completed on this install; 0 when it has never run. */ +export async function completedAgentStorageMigrationGeneration(): Promise { const [row] = await db.select().from(kv).where(eq(kv.key, MARKER_KEY)).limit(1); - return row?.value === '1'; + const generation = Number.parseInt(row?.value ?? '', 10); + return Number.isFinite(generation) ? generation : 0; } export async function markAgentStorageMigrationComplete(): Promise { + const value = String(AGENT_STORAGE_MIGRATION_GENERATION); await db .insert(kv) - .values({ key: MARKER_KEY, value: '1', updatedAt: sql`CURRENT_TIMESTAMP` }) - .onConflictDoUpdate({ target: kv.key, set: { value: '1', updatedAt: sql`CURRENT_TIMESTAMP` } }); + .values({ key: MARKER_KEY, value, updatedAt: sql`CURRENT_TIMESTAMP` }) + .onConflictDoUpdate({ + target: kv.key, + set: { value, updatedAt: sql`CURRENT_TIMESTAMP` }, + }); } diff --git a/dash/apps/switchdash-desktop/src/main/core/agents/deleteAgent.test.ts b/dash/apps/switchdash-desktop/src/main/core/agents/deleteAgent.test.ts new file mode 100644 index 000000000..e6002ccf7 --- /dev/null +++ b/dash/apps/switchdash-desktop/src/main/core/agents/deleteAgent.test.ts @@ -0,0 +1,123 @@ +import type { PluginFs } from '@switchdash/core/agents/plugins'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { agentSettingsRelativePath } from './switch-settings-paths'; + +function fakeFs(seed: Record = {}): PluginFs { + const files = new Map(Object.entries(seed)); + return { + read: async (p) => files.get(p) ?? null, + write: async (p, c) => void files.set(p, c), + delete: async (p) => void files.delete(p), + exists: async (p) => files.has(p), + list: async () => [...files.keys()], + }; +} + +const h = vi.hoisted(() => { + const removeLocal = vi.fn(async (fs: PluginFs, name: string) => { + await fs.delete(`.claude/agents/${name}.md`); + }); + const state: { fs: PluginFs; repoAgents: object | null; agent: Record | null } = + { + fs: fakeFs(), + repoAgents: { removeLocal }, + agent: null, + }; + return { state, removeLocal, removeSwitchCredentials: vi.fn(async () => {}) }; +}); + +vi.mock('@main/core/providers/plugin-registry', () => ({ + getPlugin: () => ({ behavior: { repoAgents: h.state.repoAgents } }), +})); +vi.mock('./agent-workspace-fs', () => ({ + resolveWorkspaceFsFor: vi.fn(async () => ({ fs: h.state.fs, close: vi.fn() })), +})); +vi.mock('./agent-location', () => ({ + getAgentLocation: vi.fn(async () => ({ sshHost: null, dir: '/repo' })), +})); +vi.mock('./getAgentById', () => ({ getAgentById: vi.fn(async () => h.state.agent) })); +vi.mock('./remove-switch-settings', () => ({ + removeSwitchCredentials: h.removeSwitchCredentials, +})); +vi.mock('./agent-events', () => ({ agentEvents: { _emit: vi.fn() } })); +vi.mock('./remote-watcher', () => ({ stopRemoteWatcher: vi.fn(async () => {}) })); +vi.mock('./connect-remote-agent', () => ({ connectRemoteAgent: vi.fn() })); +vi.mock('@main/core/agent-runtime/impl/remote-sidecar-launcher', () => ({ + agentSidecarTmuxName: vi.fn(() => 'tmux'), + killSidecarSession: vi.fn(async () => {}), +})); +vi.mock('@main/core/switch-rooms/auto-session-store', () => ({ + setAutoSessionAgent: vi.fn(async () => {}), +})); +vi.mock('@main/core/switch-rooms/auto-session-watcher', () => ({ + autoSessionWatcher: { stopForAgent: vi.fn() }, +})); +vi.mock('@main/core/switch-servers/gateway-client', () => ({ deleteAgent: vi.fn() })); +vi.mock('@main/core/switch-servers/servers-store', () => ({ getServer: vi.fn() })); +vi.mock('@main/core/view-state/view-state-service', () => ({ + viewStateService: { del: vi.fn(async () => {}) }, +})); +vi.mock('../sessions/session-runtime-manager', () => ({ + sessionRuntimeManager: { teardownSession: vi.fn(async () => {}) }, +})); +vi.mock('@main/lib/logger', () => ({ log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() } })); +vi.mock('@main/db/schema', () => ({ agents: {}, sessions: {} })); +vi.mock('@main/db/client', () => ({ + db: { + select: () => ({ from: () => ({ where: async () => [] }) }), + delete: () => ({ where: async () => undefined }), + }, +})); + +const { deleteAgent } = await import('./deleteAgent'); + +const CREDS = JSON.stringify({ + env: { SWITCH_API_ENDPOINT: 'https://s', SWITCH_API_TOKEN: 'tok-123', SWITCH_AGENT_ID: 'sw-1' }, +}); + +describe('deleteAgent', () => { + beforeEach(() => { + vi.clearAllMocks(); + h.state.repoAgents = { removeLocal: h.removeLocal }; + h.state.agent = { id: 'agent-1', name: 'cc-hoot', providerId: 'claude', locationId: 'loc' }; + }); + + it('removes the per-agent credentials for a provider with no repo-agent definitions', async () => { + // The write is unconditional for every provider, so the teardown must be too: + // a provider without `removeLocal` would otherwise leave a live token on disk + // with no UI path left to revoke it. + h.state.repoAgents = null; + h.state.agent = { id: 'agent-1', name: 'codex-hoot', providerId: 'codex', locationId: 'loc' }; + const fs = fakeFs({ [agentSettingsRelativePath('codex-hoot')]: CREDS }); + h.state.fs = fs; + + await deleteAgent('agent-1', { deleteInSwitch: false }); + + expect(await fs.exists(agentSettingsRelativePath('codex-hoot'))).toBe(false); + }); + + it('removes both the credentials and the definition for a repo-agents provider', async () => { + const fs = fakeFs({ + [agentSettingsRelativePath('cc-hoot')]: CREDS, + '.claude/agents/cc-hoot.md': '# cc-hoot', + }); + h.state.fs = fs; + + await deleteAgent('agent-1', { deleteInSwitch: false }); + + expect(await fs.exists(agentSettingsRelativePath('cc-hoot'))).toBe(false); + expect(await fs.exists('.claude/agents/cc-hoot.md')).toBe(false); + }); + + it('leaves a sibling agent sharing the directory untouched', async () => { + const fs = fakeFs({ + [agentSettingsRelativePath('cc-hoot')]: CREDS, + [agentSettingsRelativePath('cc-sibling')]: CREDS, + }); + h.state.fs = fs; + + await deleteAgent('agent-1', { deleteInSwitch: false }); + + expect(await fs.exists(agentSettingsRelativePath('cc-sibling'))).toBe(true); + }); +}); diff --git a/dash/apps/switchdash-desktop/src/main/core/agents/deleteAgent.ts b/dash/apps/switchdash-desktop/src/main/core/agents/deleteAgent.ts index 7271289f4..5f329c9f0 100644 --- a/dash/apps/switchdash-desktop/src/main/core/agents/deleteAgent.ts +++ b/dash/apps/switchdash-desktop/src/main/core/agents/deleteAgent.ts @@ -22,6 +22,7 @@ import { connectRemoteAgent } from './connect-remote-agent'; import { getAgentById } from './getAgentById'; import { stopRemoteWatcher } from './remote-watcher'; import { removeSwitchCredentials } from './remove-switch-settings'; +import { agentSettingsRelativePath } from './switch-settings-paths'; export type DeleteAgentOptions = { /** @@ -80,6 +81,20 @@ async function removeProvisionedFiles(agent: Agent, location: Location): Promise }); }); } + // The per-agent credentials are written for every provider, so they are + // removed for every provider — a provider without repo-agent definitions has + // no `removeLocal` to carry the token file out with it. + // + // Isolated like `removeLocal` above: each teardown step must run even if an + // earlier one fails, or one unwritable file leaves the rest of the agent's + // credentials behind. + await ctx.fs.delete(agentSettingsRelativePath(agent.name ?? agent.id)).catch((error) => { + log.warn('deleteAgent: failed to remove the per-agent Switch credentials', { + agentId: agent.id, + name: agent.name, + error: String(error), + }); + }); await removeSwitchCredentials(agent.providerId, ctx.fs); } finally { ctx.close(); diff --git a/dash/apps/switchdash-desktop/src/main/core/agents/generate-agent-launch-spec.test.ts b/dash/apps/switchdash-desktop/src/main/core/agents/generate-agent-launch-spec.test.ts index 9e5a288b4..ded1fe209 100644 --- a/dash/apps/switchdash-desktop/src/main/core/agents/generate-agent-launch-spec.test.ts +++ b/dash/apps/switchdash-desktop/src/main/core/agents/generate-agent-launch-spec.test.ts @@ -8,9 +8,16 @@ const launchArgs = vi.fn((dir: string, name: string) => [ `${dir}/.switch/agents/${name}.json`, ]); +const launchArgsForServer = vi.fn((server: { name: string; url?: string }) => [ + '-c', + `mcp_servers.${server.name}.url=${JSON.stringify(server.url)}`, +]); +/** Set per test: whether the mocked provider receives MCP servers on argv. */ +let mcpBehavior: { launchArgsForServer?: typeof launchArgsForServer } | undefined; + vi.mock('@main/core/providers/plugin-registry', () => ({ getPlugin: () => ({ - behavior: { prompt: { buildCommand }, repoAgents: { launchArgs } }, + behavior: { prompt: { buildCommand }, repoAgents: { launchArgs }, mcp: mcpBehavior }, capabilities: { hostDependency: { binaryNames: ['claude'] } }, }), })); @@ -22,6 +29,13 @@ vi.mock('@main/core/settings/provider-settings-service', () => ({ })); vi.mock('@main/core/dependencies/host-dependency-store', () => ({ hostDependencyStore: {} })); +import { buildStandardCommand } from '@switchdash/core/agents/plugins/helpers'; +import { SWITCH_API_ENDPOINT_PLACEHOLDER } from '@shared/core/switch-rooms/switch-mcp-endpoint'; +import { + INITIAL_PROMPT_PLACEHOLDER, + materializeAgentCommand, + SESSION_ID_PLACEHOLDER, +} from '../../../sidecar/agent-launch-spec'; import { generateAgentLaunchSpec } from './generate-agent-launch-spec'; const baseParams = { @@ -37,6 +51,8 @@ describe('generateAgentLaunchSpec', () => { beforeEach(() => { buildCommand.mockClear(); launchArgs.mockClear(); + launchArgsForServer.mockClear(); + mcpBehavior = undefined; }); // The bug (CHOO-1664): autoApprove was hardcoded true, so the remote watcher's @@ -71,4 +87,111 @@ describe('generateAgentLaunchSpec', () => { expect(launchArgs).not.toHaveBeenCalled(); expect(buildCommand).toHaveBeenCalledWith(expect.objectContaining({ agentArgs: [] })); }); + + it('bakes an endpoint placeholder for a provider that takes MCP servers on argv', async () => { + // The endpoint is only known on the VM, so the spec carries a token the + // watcher substitutes per spawn. Without it a remote auto-started Codex + // session gets its token from the sidecar but no `switch` tools at all. + mcpBehavior = { launchArgsForServer }; + + await generateAgentLaunchSpec({ ...baseParams, autoApprove: false }); + + expect(buildCommand).toHaveBeenCalledWith( + expect.objectContaining({ + agentArgs: ['-c', `mcp_servers.switch.url="${SWITCH_API_ENDPOINT_PLACEHOLDER}/mcp/"`], + }) + ); + }); + + it('adds no MCP args for a provider that resolves servers from config', async () => { + await generateAgentLaunchSpec({ ...baseParams, autoApprove: false }); + + expect(launchArgsForServer).not.toHaveBeenCalled(); + expect(buildCommand).toHaveBeenCalledWith(expect.objectContaining({ agentArgs: [] })); + }); +}); + +/** + * The suite above mocks `buildCommand`, so it cannot see provider-specific argv + * constraints. Codex sets `sessionIdOnResumeOnly`: its fresh-session argv carries + * no session-id token at all, and a watcher that requires one rejects every spec + * it produces. These drive the real provider spec instead. + */ +describe('generateAgentLaunchSpec against real provider command builders', () => { + const CODEX_SPEC = { + defaultArgs: ['--dangerously-bypass-hook-trust'], + autoApproveFlag: '-c approval_policy="never" -c sandbox_mode="danger-full-access"', + initialPromptFlag: '', + resumeFlag: 'resume', + sessionIdFlag: ' ', + sessionIdOnResumeOnly: true, + resumeWithoutSessionFlag: 'resume --last', + }; + + /** What `generateAgentLaunchSpec` asks a provider to build. */ + function buildSpecArgs(providerSpec: Parameters[1]): string[] { + return buildStandardCommand( + { + cli: '/usr/bin/agent', + extraArgs: [], + agentArgs: ['-c', `mcp_servers.switch.url="${SWITCH_API_ENDPOINT_PLACEHOLDER}/mcp/"`], + autoApprove: true, + initialPrompt: INITIAL_PROMPT_PLACEHOLDER, + sessionId: SESSION_ID_PLACEHOLDER, + providerSessionId: undefined, + isResuming: false, + model: '', + }, + providerSpec + ).args; + } + + it('produces a Codex spec the watcher can materialize', () => { + const args = buildSpecArgs(CODEX_SPEC); + expect(args).not.toContain(SESSION_ID_PLACEHOLDER); + + const cmd = materializeAgentCommand( + { + command: '/usr/bin/codex', + args, + env: {}, + cwd: '/home/agent/repo', + providerId: 'codex', + deeplinkScheme: 'switchdash', + }, + { + sessionId: 's1', + initialPrompt: 'connect to switch room room-x', + extraEnv: {}, + switchApiEndpoint: 'https://switch.test/api', + } + ); + + expect(cmd.args).toContain('mcp_servers.switch.url="https://switch.test/api/mcp/"'); + expect(cmd.args).toContain('connect to switch room room-x'); + }); + + it('still carries the session id for a provider that takes one when fresh', () => { + const args = buildSpecArgs({ initialPromptFlag: '', sessionIdFlag: '--session-id' }); + expect(args).toContain(SESSION_ID_PLACEHOLDER); + + const cmd = materializeAgentCommand( + { + command: '/usr/bin/claude', + args, + env: {}, + cwd: '/home/agent/repo', + providerId: 'claude', + deeplinkScheme: 'switchdash', + }, + { + sessionId: 's1', + initialPrompt: 'p', + extraEnv: {}, + switchApiEndpoint: 'https://switch.test/api', + } + ); + + expect(cmd.args).toContain('s1'); + }); }); diff --git a/dash/apps/switchdash-desktop/src/main/core/agents/generate-agent-launch-spec.ts b/dash/apps/switchdash-desktop/src/main/core/agents/generate-agent-launch-spec.ts index c1d8f5efd..6bac34432 100644 --- a/dash/apps/switchdash-desktop/src/main/core/agents/generate-agent-launch-spec.ts +++ b/dash/apps/switchdash-desktop/src/main/core/agents/generate-agent-launch-spec.ts @@ -1,8 +1,10 @@ import { resolveAgentExecutable } from '@main/core/agent-runtime/impl/resolve-agent-executable'; +import { switchMcpLaunchArgs } from '@main/core/agent-runtime/switch-mcp-launch-args'; import { hostDependencyStore } from '@main/core/dependencies/host-dependency-store'; import type { IExecutionContext } from '@main/core/execution-context/types'; import { getPlugin } from '@main/core/providers/plugin-registry'; import { providerOverrideSettings } from '@main/core/settings/provider-settings-service'; +import { SWITCH_API_ENDPOINT_PLACEHOLDER } from '@shared/core/switch-rooms/switch-mcp-endpoint'; import { type AgentLaunchSpec, INITIAL_PROMPT_PLACEHOLDER, @@ -64,8 +66,13 @@ export async function generateAgentLaunchSpec(params: { cli, extraArgs: parseExtraArgs(providerConfig?.extraArgs), // The provider owns how to run as the named agent (CHOO-1440); kept distinct - // from user extra args. - agentArgs: agentName && repoAgents ? repoAgents.launchArgs(remoteRepoDir, agentName) : [], + // from user extra args. A provider that receives its Switch MCP server on + // argv gets the endpoint as a placeholder the watcher resolves per spawn, + // since the endpoint is only known on the VM. + agentArgs: [ + ...(agentName && repoAgents ? repoAgents.launchArgs(remoteRepoDir, agentName) : []), + ...switchMcpLaunchArgs(plugin, SWITCH_API_ENDPOINT_PLACEHOLDER), + ], autoApprove, initialPrompt: INITIAL_PROMPT_PLACEHOLDER, sessionId: SESSION_ID_PLACEHOLDER, diff --git a/dash/apps/switchdash-desktop/src/main/core/agents/known-agent-type.test.ts b/dash/apps/switchdash-desktop/src/main/core/agents/known-agent-type.test.ts new file mode 100644 index 000000000..42327a65d --- /dev/null +++ b/dash/apps/switchdash-desktop/src/main/core/agents/known-agent-type.test.ts @@ -0,0 +1,36 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { log } from '@main/lib/logger'; +import { knownAgentTypeForProvider } from './known-agent-type'; + +vi.mock('@main/lib/logger', () => ({ + log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +describe('knownAgentTypeForProvider', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('maps codex to its own gateway known-agent type', () => { + expect(knownAgentTypeForProvider('codex')).toBe('codex'); + expect(log.warn).not.toHaveBeenCalled(); + }); + + it('maps claude to claude-code', () => { + expect(knownAgentTypeForProvider('claude')).toBe('claude-code'); + expect(log.warn).not.toHaveBeenCalled(); + }); + + it('warns when a provider has no gateway known-agent type, then falls back visibly', () => { + // Only claude-code and codex exist server-side, so anything else registers + // as a type it is not. That is a disclosed fallback, never a silent one. + for (const id of ['grok', 'gemini', 'cursor', 'droid'] as const) { + vi.clearAllMocks(); + expect(knownAgentTypeForProvider(id)).toBe('claude-code'); + expect(log.warn).toHaveBeenCalledWith( + expect.stringContaining('no gateway known-agent type'), + expect.objectContaining({ providerId: id, registeringAs: 'claude-code' }) + ); + } + }); +}); diff --git a/dash/apps/switchdash-desktop/src/main/core/agents/known-agent-type.ts b/dash/apps/switchdash-desktop/src/main/core/agents/known-agent-type.ts new file mode 100644 index 000000000..71c972316 --- /dev/null +++ b/dash/apps/switchdash-desktop/src/main/core/agents/known-agent-type.ts @@ -0,0 +1,34 @@ +import { log } from '@main/lib/logger'; +import type { AgentProviderId } from '@shared/core/providers/agent-provider-registry'; + +/** + * Gateway known-agent types that exist server-side (`KNOWN_AGENTS` in + * `switch_core/gateway/known_agents.py`). Only these two are real. + */ +const KNOWN_AGENT_TYPE_BY_PROVIDER: Partial> = { + claude: 'claude-code', + codex: 'codex', +}; + +const FALLBACK_KNOWN_AGENT_TYPE = 'claude-code'; + +/** + * Map a switchdash provider to the gateway known-agent type it registers as. + * + * A provider outside the two server-side types has no faithful representation. + * It still registers as `claude-code` — the generic switchdash-managed shape — + * because switchdash drives the session itself and the type mainly determines + * the connector label and the hand-onboarding command an operator is shown. That + * mismatch is real (an operator onboarding a Gemini agent by hand is told to run + * `claude`), so it is warned about rather than passed over silently. + */ +export function knownAgentTypeForProvider(providerId: AgentProviderId): string { + const known = KNOWN_AGENT_TYPE_BY_PROVIDER[providerId]; + if (known) return known; + + log.warn( + 'knownAgentTypeForProvider: provider has no gateway known-agent type; registering as the generic switchdash-managed shape', + { providerId, registeringAs: FALLBACK_KNOWN_AGENT_TYPE } + ); + return FALLBACK_KNOWN_AGENT_TYPE; +} diff --git a/dash/apps/switchdash-desktop/src/main/core/agents/migrate-agent-storage.test.ts b/dash/apps/switchdash-desktop/src/main/core/agents/migrate-agent-storage.test.ts index 9b907f356..09e35036f 100644 --- a/dash/apps/switchdash-desktop/src/main/core/agents/migrate-agent-storage.test.ts +++ b/dash/apps/switchdash-desktop/src/main/core/agents/migrate-agent-storage.test.ts @@ -41,52 +41,52 @@ function credsJson(agentId: string): string { // Shared mock state + spies. Hoisted so the vi.mock factories (which are lifted // above imports) can reference them. `agents`/`workspace` are set per test. +// `repoAgents` is the behavior `getPlugin` returns — set it to null in a test to +// simulate a provider without repo-agent definitions (e.g. Codex). const h = vi.hoisted(() => { - const state: { agents: Array>; workspace: PluginFs | null } = { + const readLaunchEnv = vi.fn(async (fs: PluginFs, name: string) => { + const raw = + (await fs.read(`.switch/agents/${name}.json`)) ?? + (await fs.read(`.claude/switch-subagents/${name}.settings.json`)); + return raw ? ((JSON.parse(raw).env ?? {}) as Record) : {}; + }); + const readDefinition = vi.fn((fs: PluginFs, name: string) => + fs.read(`.claude/agents/${name}.md`).then((c) => (c === null ? null : { name })) + ); + const writeDefinition = vi.fn((fs: PluginFs, attrs: { name: string }) => + fs.write(`.claude/agents/${attrs.name}.md`, `# ${attrs.name}`) + ); + const discoverLocal = vi.fn(async () => []); + const defaultRepoAgents = { + readLaunchEnv, + readDefinition, + writeDefinition, + discoverLocal, + }; + const state: { + agents: Array>; + workspace: PluginFs | null; + repoAgents: object | null; + } = { agents: [], workspace: null, + repoAgents: defaultRepoAgents, }; return { state, - writeCredentials: vi.fn((fs: PluginFs, creds: { agentName: string }) => - fs.write( - `.switch/agents/${creds.agentName}.json`, - JSON.stringify({ - env: { SWITCH_API_ENDPOINT: 'x', SWITCH_API_TOKEN: 'x', SWITCH_AGENT_ID: 'x' }, - }) - ) - ), - readLaunchEnv: vi.fn(async (fs: PluginFs, name: string) => { - const raw = - (await fs.read(`.switch/agents/${name}.json`)) ?? - (await fs.read(`.claude/switch-subagents/${name}.settings.json`)); - return raw ? ((JSON.parse(raw).env ?? {}) as Record) : {}; - }), - readDefinition: vi.fn((fs: PluginFs, name: string) => - fs.read(`.claude/agents/${name}.md`).then((c) => (c === null ? null : { name })) - ), - writeDefinition: vi.fn((fs: PluginFs, attrs: { name: string }) => - fs.write(`.claude/agents/${attrs.name}.md`, `# ${attrs.name}`) - ), - discoverLocal: vi.fn(async () => []), + defaultRepoAgents, + readLaunchEnv, + readDefinition, + writeDefinition, + discoverLocal, updateAgent: vi.fn(async () => undefined), - isComplete: vi.fn(async () => false), + completedGeneration: vi.fn(async () => 0), markComplete: vi.fn(async () => undefined), }; }); vi.mock('@main/core/providers/plugin-registry', () => ({ - getPlugin: () => ({ - behavior: { - repoAgents: { - writeCredentials: h.writeCredentials, - readLaunchEnv: h.readLaunchEnv, - readDefinition: h.readDefinition, - writeDefinition: h.writeDefinition, - discoverLocal: h.discoverLocal, - }, - }, - }), + getPlugin: () => ({ behavior: { repoAgents: h.state.repoAgents } }), })); vi.mock('@main/core/locations/store', () => ({ getLocationById: vi.fn(async () => ({ id: 'loc', sshHost: null, dir: '/repo' })), @@ -104,7 +104,8 @@ vi.mock('@main/core/switch-servers/gateway-client', () => ({ fetchAgentDetail: v vi.mock('@main/core/switch-servers/servers-store', () => ({ getServer: vi.fn() })); vi.mock('@main/lib/logger', () => ({ log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() } })); vi.mock('./agent-storage-migration-marker', () => ({ - isAgentStorageMigrationComplete: h.isComplete, + AGENT_STORAGE_MIGRATION_GENERATION: 2, + completedAgentStorageMigrationGeneration: h.completedGeneration, markAgentStorageMigrationComplete: h.markComplete, })); @@ -124,6 +125,7 @@ describe('migrateAgentStorage', () => { beforeEach(() => { vi.clearAllMocks(); h.state.agents = [{ ...baseAgent }]; + h.state.repoAgents = h.defaultRepoAgents; }); it('recovers creds from a stale id-keyed neutral file, writes the name-keyed file, and removes the stale one', async () => { @@ -135,12 +137,33 @@ describe('migrateAgentStorage', () => { await migrateAgentStorage(); - expect(h.writeCredentials).toHaveBeenCalledWith( - ws, - expect.objectContaining({ agentName: 'cc-hoot-main', apiToken: 'tok-123', agentId: 'sw-1' }) - ); expect(await ws.exists('.switch/agents/cc-hoot-main.json')).toBe(true); + const written = JSON.parse((await ws.read('.switch/agents/cc-hoot-main.json')) as string); + expect(written.env.SWITCH_API_TOKEN).toBe('tok-123'); + expect(written.env.SWITCH_AGENT_ID).toBe('sw-1'); + expect(await ws.exists('.switch/agents/agent-id-1.json')).toBe(false); + }); + + it('migrates a provider without repo-agents (Codex): id-keyed creds → name-keyed, id file removed, no definition written', async () => { + // Codex has no repoAgents behavior; the pre-rework scheme keyed its neutral + // creds file by agent id. The migration must still collapse it onto the + // name-keyed key-space, or the agent silently loses its (unrecoverable) token. + h.state.agents = [{ ...baseAgent, providerId: 'codex', name: 'codex-hoot' }]; + h.state.repoAgents = null; + const ws = fakeFs({ '.switch/agents/agent-id-1.json': credsJson('sw-1') }); + h.state.workspace = ws; + + await migrateAgentStorage(); + + // Name-keyed file written via the unconditional neutral writer (not the + // behavior hook, which does not exist for Codex), token preserved. + expect(await ws.exists('.switch/agents/codex-hoot.json')).toBe(true); + const written = JSON.parse((await ws.read('.switch/agents/codex-hoot.json')) as string); + expect(written.env.SWITCH_API_TOKEN).toBe('tok-123'); + expect(written.env.SWITCH_AGENT_ID).toBe('sw-1'); + // Stale id-keyed file removed; no definition written (no behavior). expect(await ws.exists('.switch/agents/agent-id-1.json')).toBe(false); + expect(h.writeDefinition).not.toHaveBeenCalled(); }); it('falls back to .claude/settings.local.json when no neutral file exists', async () => { @@ -152,23 +175,27 @@ describe('migrateAgentStorage', () => { await migrateAgentStorage(); - expect(h.writeCredentials).toHaveBeenCalledWith( - ws, - expect.objectContaining({ agentName: 'cc-hoot-main', agentId: 'sw-1' }) - ); + const written = JSON.parse((await ws.read('.switch/agents/cc-hoot-main.json')) as string); + expect(written.env.SWITCH_AGENT_ID).toBe('sw-1'); }); it('does nothing when the name-keyed file already exists and the definition is present', async () => { - h.state.workspace = fakeFs({ + // Observe the files themselves, not spies: the credential step goes through + // the real `writeNeutralAgentSettingsFs`, so a spy on the behavior hook + // cannot see it re-derive and rewrite the token on every boot. + const seed = { '.switch/agents/cc-hoot-main.json': credsJson('sw-1'), '.claude/agents/cc-hoot-main.md': '# def', - }); + }; + const ws = fakeFs({ ...seed }); + h.state.workspace = ws; await migrateAgentStorage(); - expect(h.writeCredentials).not.toHaveBeenCalled(); + for (const [path, content] of Object.entries(seed)) { + expect(await ws.read(path)).toBe(content); + } expect(h.writeDefinition).not.toHaveBeenCalled(); - expect(h.updateAgent).not.toHaveBeenCalled(); }); it('writes no credentials when none exist anywhere (unrecoverable token)', async () => { @@ -177,21 +204,44 @@ describe('migrateAgentStorage', () => { await migrateAgentStorage(); - expect(h.writeCredentials).not.toHaveBeenCalled(); expect(await ws.exists('.switch/agents/cc-hoot-main.json')).toBe(false); }); - it('skips the whole pass (no workspace opened) once the marker is set', async () => { - h.isComplete.mockResolvedValueOnce(true); + it('skips the whole pass (no workspace opened) once the current generation is latched', async () => { + h.completedGeneration.mockResolvedValueOnce(2); const resolveWorkspaceFsFor = (await import('./agent-workspace-fs')).resolveWorkspaceFsFor; await migrateAgentStorage(); expect(resolveWorkspaceFsFor).not.toHaveBeenCalled(); - expect(h.writeCredentials).not.toHaveBeenCalled(); expect(h.markComplete).not.toHaveBeenCalled(); }); + it('re-running for generation 2 opens no workspace for a provider generation 1 already did', async () => { + // Generation 2 only broadened the credential step to providers WITHOUT a + // behavior, so re-opening a Claude agent's workspace — an SSH connect and an + // SFTP channel for a remote one — could not change anything. + h.completedGeneration.mockResolvedValueOnce(1); + const resolveWorkspaceFsFor = (await import('./agent-workspace-fs')).resolveWorkspaceFsFor; + + await migrateAgentStorage(); + + expect(resolveWorkspaceFsFor).not.toHaveBeenCalled(); + expect(h.markComplete).toHaveBeenCalledTimes(1); + }); + + it('re-running for generation 2 still migrates a provider generation 1 skipped', async () => { + h.completedGeneration.mockResolvedValueOnce(1); + h.state.agents = [{ ...baseAgent, providerId: 'codex', name: 'codex-hoot' }]; + h.state.repoAgents = null; + const ws = fakeFs({ '.switch/agents/agent-id-1.json': credsJson('sw-1') }); + h.state.workspace = ws; + + await migrateAgentStorage(); + + expect(await ws.exists('.switch/agents/codex-hoot.json')).toBe(true); + }); + it('latches the marker after a clean pass', async () => { h.state.workspace = fakeFs({ '.switch/agents/cc-hoot-main.json': credsJson('sw-1'), diff --git a/dash/apps/switchdash-desktop/src/main/core/agents/migrate-agent-storage.ts b/dash/apps/switchdash-desktop/src/main/core/agents/migrate-agent-storage.ts index 7d2c588c2..0f580d454 100644 --- a/dash/apps/switchdash-desktop/src/main/core/agents/migrate-agent-storage.ts +++ b/dash/apps/switchdash-desktop/src/main/core/agents/migrate-agent-storage.ts @@ -4,12 +4,14 @@ import { parseSwitchAgentCredentials } from '@main/core/switch-rooms/switch-cred import { log } from '@main/lib/logger'; import type { Agent } from '@shared/core/agents/agents'; import { - isAgentStorageMigrationComplete, + AGENT_STORAGE_MIGRATION_GENERATION, + completedAgentStorageMigrationGeneration, markAgentStorageMigrationComplete, } from './agent-storage-migration-marker'; import { resolveWorkspaceFsFor } from './agent-workspace-fs'; import { getAgents } from './getAgents'; import { agentSettingsRelativePath, SWITCH_SETTINGS_RELATIVE_PATH } from './switch-settings-paths'; +import { writeNeutralAgentSettingsFs } from './write-switch-settings'; /** * Migrate existing switchdash-managed agents to the current storage/definition @@ -29,16 +31,15 @@ export async function migrateAgentStorage(): Promise { // Once a full pass has migrated every agent, never re-run: the steady-state // migration re-opens each agent's workspace filesystem (an SSH/SFTP round trip // per remote agent) on every boot for no benefit. - if (await isAgentStorageMigrationComplete()) return; + const completed = await completedAgentStorageMigrationGeneration(); + if (completed >= AGENT_STORAGE_MIGRATION_GENERATION) return; const agents = await getAgents(); let migrated = 0; let allComplete = true; for (const agent of agents) { try { - const result = await migrateOne(agent); - if (result.changed) migrated += 1; - if (!result.complete) allComplete = false; + if (await migrateOne(agent, completed)) migrated += 1; } catch (error) { allComplete = false; log.warn('migrateAgentStorage: failed to migrate agent', { @@ -56,20 +57,29 @@ export async function migrateAgentStorage(): Promise { if (allComplete) await markAgentStorageMigrationComplete(); } -interface MigrateResult { - /** Whether this pass wrote anything for the agent. */ - changed: boolean; - /** Whether the agent is now fully in the new layout (nothing left to retry). */ - complete: boolean; -} - -/** Migrate one agent (local or remote). */ -async function migrateOne(agent: Agent): Promise { +/** + * Migrate one agent (local or remote). Returns whether anything was written. + * + * `completedGeneration` is the generation this install already finished, so a + * re-run can skip agents the new generation cannot change — the check happens + * before the workspace filesystem is opened, which for a remote agent is an SSH + * connect and an SFTP channel. + */ +async function migrateOne(agent: Agent, completedGeneration: number): Promise { + // A provider may have no repo-agent behavior (e.g. Codex): it has no on-disk + // definition and no `readLaunchEnv` hook, but its provider-neutral credentials + // still need collapsing onto the one name-keyed key-space. So the credential + // migration runs for every provider; only the definition step (2) is + // behavior-gated. const behavior = getPlugin(agent.providerId).behavior.repoAgents; - if (!behavior) return { changed: false, complete: true }; + + // Generation 2 only broadened step 1 to providers WITHOUT a behavior; for one + // that has it, every step is what generation 1 already ran. Skipping here is + // what keeps the generation bump from re-opening a workspace per Claude agent. + if (completedGeneration >= 1 && behavior) return false; const location = await getLocationById(agent.locationId); - if (!location) return { changed: false, complete: true }; + if (!location) return false; const workspace = await resolveWorkspaceFsFor(location.sshHost, location.dir); try { @@ -84,9 +94,11 @@ async function migrateOne(agent: Agent): Promise { // 1. Credentials: if the name-keyed neutral file is absent, adopt whatever // complete credentials already exist on disk, in priority order: // a. a stale ID-keyed neutral file `.switch/agents/.json` — an - // earlier layout keyed the neutral file by agent id, not name; + // earlier layout keyed the neutral file by agent id, not name (this + // includes Codex agents added on the pre-rework id-keyed scheme); // b. the legacy per-agent file (via readLaunchEnv: name-keyed neutral - // then `.claude/switch-subagents/.settings.json`); + // then `.claude/switch-subagents/.settings.json`) — behavior + // providers only; // c. the shared `.claude/settings.local.json` (legacy "main" agent). // The token is minted once and lives only on disk, so this is the only way // to recover it — nothing can reconstruct it from the gateway. @@ -95,15 +107,12 @@ async function migrateOne(agent: Agent): Promise { const neutral = name === agent.id ? null : await workspace.fs.read(namedRelPath); if (neutral === null) { const creds = - parseSwitchAgentCredentials((await workspace.fs.read(idKeyedRelPath)) ?? '', log) ?? - toCreds(await behavior.readLaunchEnv(workspace.fs, name)) ?? - parseSwitchAgentCredentials( - (await workspace.fs.read(SWITCH_SETTINGS_RELATIVE_PATH)) ?? '', - log - ); + parseSwitchAgentCredentials(await workspace.fs.read(idKeyedRelPath), log) ?? + (behavior ? toCreds(await behavior.readLaunchEnv(workspace.fs, name)) : null) ?? + parseSwitchAgentCredentials(await workspace.fs.read(SWITCH_SETTINGS_RELATIVE_PATH), log); if (creds) { - await behavior.writeCredentials(workspace.fs, { - agentName: name, + await writeNeutralAgentSettingsFs(workspace.fs, { + slug: name, apiEndpoint: creds.apiEndpoint, apiToken: creds.token, agentId: creds.agentId, @@ -123,13 +132,14 @@ async function migrateOne(agent: Agent): Promise { } // 2. Definition: ensure the provider has an on-disk definition for this agent - // so it runs as a named repository-defined agent. - if ((await behavior.readDefinition(workspace.fs, name)) === null) { + // so it runs as a named repository-defined agent. Behavior providers only — + // a provider without definitions (Codex) has nothing to write here. + if (behavior && (await behavior.readDefinition(workspace.fs, name)) === null) { await behavior.writeDefinition(workspace.fs, { name, description }); changed = true; } - return { changed, complete: true }; + return changed; } finally { workspace.close(); } diff --git a/dash/apps/switchdash-desktop/src/main/core/agents/onboard-agent.ts b/dash/apps/switchdash-desktop/src/main/core/agents/onboard-agent.ts index bccf9fb69..24857d790 100644 --- a/dash/apps/switchdash-desktop/src/main/core/agents/onboard-agent.ts +++ b/dash/apps/switchdash-desktop/src/main/core/agents/onboard-agent.ts @@ -108,7 +108,7 @@ export async function onboardAgent(params: OnboardAgentParams): Promise.json`), the authoritative identity switchdash - // injects at launch so agents sharing a location don't collide on the single - // `.claude/settings.local.json` identity (CHOO-1440). Local agents only — a - // remote agent's dir lives on its VM. Best-effort: the launch/poller fall back - // to `settings.local.json`, so a failure here does not break onboarding. + // (`.switch/agents/.json`), the authoritative identity switchdash injects + // at launch so agents sharing a location don't collide on the single + // `.claude/settings.local.json` identity (CHOO-1440). Keyed by `name` — the one + // key-space every reader uses. Local agents only — a remote agent's dir lives on + // its VM. Best-effort: the launch/poller fall back to `settings.local.json`, so + // a failure here does not break onboarding. if (sshHost === null) { const creds = await readSwitchAgentCredentials(params.dir, log); if (creds) { await writeAgentNeutralSettings({ dir: params.dir, - slug: agent.id, + slug: agent.name, apiEndpoint: creds.apiEndpoint, apiToken: creds.token, agentId: creds.agentId, diff --git a/dash/apps/switchdash-desktop/src/main/core/agents/onboard-location-agents.ts b/dash/apps/switchdash-desktop/src/main/core/agents/onboard-location-agents.ts index 7e97cdc62..921df3867 100644 --- a/dash/apps/switchdash-desktop/src/main/core/agents/onboard-location-agents.ts +++ b/dash/apps/switchdash-desktop/src/main/core/agents/onboard-location-agents.ts @@ -1,6 +1,7 @@ import { randomUUID } from 'node:crypto'; import { err, ok } from '@switchdash/shared'; import type { Result } from '@switchdash/shared'; +import { knownAgentTypeForProvider } from '@main/core/agents/known-agent-type'; import { locationManager } from '@main/core/locations/location-manager'; import { checkIsValidDirectory } from '@main/core/locations/path-utils'; import { ensureLocation } from '@main/core/locations/store'; @@ -19,6 +20,7 @@ import { createAgent } from './createAgent'; import { getAgents } from './getAgents'; import { registerAgentIdentity } from './register-agent-identity'; import { reconcileAgentAutoSessionFromGateway } from './setAgentAutoSession'; +import { writeNeutralAgentSettingsFs } from './write-switch-settings'; export type OnboardLocationParams = { sshHost: string | null; @@ -114,6 +116,9 @@ async function resolveIdentity( description: description ?? `Claude Code agent ${name}`, repoDir: ctx.dir, autoSession: true, + // This path onboards `.claude/agents/*.md` definitions, so the identity is a + // Claude Code one by construction. + agentType: knownAgentTypeForProvider('claude'), }); if (registered.kind !== 'created') { const message = 'message' in registered ? registered.message : ''; @@ -123,8 +128,8 @@ async function resolveIdentity( }; } - await ctx.behavior.writeCredentials(ctx.workspace.fs, { - agentName: name, + await writeNeutralAgentSettingsFs(ctx.workspace.fs, { + slug: name, apiEndpoint: ctx.server.apiUrl, apiToken: registered.apiKey, agentId: registered.id, diff --git a/dash/apps/switchdash-desktop/src/main/core/agents/propagate-server-api-url.ts b/dash/apps/switchdash-desktop/src/main/core/agents/propagate-server-api-url.ts index 3fe2df075..31ae7c8a9 100644 --- a/dash/apps/switchdash-desktop/src/main/core/agents/propagate-server-api-url.ts +++ b/dash/apps/switchdash-desktop/src/main/core/agents/propagate-server-api-url.ts @@ -16,11 +16,6 @@ import { updateAgent } from './updateAgent'; import { mapAgentRowToAgent } from './utils'; import { mergeSwitchApiEndpoint } from './write-switch-settings'; -// Remote hosts are POSIX; use a forward-slash literal rather than the -// platform-dependent SWITCH_SETTINGS_RELATIVE_PATH (which emits backslashes when -// switchdash runs on Windows). Matches write-remote-switch-settings.ts. -const REMOTE_SETTINGS_PATH = '.claude/settings.local.json'; - /** * Rewrite one local agent's `SWITCH_API_ENDPOINT`, preserving its token and * every other key. Returns whether the file was updated (false = not a @@ -59,7 +54,7 @@ async function propagateRemote( try { let existingRaw: string | null = null; try { - ({ content: existingRaw } = await fs.read(REMOTE_SETTINGS_PATH)); + ({ content: existingRaw } = await fs.read(SWITCH_SETTINGS_RELATIVE_PATH)); } catch (error) { // Absent file -> unprovisioned agent. A transport failure (dead SSH // connection) must propagate rather than look like "no config". @@ -70,7 +65,7 @@ async function propagateRemote( const merged = mergeSwitchApiEndpoint(existingRaw, apiEndpoint); if (merged === null) return false; - const result = await fs.write(REMOTE_SETTINGS_PATH, merged); + const result = await fs.write(SWITCH_SETTINGS_RELATIVE_PATH, merged); if (!result.success) { throw new Error(`failed to write remote Switch settings: ${result.error ?? 'unknown error'}`); } diff --git a/dash/apps/switchdash-desktop/src/main/core/agents/register-agent-identity.ts b/dash/apps/switchdash-desktop/src/main/core/agents/register-agent-identity.ts index e90d8c896..610dc140d 100644 --- a/dash/apps/switchdash-desktop/src/main/core/agents/register-agent-identity.ts +++ b/dash/apps/switchdash-desktop/src/main/core/agents/register-agent-identity.ts @@ -7,6 +7,10 @@ export type RegisterAgentInput = { description: string; repoDir: string; autoSession?: boolean; + /** Gateway known-agent type; derive from the provider via + * `knownAgentTypeForProvider`. Required — an omitted type would silently + * register the agent as Claude Code whatever it actually runs (CHOO-1436). */ + agentType: string; }; /** @@ -34,6 +38,7 @@ export async function registerAgentIdentity( const registered = await registerKnownAgent(server, { name: input.name, description: input.description, + agentType: input.agentType, options: { channels_enabled: true, repo_dir: input.repoDir, diff --git a/dash/apps/switchdash-desktop/src/main/core/agents/remove-switch-settings.ts b/dash/apps/switchdash-desktop/src/main/core/agents/remove-switch-settings.ts index 233aa9409..a88931bcb 100644 --- a/dash/apps/switchdash-desktop/src/main/core/agents/remove-switch-settings.ts +++ b/dash/apps/switchdash-desktop/src/main/core/agents/remove-switch-settings.ts @@ -1,13 +1,8 @@ import type { PluginFs } from '@switchdash/core/agents/plugins'; import { getPlugin } from '@main/core/providers/plugin-registry'; +import { SWITCH_SETTINGS_RELATIVE_PATH } from './switch-settings-paths'; import { removeSwitchSettings } from './write-switch-settings'; -// Rooted at the agent's working dir and used against both local and remote -// (SFTP) PluginFs, so use a forward-slash literal rather than the -// platform-dependent SWITCH_SETTINGS_RELATIVE_PATH (which emits backslashes on -// Windows and would not resolve on a POSIX host). -const SWITCH_SETTINGS_POSIX_PATH = '.claude/settings.local.json'; - /** * Reverse the default `.claude/settings.local.json` provisioning: strip the * `SWITCH_*` env block and connector allow-rules, deleting the file if it was @@ -16,14 +11,14 @@ const SWITCH_SETTINGS_POSIX_PATH = '.claude/settings.local.json'; * directory and a remote SSH host. */ async function removeDefaultSwitchCredentials(fs: PluginFs): Promise { - const existing = await fs.read(SWITCH_SETTINGS_POSIX_PATH); + const existing = await fs.read(SWITCH_SETTINGS_RELATIVE_PATH); const result = removeSwitchSettings(existing); if (result.kind === 'skip') return; if (result.kind === 'delete') { - await fs.delete(SWITCH_SETTINGS_POSIX_PATH); + await fs.delete(SWITCH_SETTINGS_RELATIVE_PATH); return; } - await fs.write(SWITCH_SETTINGS_POSIX_PATH, result.content); + await fs.write(SWITCH_SETTINGS_RELATIVE_PATH, result.content); } /** diff --git a/dash/apps/switchdash-desktop/src/main/core/agents/renameAgent.test.ts b/dash/apps/switchdash-desktop/src/main/core/agents/renameAgent.test.ts new file mode 100644 index 000000000..d8b14b39e --- /dev/null +++ b/dash/apps/switchdash-desktop/src/main/core/agents/renameAgent.test.ts @@ -0,0 +1,208 @@ +import type { PluginFs } from '@switchdash/core/agents/plugins'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { agentSettingsRelativePath } from './switch-settings-paths'; + +function fakeFs(seed: Record = {}): PluginFs { + const files = new Map(Object.entries(seed)); + return { + read: async (p) => files.get(p) ?? null, + write: async (p, c) => void files.set(p, c), + delete: async (p) => void files.delete(p), + exists: async (p) => files.has(p), + list: async () => [...files.keys()], + }; +} + +const h = vi.hoisted(() => { + const readDefinition = vi.fn(async (fs: PluginFs, name: string) => + (await fs.read(`.claude/agents/${name}.md`)) === null ? null : { name, description: 'd' } + ); + const writeDefinition = vi.fn(async (fs: PluginFs, attrs: { name: string }) => { + await fs.write(`.claude/agents/${attrs.name}.md`, `# ${attrs.name}`); + }); + const removeLocal = vi.fn(async (fs: PluginFs, name: string) => { + await fs.delete(`.claude/agents/${name}.md`); + }); + const state: { + row: Record | undefined; + fs: PluginFs; + repoAgents: object | null; + nameTaken: boolean; + } = { + row: undefined, + fs: fakeFs(), + repoAgents: { readDefinition, writeDefinition, removeLocal }, + nameTaken: false, + }; + return { state, readDefinition, writeDefinition, removeLocal }; +}); + +vi.mock('@main/core/providers/plugin-registry', () => ({ + getPlugin: () => ({ behavior: { repoAgents: h.state.repoAgents } }), +})); +vi.mock('./agent-location', () => ({ + getAgentLocation: vi.fn(async () => ({ sshHost: null, dir: '/repo' })), + getRemoteAgentLocation: vi.fn(async () => null), +})); +vi.mock('./agent-workspace-fs', () => ({ + resolveWorkspaceFsFor: vi.fn(async () => ({ fs: h.state.fs, close: vi.fn() })), +})); +vi.mock('./getAgentById', () => ({ + getAgentById: vi.fn(async () => ({ + id: 'agent-1', + name: 'old-name', + providerId: 'claude', + locationId: 'loc-1', + })), +})); +vi.mock('./agent-name-taken', () => ({ + agentNameTaken: vi.fn(async () => h.state.nameTaken), +})); +vi.mock('./connect-remote-agent', () => ({ connectRemoteAgent: vi.fn() })); +vi.mock('./remote-watcher', () => ({ ensureRemoteWatcher: vi.fn(async () => {}) })); +vi.mock('@main/core/agent-runtime/impl/remote-sidecar-launcher', () => ({ + agentSidecarTmuxName: vi.fn(() => 'tmux'), + killSidecarSession: vi.fn(async () => {}), +})); +vi.mock('@main/lib/logger', () => ({ log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() } })); +vi.mock('@main/db/client', () => ({ + db: { + update: () => ({ + set: () => ({ where: () => ({ returning: async () => (h.state.row ? [h.state.row] : []) }) }), + }), + }, +})); +vi.mock('@main/db/schema', () => ({ agents: {} })); +vi.mock('./utils', () => ({ + mapAgentRowToAgent: (row: Record) => row, +})); + +const { renameAgent } = await import('./renameAgent'); + +const CREDS = JSON.stringify({ + env: { SWITCH_API_ENDPOINT: 'https://s', SWITCH_API_TOKEN: 'tok-123', SWITCH_AGENT_ID: 'sw-1' }, +}); + +describe('renameAgent', () => { + beforeEach(() => { + vi.clearAllMocks(); + h.state.nameTaken = false; + h.state.row = { id: 'agent-1', name: 'new-name', providerId: 'claude' }; + h.state.repoAgents = { + readDefinition: h.readDefinition, + writeDefinition: h.writeDefinition, + removeLocal: h.removeLocal, + }; + }); + + it('moves the credentials onto the new name so the minted token is not lost', async () => { + // The token is minted once and lives only in this file; every reader resolves + // it from the agent's current name. + const fs = fakeFs({ [agentSettingsRelativePath('old-name')]: CREDS }); + h.state.fs = fs; + + await renameAgent({ agentId: 'agent-1', newName: 'new-name' }); + + expect(await fs.read(agentSettingsRelativePath('new-name'))).toBe(CREDS); + expect(await fs.exists(agentSettingsRelativePath('old-name'))).toBe(false); + }); + + it('moves the credentials for a provider with no repo-agent definitions', async () => { + h.state.repoAgents = null; + const fs = fakeFs({ [agentSettingsRelativePath('old-name')]: CREDS }); + h.state.fs = fs; + + await renameAgent({ agentId: 'agent-1', newName: 'new-name' }); + + expect(await fs.read(agentSettingsRelativePath('new-name'))).toBe(CREDS); + expect(await fs.exists(agentSettingsRelativePath('old-name'))).toBe(false); + }); + + it('moves the definition too, so the CLI can still launch as --agent ', async () => { + const fs = fakeFs({ + [agentSettingsRelativePath('old-name')]: CREDS, + '.claude/agents/old-name.md': '# old-name', + }); + h.state.fs = fs; + + await renameAgent({ agentId: 'agent-1', newName: 'new-name' }); + + expect(await fs.exists('.claude/agents/new-name.md')).toBe(true); + expect(await fs.exists('.claude/agents/old-name.md')).toBe(false); + }); + + it('writes the new files before removing the old ones', async () => { + const order: string[] = []; + const fs = fakeFs({ + [agentSettingsRelativePath('old-name')]: CREDS, + '.claude/agents/old-name.md': '# old-name', + }); + const write = fs.write.bind(fs); + const del = fs.delete.bind(fs); + fs.write = async (p, c) => { + order.push(`write ${p}`); + return write(p, c); + }; + fs.delete = async (p) => { + order.push(`delete ${p}`); + return del(p); + }; + h.state.fs = fs; + + await renameAgent({ agentId: 'agent-1', newName: 'new-name' }); + + // An interruption must leave a recoverable duplicate, never nothing. + const firstDelete = order.findIndex((o) => o.startsWith('delete')); + const lastWrite = order.map((o) => o.startsWith('write')).lastIndexOf(true); + expect(firstDelete).toBeGreaterThan(lastWrite); + }); + + it('does not touch the filesystem when the name is unchanged', async () => { + h.state.row = { id: 'agent-1', name: 'old-name', providerId: 'claude' }; + const fs = fakeFs({ [agentSettingsRelativePath('old-name')]: CREDS }); + h.state.fs = fs; + + await renameAgent({ agentId: 'agent-1', newName: 'old-name' }); + + expect(await fs.read(agentSettingsRelativePath('old-name'))).toBe(CREDS); + expect(h.removeLocal).not.toHaveBeenCalled(); + }); + + it('refuses a name a sibling in the same location already holds', async () => { + // Nothing keys agent state by id: the credentials live at + // `.switch/agents/.json`. Renaming onto a sibling would overwrite that + // sibling's token with this agent's and then delete the original, leaving the + // sibling authenticating to Switch as somebody else. + const SIBLING = JSON.stringify({ + env: { + SWITCH_API_ENDPOINT: 'https://s', + SWITCH_API_TOKEN: 'tok-sib', + SWITCH_AGENT_ID: 'sw-2', + }, + }); + h.state.nameTaken = true; + const fs = fakeFs({ + [agentSettingsRelativePath('old-name')]: CREDS, + [agentSettingsRelativePath('new-name')]: SIBLING, + }); + h.state.fs = fs; + + const result = await renameAgent({ agentId: 'agent-1', newName: 'new-name' }); + + expect(result).toEqual({ + success: false, + error: { type: 'name-taken', name: 'new-name' }, + }); + expect(await fs.read(agentSettingsRelativePath('new-name'))).toBe(SIBLING); + expect(await fs.read(agentSettingsRelativePath('old-name'))).toBe(CREDS); + expect(h.writeDefinition).not.toHaveBeenCalled(); + }); + + it('returns the renamed agent on success', async () => { + h.state.fs = fakeFs({ [agentSettingsRelativePath('old-name')]: CREDS }); + + const result = await renameAgent({ agentId: 'agent-1', newName: 'new-name' }); + + expect(result.success).toBe(true); + }); +}); diff --git a/dash/apps/switchdash-desktop/src/main/core/agents/renameAgent.ts b/dash/apps/switchdash-desktop/src/main/core/agents/renameAgent.ts index f14360005..fb8dfdd3c 100644 --- a/dash/apps/switchdash-desktop/src/main/core/agents/renameAgent.ts +++ b/dash/apps/switchdash-desktop/src/main/core/agents/renameAgent.ts @@ -1,16 +1,21 @@ +import { type Result, err, ok } from '@switchdash/shared'; import { eq, sql } from 'drizzle-orm'; import { agentSidecarTmuxName, killSidecarSession, } from '@main/core/agent-runtime/impl/remote-sidecar-launcher'; +import { getPlugin } from '@main/core/providers/plugin-registry'; import { db } from '@main/db/client'; import { agents } from '@main/db/schema'; import { log } from '@main/lib/logger'; import type { Agent, RenameAgentParams } from '@shared/core/agents/agents'; -import { getRemoteAgentLocation } from './agent-location'; +import { getAgentLocation, getRemoteAgentLocation } from './agent-location'; +import { agentNameTaken } from './agent-name-taken'; +import { resolveWorkspaceFsFor } from './agent-workspace-fs'; import { connectRemoteAgent } from './connect-remote-agent'; import { getAgentById } from './getAgentById'; import { ensureRemoteWatcher } from './remote-watcher'; +import { agentSettingsRelativePath } from './switch-settings-paths'; import { mapAgentRowToAgent } from './utils'; /** @@ -44,17 +49,88 @@ async function moveSidecarToNewName(previous: Agent, renamed: Agent): Promise { +/** + * Move the agent's on-disk state to its new name. + * + * Everything switchdash writes per agent is keyed by the agent's `name` — the + * Switch credentials at `.switch/agents/.json` and, for a provider with + * repo-agent definitions, the definition the CLI is launched against + * (`--agent `). A rename that only updates the row leaves both behind + * under the old key, and the credentials are unrecoverable: the token is minted + * once and lives nowhere else, so the agent would silently fall back to the + * shared `.claude/settings.local.json` identity — possibly another agent's. + * + * The new files are written before the old ones are removed, so an interruption + * leaves a recoverable duplicate rather than nothing. + * + * Best-effort — a rename must not fail because the VM is unreachable. A failure + * here leaves the agent on its old key, which the next launch reports as missing + * credentials rather than silently mis-authenticating. + */ +async function moveProvisionedFiles(previous: Agent, renamed: Agent): Promise { + const from = previous.name ?? previous.id; + const to = renamed.name ?? renamed.id; + if (from === to) return; + + try { + const location = await getAgentLocation(previous); + const ctx = await resolveWorkspaceFsFor(location.sshHost, location.dir); + try { + const creds = await ctx.fs.read(agentSettingsRelativePath(from)); + if (creds !== null) await ctx.fs.write(agentSettingsRelativePath(to), creds); + + const behavior = getPlugin(previous.providerId).behavior.repoAgents; + const definition = behavior ? await behavior.readDefinition(ctx.fs, from) : null; + if (behavior && definition) { + await behavior.writeDefinition(ctx.fs, { ...definition, name: to }); + } + + if (creds !== null) await ctx.fs.delete(agentSettingsRelativePath(from)); + if (behavior && definition) await behavior.removeLocal(ctx.fs, from); + } finally { + ctx.close(); + } + } catch (error) { + log.warn('renameAgent: failed to move the agent files to the new name', { + agentId: previous.id, + from, + to, + error: String(error), + }); + } +} + +export type RenameAgentError = { type: 'agent-not-found' } | { type: 'name-taken'; name: string }; + +/** + * Rename an agent and move the on-disk state keyed by its old name. + * + * The name must be free in the agent's location before anything is written: + * {@link moveProvisionedFiles} writes the credentials to the destination + * unconditionally, so renaming onto a sibling would hand that sibling this + * agent's Switch token and then delete the original. + */ +export async function renameAgent( + params: RenameAgentParams +): Promise> { const previous = await getAgentById(params.agentId); + if (!previous) return err({ type: 'agent-not-found' }); + + if (await agentNameTaken(previous.locationId, params.newName, previous.id)) { + return err({ type: 'name-taken', name: params.newName }); + } + const [row] = await db .update(agents) .set({ name: params.newName, updatedAt: sql`CURRENT_TIMESTAMP` }) .where(eq(agents.id, params.agentId)) .returning(); - if (!row) return undefined; + if (!row) return err({ type: 'agent-not-found' }); + const renamed = mapAgentRowToAgent(row); - if (previous && previous.name !== renamed.name) { + if (previous.name !== renamed.name) { + await moveProvisionedFiles(previous, renamed); await moveSidecarToNewName(previous, renamed); } - return renamed; + return ok(renamed); } diff --git a/dash/apps/switchdash-desktop/src/main/core/agents/switch-settings-paths.test.ts b/dash/apps/switchdash-desktop/src/main/core/agents/switch-settings-paths.test.ts new file mode 100644 index 000000000..a5e3b09f0 --- /dev/null +++ b/dash/apps/switchdash-desktop/src/main/core/agents/switch-settings-paths.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; +import { + agentSettingsRelativePath, + SWITCH_AGENTS_DIR_RELATIVE, + SWITCH_AGENTS_GITIGNORE_RELATIVE, + SWITCH_SETTINGS_RELATIVE_PATH, + SWITCH_SUBAGENTS_DIR_RELATIVE, +} from './switch-settings-paths'; + +// These relative paths are handed to a `PluginFs` that may be a remote POSIX +// host over SFTP, so they must never carry a Windows separator. `path.join` +// would, when switchdash itself runs on Windows. +describe('relative Switch settings paths', () => { + const relatives = { + SWITCH_SETTINGS_RELATIVE_PATH, + SWITCH_SUBAGENTS_DIR_RELATIVE, + SWITCH_AGENTS_DIR_RELATIVE, + SWITCH_AGENTS_GITIGNORE_RELATIVE, + 'agentSettingsRelativePath()': agentSettingsRelativePath('some-agent'), + }; + + for (const [name, value] of Object.entries(relatives)) { + it(`${name} uses forward slashes only`, () => { + expect(value).not.toContain('\\'); + }); + } + + it('resolves to the documented POSIX layout', () => { + expect(SWITCH_SETTINGS_RELATIVE_PATH).toBe('.claude/settings.local.json'); + expect(SWITCH_SUBAGENTS_DIR_RELATIVE).toBe('.claude/switch-subagents'); + expect(SWITCH_AGENTS_DIR_RELATIVE).toBe('.switch/agents'); + expect(SWITCH_AGENTS_GITIGNORE_RELATIVE).toBe('.switch/agents/.gitignore'); + expect(agentSettingsRelativePath('cc-hoot-main')).toBe('.switch/agents/cc-hoot-main.json'); + }); +}); diff --git a/dash/apps/switchdash-desktop/src/main/core/agents/switch-settings-paths.ts b/dash/apps/switchdash-desktop/src/main/core/agents/switch-settings-paths.ts index 034d70f99..8f8ef197b 100644 --- a/dash/apps/switchdash-desktop/src/main/core/agents/switch-settings-paths.ts +++ b/dash/apps/switchdash-desktop/src/main/core/agents/switch-settings-paths.ts @@ -5,6 +5,12 @@ import path from 'node:path'; * leaf module (only `node:path`) so the credentials reader — and through it the * remote sidecar bundle — can use them without pulling in the Electron-bound * agent-detection module. + * + * Every *relative* path here is a forward-slash literal, never `path.join`: these + * are handed to a `PluginFs`, which is either the local disk or a remote POSIX + * host over SFTP, and `path.join` emits backslashes when switchdash runs on + * Windows. The *absolute* helpers below still use `path.join`, which normalises a + * forward-slash tail correctly on every platform. */ /** @@ -12,7 +18,7 @@ import path from 'node:path'; * file that the switch-connector `configure` skill writes the `SWITCH_*` env * block into for a per-location agent. */ -export const SWITCH_SETTINGS_RELATIVE_PATH = path.join('.claude', 'settings.local.json'); +export const SWITCH_SETTINGS_RELATIVE_PATH = '.claude/settings.local.json'; /** * Directory, relative to an agent's working directory, where the switch-connector @@ -20,7 +26,7 @@ export const SWITCH_SETTINGS_RELATIVE_PATH = path.join('.claude', 'settings.loca * (`.settings.json`). switchdash discovers a parent agent's * launchable Claude Code subagents by scanning this directory. */ -export const SWITCH_SUBAGENTS_DIR_RELATIVE = path.join('.claude', 'switch-subagents'); +export const SWITCH_SUBAGENTS_DIR_RELATIVE = '.claude/switch-subagents'; /** Absolute path to a subagent's Switch credentials file under `dir`. */ export function subagentSettingsPath(dir: string, agentName: string): string { @@ -36,14 +42,14 @@ export function subagentSettingsPath(dir: string, agentName: string): string { * (CHOO-1440). The `.claude/agents/.md` *definition* stays under `.claude` * because it is Claude-specific; only the Switch credentials move here. */ -export const SWITCH_AGENTS_DIR_RELATIVE = path.join('.switch', 'agents'); +export const SWITCH_AGENTS_DIR_RELATIVE = '.switch/agents'; /** * Relative path to the `.gitignore` that keeps the per-agent credentials files * (which contain `SWITCH_API_TOKEN`) out of version control. Its content is `*` * so the whole directory is ignored. */ -export const SWITCH_AGENTS_GITIGNORE_RELATIVE = path.join(SWITCH_AGENTS_DIR_RELATIVE, '.gitignore'); +export const SWITCH_AGENTS_GITIGNORE_RELATIVE = `${SWITCH_AGENTS_DIR_RELATIVE}/.gitignore`; /** * Relative path (from a location dir) to an agent's provider-neutral Switch @@ -51,7 +57,7 @@ export const SWITCH_AGENTS_GITIGNORE_RELATIVE = path.join(SWITCH_AGENTS_DIR_RELA * definition name for a subagent-derived agent, or its stable name otherwise. */ export function agentSettingsRelativePath(slug: string): string { - return path.join(SWITCH_AGENTS_DIR_RELATIVE, `${slug}.json`); + return `${SWITCH_AGENTS_DIR_RELATIVE}/${slug}.json`; } /** Absolute path to an agent's provider-neutral Switch credentials file under `dir`. */ diff --git a/dash/apps/switchdash-desktop/src/main/core/agents/write-remote-switch-settings.ts b/dash/apps/switchdash-desktop/src/main/core/agents/write-remote-switch-settings.ts index 9dfdadc0d..60a07b3d9 100644 --- a/dash/apps/switchdash-desktop/src/main/core/agents/write-remote-switch-settings.ts +++ b/dash/apps/switchdash-desktop/src/main/core/agents/write-remote-switch-settings.ts @@ -3,13 +3,10 @@ import { FileSystemErrorCodes, type FileSystemProvider, } from '@main/core/fs/types'; +import { SWITCH_SETTINGS_RELATIVE_PATH } from './switch-settings-paths'; import { mergeSwitchSettings, type SwitchSettingsCredentials } from './write-switch-settings'; -// Remote hosts are POSIX; use forward-slash literals rather than the -// platform-dependent path.join constant (which would emit backslashes when -// switchdash runs on Windows). const REMOTE_SETTINGS_DIR = '.claude'; -const REMOTE_SETTINGS_PATH = '.claude/settings.local.json'; /** * Write the agent's `SWITCH_*` credentials into the remote working directory's @@ -29,7 +26,7 @@ export async function writeRemoteSwitchSettings( ): Promise { let existingRaw: string | null = null; try { - const result = await fs.read(REMOTE_SETTINGS_PATH); + const result = await fs.read(SWITCH_SETTINGS_RELATIVE_PATH); existingRaw = result.content; } catch (error) { // Start fresh only when the file is genuinely absent. A transport failure @@ -42,7 +39,7 @@ export async function writeRemoteSwitchSettings( const merged = mergeSwitchSettings(existingRaw, creds); await fs.mkdir(REMOTE_SETTINGS_DIR, { recursive: true }); - const result = await fs.write(REMOTE_SETTINGS_PATH, merged); + const result = await fs.write(SWITCH_SETTINGS_RELATIVE_PATH, merged); if (!result.success) { throw new Error(`failed to write remote Switch settings: ${result.error ?? 'unknown error'}`); } diff --git a/dash/apps/switchdash-desktop/src/main/core/agents/write-switch-settings.test.ts b/dash/apps/switchdash-desktop/src/main/core/agents/write-switch-settings.test.ts index 1bc199834..c1d2441a5 100644 --- a/dash/apps/switchdash-desktop/src/main/core/agents/write-switch-settings.test.ts +++ b/dash/apps/switchdash-desktop/src/main/core/agents/write-switch-settings.test.ts @@ -2,12 +2,19 @@ import { promises as fs } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { createPluginFs } from '@main/core/providers/plugin-fs'; import { detectSwitchAgent } from './detect'; -import { SWITCH_SETTINGS_RELATIVE_PATH } from './switch-settings-paths'; +import { + agentSettingsRelativePath, + SWITCH_AGENTS_GITIGNORE_RELATIVE, + SWITCH_SETTINGS_RELATIVE_PATH, +} from './switch-settings-paths'; import { mergeSwitchApiEndpoint, mergeSwitchSettings, removeSwitchSettings, + writeAgentNeutralSettings, + writeNeutralAgentSettingsFs, writeSwitchSettings, } from './write-switch-settings'; @@ -92,6 +99,111 @@ describe('writeSwitchSettings', () => { }); }); +describe('writeNeutralAgentSettingsFs', () => { + it('writes the provider-neutral per-agent creds keyed by slug, with a gitignore', async () => { + await writeNeutralAgentSettingsFs(createPluginFs(dir), { + slug: 'agent-abc', + apiEndpoint: 'https://switch.example.com', + apiToken: 'secret-token', + agentId: 'switch-agent-1', + }); + + const raw = await fs.readFile(path.join(dir, agentSettingsRelativePath('agent-abc')), 'utf8'); + const settings = JSON.parse(raw) as Record; + expect(settings.env).toEqual({ + SWITCH_API_ENDPOINT: 'https://switch.example.com', + SWITCH_API_TOKEN: 'secret-token', + SWITCH_AGENT_ID: 'switch-agent-1', + }); + + // The credentials directory is git-ignored so the token never enters VCS. + const ignore = await fs.readFile(path.join(dir, SWITCH_AGENTS_GITIGNORE_RELATIVE), 'utf8'); + expect(ignore).toBe('*\n'); + }); + + it('merges into an existing per-agent file, preserving unrelated env keys and allow rules', async () => { + const relPath = agentSettingsRelativePath('agent-abc'); + await fs.mkdir(path.dirname(path.join(dir, relPath)), { recursive: true }); + await fs.writeFile( + path.join(dir, relPath), + JSON.stringify({ + permissions: { allow: ['Bash'] }, + env: { EXISTING: 'keep', SWITCH_API_TOKEN: 'old' }, + }), + 'utf8' + ); + + await writeNeutralAgentSettingsFs(createPluginFs(dir), { + slug: 'agent-abc', + apiEndpoint: 'https://switch.example.com', + apiToken: 'new-token', + agentId: 'switch-agent-1', + }); + + const settings = JSON.parse(await fs.readFile(path.join(dir, relPath), 'utf8')) as Record< + string, + unknown + >; + expect(settings.env).toEqual({ + EXISTING: 'keep', + SWITCH_API_ENDPOINT: 'https://switch.example.com', + SWITCH_API_TOKEN: 'new-token', + SWITCH_AGENT_ID: 'switch-agent-1', + }); + // The connector's MCP tools are auto-approved on top of whatever the agent + // already allowed, so a Switch agent never has to ask to reach its room. + expect(settings.permissions).toEqual({ + allow: [ + 'Bash', + 'mcp__plugin_switch-connector_switch', + 'mcp__plugin_switch-connector_switch-channel', + ], + }); + }); + + it('writes the gitignore before the token file, so a crash never leaves a tracked token', async () => { + const writes: string[] = []; + const recordingFs = createPluginFs(dir); + const write = recordingFs.write.bind(recordingFs); + recordingFs.write = async (p, c) => { + writes.push(p); + return write(p, c); + }; + + await writeNeutralAgentSettingsFs(recordingFs, { + slug: 'agent-abc', + apiEndpoint: 'https://switch.example.com', + apiToken: 'secret-token', + agentId: 'switch-agent-1', + }); + + expect(writes).toEqual([ + SWITCH_AGENTS_GITIGNORE_RELATIVE, + agentSettingsRelativePath('agent-abc'), + ]); + }); +}); + +describe('writeAgentNeutralSettings', () => { + it('produces the same file as the PluginFs writer, for a plain directory path', async () => { + await writeAgentNeutralSettings({ + dir, + slug: 'agent-abc', + apiEndpoint: 'https://switch.example.com', + apiToken: 'secret-token', + agentId: 'switch-agent-1', + }); + + const raw = await fs.readFile(path.join(dir, agentSettingsRelativePath('agent-abc')), 'utf8'); + expect((JSON.parse(raw) as { env: Record }).env).toEqual({ + SWITCH_API_ENDPOINT: 'https://switch.example.com', + SWITCH_API_TOKEN: 'secret-token', + SWITCH_AGENT_ID: 'switch-agent-1', + }); + expect(await fs.readFile(path.join(dir, SWITCH_AGENTS_GITIGNORE_RELATIVE), 'utf8')).toBe('*\n'); + }); +}); + describe('mergeSwitchApiEndpoint', () => { it('rewrites only SWITCH_API_ENDPOINT, preserving token, id, and other keys', () => { const existing = JSON.stringify({ diff --git a/dash/apps/switchdash-desktop/src/main/core/agents/write-switch-settings.ts b/dash/apps/switchdash-desktop/src/main/core/agents/write-switch-settings.ts index 96f6c0379..090e85968 100644 --- a/dash/apps/switchdash-desktop/src/main/core/agents/write-switch-settings.ts +++ b/dash/apps/switchdash-desktop/src/main/core/agents/write-switch-settings.ts @@ -1,6 +1,8 @@ import { promises as fs } from 'node:fs'; import path from 'node:path'; import { SWITCH_CONNECTOR_TOOL_RULES } from '@switchdash/core/agents/plugins'; +import type { PluginFs } from '@switchdash/core/agents/plugins'; +import { createPluginFs } from '@main/core/providers/plugin-fs'; import { agentSettingsRelativePath, SWITCH_AGENTS_GITIGNORE_RELATIVE, @@ -257,30 +259,31 @@ export async function writeSwitchSettings(params: { * the renderer or logged. A `.gitignore` keeps the directory out of version * control. */ -export async function writeAgentNeutralSettings(params: { - dir: string; - slug: string; - apiEndpoint: string; - apiToken: string; - agentId: string; -}): Promise { - const settingsPath = path.join(params.dir, agentSettingsRelativePath(params.slug)); - const gitignorePath = path.join(params.dir, SWITCH_AGENTS_GITIGNORE_RELATIVE); - - let existingRaw: string | null = null; - try { - existingRaw = await fs.readFile(settingsPath, 'utf8'); - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code !== 'ENOENT' && code !== 'ENOTDIR') throw error; - } +export async function writeAgentNeutralSettings( + params: { dir: string; slug: string } & SwitchSettingsCredentials +): Promise { + await writeNeutralAgentSettingsFs(createPluginFs(params.dir), params); +} - const merged = mergeSwitchSettings(existingRaw, params); - await fs.mkdir(path.dirname(settingsPath), { recursive: true }); - await fs.writeFile(settingsPath, merged, 'utf8'); - try { - await fs.access(gitignorePath); - } catch { - await fs.writeFile(gitignorePath, '*\n', 'utf8'); +/** + * Write an agent's provider-neutral per-agent Switch credentials over a + * {@link PluginFs} (local disk or a remote repo dir via SFTP), keyed by `slug` + * (the agent name) — the authoritative identity switchdash injects at launch + * (`agentSettingsPath`). This is the single per-agent credential writer for every + * provider and every transport; providers with repo-agent definitions (Claude) + * layer their definition on top. + * + * The `.gitignore` is written first: it is what keeps `SWITCH_API_TOKEN` out of + * version control, so it must already be in place before the token reaches disk. + */ +export async function writeNeutralAgentSettingsFs( + workspaceFs: PluginFs, + params: { slug: string } & SwitchSettingsCredentials +): Promise { + if (!(await workspaceFs.exists(SWITCH_AGENTS_GITIGNORE_RELATIVE))) { + await workspaceFs.write(SWITCH_AGENTS_GITIGNORE_RELATIVE, '*\n'); } + const relPath = agentSettingsRelativePath(params.slug); + const merged = mergeSwitchSettings(await workspaceFs.read(relPath), params); + await workspaceFs.write(relPath, merged); } diff --git a/dash/apps/switchdash-desktop/src/main/core/fs/assert-removed.ts b/dash/apps/switchdash-desktop/src/main/core/fs/assert-removed.ts new file mode 100644 index 000000000..046e3e504 --- /dev/null +++ b/dash/apps/switchdash-desktop/src/main/core/fs/assert-removed.ts @@ -0,0 +1,21 @@ +/** + * Turn a {@link FileSystemProvider.remove} result into the `PluginFs.delete` + * contract: absent is success, everything else throws. + * + * `remove` never throws — it reports through `{ success, error }` — so a caller + * that ignores the result swallows permission denied, a non-recursive directory, + * a failed `rm -rf`, and a dropped connection alike. `delete` is how an agent's + * Switch token is revoked from disk, so a silent no-op there reports a credential + * as destroyed while it is still readable on the host. + * + * The not-found case is matched on the message because the result carries no + * error code; the literal is the one produced by `SshFileSystem.remove` when its + * `stat` finds nothing. Prefer a structured code on the result if one is ever + * added — message matching is the weakest part of this check. + */ +export function assertRemoved(path: string, result: { success: boolean; error?: string }): void { + if (result.success) return; + const error = result.error ?? 'unknown error'; + if (error.startsWith('File not found')) return; + throw new Error(`remote plugin fs: failed to delete ${path}: ${error}`); +} diff --git a/dash/apps/switchdash-desktop/src/main/core/providers/plugin-fs.test.ts b/dash/apps/switchdash-desktop/src/main/core/providers/plugin-fs.test.ts new file mode 100644 index 000000000..aa66b7966 --- /dev/null +++ b/dash/apps/switchdash-desktop/src/main/core/providers/plugin-fs.test.ts @@ -0,0 +1,63 @@ +import { chmod, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { createPluginFs } from './plugin-fs'; + +/** + * Against a real temp directory rather than a mocked `node:fs`: the behaviour + * under test is which errno values are treated as "already absent", and a mock + * would just restate the implementation's own answer. + */ +describe('createPluginFs delete', () => { + let root: string; + + beforeEach(async () => { + root = await mkdtemp(path.join(tmpdir(), 'plugin-fs-')); + }); + + afterEach(async () => { + await chmod(root, 0o755).catch(() => {}); + await rm(root, { recursive: true, force: true }); + }); + + it('removes an existing file', async () => { + const fs = createPluginFs(root); + await writeFile(path.join(root, 'creds.json'), '{}'); + + await fs.delete('creds.json'); + + expect(await fs.exists('creds.json')).toBe(false); + }); + + it('resolves when the file is already gone', async () => { + const fs = createPluginFs(root); + await expect(fs.delete('never-existed.json')).resolves.toBeUndefined(); + }); + + it('resolves when a parent path segment is not a directory', async () => { + // ENOTDIR: the file equally does not exist, same reasoning as read(). + const fs = createPluginFs(root); + await writeFile(path.join(root, 'afile'), 'x'); + await expect(fs.delete('afile/nested.json')).resolves.toBeUndefined(); + }); + + it('throws (fails loud) when the file exists but cannot be removed', async () => { + // A silently-swallowed failure here reports an agent's Switch token as + // revoked while it is still readable on disk. + const fs = createPluginFs(root); + const locked = path.join(root, 'locked'); + await mkdir(locked); + await writeFile(path.join(locked, 'token.json'), 'secret'); + await chmod(locked, 0o500); // r-x: the entry cannot be unlinked + + await expect(fs.delete('locked/token.json')).rejects.toThrow(); + + await chmod(locked, 0o700); + }); + + it('throws on a path escape instead of silently doing nothing', async () => { + const fs = createPluginFs(root); + await expect(fs.delete('../outside.json')).rejects.toThrow(/path escape/); + }); +}); diff --git a/dash/apps/switchdash-desktop/src/main/core/providers/plugin-fs.ts b/dash/apps/switchdash-desktop/src/main/core/providers/plugin-fs.ts index 2ace3d710..f8de3fe9d 100644 --- a/dash/apps/switchdash-desktop/src/main/core/providers/plugin-fs.ts +++ b/dash/apps/switchdash-desktop/src/main/core/providers/plugin-fs.ts @@ -44,10 +44,18 @@ export function createPluginFs(root: string): PluginFs { }, async delete(path: string): Promise { + const abs = resolveSafe(path); try { - await fs.unlink(resolveSafe(path)); - } catch { - // Silently ignore if file doesn't exist + await fs.unlink(abs); + } catch (error) { + // Only "already absent" is success, on the same reasoning as read(). + // A permission error or an I/O failure must propagate: delete is how an + // agent's Switch token is revoked from disk, and a silent no-op there + // reports a credential as destroyed while it is still readable. + const code = (error as NodeJS.ErrnoException).code; + if (code !== 'ENOENT' && code !== 'ENOTDIR') { + throw error; + } } }, diff --git a/dash/apps/switchdash-desktop/src/main/core/providers/provider-argv-parity.test.ts b/dash/apps/switchdash-desktop/src/main/core/providers/provider-argv-parity.test.ts new file mode 100644 index 000000000..154e53f74 --- /dev/null +++ b/dash/apps/switchdash-desktop/src/main/core/providers/provider-argv-parity.test.ts @@ -0,0 +1,52 @@ +import { pluginRegistry } from '@switchdash/plugins/agents'; +import { describe, expect, it } from 'vitest'; +import { getProvider } from '@shared/core/providers/agent-provider-registry'; + +/** `splitFlag` in standard-command.ts is not exported; this mirrors it. */ +function splitFlag(flag: string): string[] { + return flag.split(/\s+/).filter(Boolean); +} + +/** Whether `needle` appears in `haystack` as a contiguous run. */ +function containsSequence(haystack: string[], needle: string[]): boolean { + if (needle.length === 0) return true; + return haystack.some((_, i) => needle.every((token, offset) => haystack[i + offset] === token)); +} + +function buildCodexArgs(autoApprove: boolean): string[] { + return pluginRegistry.get('codex')!.behavior.prompt!.buildCommand({ + cli: 'codex', + autoApprove, + model: '', + isResuming: false, + }).args; +} + +/** + * The registry's argv fields describe the plugin rather than driving it, so + * nothing at runtime notices when the two disagree. Codex is the entry worth + * pinning: its flags disable the sandbox and bypass hook trust, so a stale + * mirror misrepresents how much access a session is launched with. + */ +describe('codex registry metadata matches the argv the plugin builds', () => { + it('emits the mirrored defaultArgs and autoApproveFlag', () => { + const def = getProvider('codex')!; + const args = buildCodexArgs(true); + + expect(def.defaultArgs).toBeDefined(); + expect(def.autoApproveFlag).toBeDefined(); + expect(containsSequence(args, def.defaultArgs!)).toBe(true); + expect(containsSequence(args, splitFlag(def.autoApproveFlag!))).toBe(true); + }); + + it('emits defaultArgs on a session that does not auto-approve', () => { + // Hook trust belongs in defaultArgs, not autoApproveFlag: Codex silently + // skips hooks it has no trust entry for, so gating it on auto-approve + // leaves a default session running none of switchdash's hooks. + const def = getProvider('codex')!; + const args = buildCodexArgs(false); + + expect(containsSequence(args, def.defaultArgs!)).toBe(true); + expect(args).not.toContain('-c'); + }); +}); diff --git a/dash/apps/switchdash-desktop/src/main/core/providers/remote-plugin-fs.test.ts b/dash/apps/switchdash-desktop/src/main/core/providers/remote-plugin-fs.test.ts index 7b7c67651..6b3ef8f4f 100644 --- a/dash/apps/switchdash-desktop/src/main/core/providers/remote-plugin-fs.test.ts +++ b/dash/apps/switchdash-desktop/src/main/core/providers/remote-plugin-fs.test.ts @@ -58,12 +58,28 @@ describe('createRemotePluginFs', () => { }); it('delete removes the path and ignores a missing file', async () => { - const remove = vi.fn(async () => ({ success: false, error: 'File not found' })); + // The literal SshFileSystem.remove produces when its stat finds nothing. + const remove = vi.fn(async () => ({ success: false, error: 'File not found: gone' })); const fs = adapter({ remove }); await expect(fs.delete('gone')).resolves.toBeUndefined(); expect(remove).toHaveBeenCalledWith('gone'); }); + it('delete resolves when the remove succeeds', async () => { + const fs = adapter({ remove: vi.fn(async () => ({ success: true })) }); + await expect(fs.delete('x')).resolves.toBeUndefined(); + }); + + it('delete throws (fails loud) when the file is there but cannot be removed', async () => { + // `remove` reports failure through its result rather than throwing, so a + // caller that ignores the result reports an undeletable Switch token as + // revoked. + const fs = adapter({ + remove: vi.fn(async () => ({ success: false, error: 'Permission denied' })), + }); + await expect(fs.delete('.switch/agents/a.json')).rejects.toThrow(/Permission denied/); + }); + it('exists passes through', async () => { const fs = adapter({ exists: vi.fn(async () => true) }); expect(await fs.exists('x')).toBe(true); diff --git a/dash/apps/switchdash-desktop/src/main/core/providers/remote-plugin-fs.ts b/dash/apps/switchdash-desktop/src/main/core/providers/remote-plugin-fs.ts index b97ccfd67..f7729f815 100644 --- a/dash/apps/switchdash-desktop/src/main/core/providers/remote-plugin-fs.ts +++ b/dash/apps/switchdash-desktop/src/main/core/providers/remote-plugin-fs.ts @@ -1,5 +1,6 @@ import { posix as pathPosix } from 'node:path'; import type { PluginFs } from '@switchdash/core/agents/plugins'; +import { assertRemoved } from '@main/core/fs/assert-removed'; import type { SshFileSystem } from '@main/core/fs/impl/ssh-fs'; import { FileSystemError, FileSystemErrorCodes } from '@main/core/fs/types'; @@ -46,8 +47,7 @@ export function createRemotePluginFs(fs: SshFileSystem): PluginFs { }, async delete(path: string): Promise { - // Match the local fs: a missing file is not an error, so ignore the result. - await fs.remove(path); + assertRemoved(path, await fs.remove(path)); }, async exists(path: string): Promise { diff --git a/dash/apps/switchdash-desktop/src/main/core/sessions/session-builder.ts b/dash/apps/switchdash-desktop/src/main/core/sessions/session-builder.ts index a283a567f..f22016c71 100644 --- a/dash/apps/switchdash-desktop/src/main/core/sessions/session-builder.ts +++ b/dash/apps/switchdash-desktop/src/main/core/sessions/session-builder.ts @@ -1,4 +1,4 @@ -import { getAgentById } from '@main/core/agents/getAgentById'; +import { agentCredsSlug } from '@main/core/agents/agent-creds-slug'; import { agentSettingsRelativePath, SWITCH_SETTINGS_RELATIVE_PATH, @@ -110,15 +110,14 @@ export async function buildSessionFromRuntime( ); // The remote preflight verifies the session's own creds file, keyed by the - // agent's NAME (`.switch/agents/.json`). Resolve it from the agent row — - // the source of truth — falling back to the session's denormalised agentName. - // The agent-id path and the legacy shared `.claude/settings.local.json` are - // last-resort fallbacks for agents not yet migrated (CHOO-1440). - const agent = await getAgentById(session.agentId); - const slug = agent?.name ?? session.agentName; + // agent's NAME (`.switch/agents/.json`). The agent-id path and the legacy + // shared `.claude/settings.local.json` are last-resort fallbacks for agents not + // yet migrated (CHOO-1440). const credsRelPaths = [ - ...(slug ? [agentSettingsRelativePath(slug)] : []), - agentSettingsRelativePath(session.agentId), + ...new Set([ + agentSettingsRelativePath(agentCredsSlug(session)), + agentSettingsRelativePath(session.agentId), + ]), SWITCH_SETTINGS_RELATIVE_PATH, ]; diff --git a/dash/apps/switchdash-desktop/src/main/core/switch-rooms/session-control.test.ts b/dash/apps/switchdash-desktop/src/main/core/switch-rooms/session-control.test.ts new file mode 100644 index 000000000..69f98eab4 --- /dev/null +++ b/dash/apps/switchdash-desktop/src/main/core/switch-rooms/session-control.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from 'vitest'; +import { resolveSessionControl, type SessionControlContext } from './session-control'; + +const ESC = '\x1b'; + +const ctx: SessionControlContext = { + room: 'hub', + role: 'reviewer', + threadId: 'msg-1', + user: 'ada', +}; + +describe('resolveSessionControl', () => { + it('reports no support for a provider switchdash cannot drive', () => { + const control = resolveSessionControl('gemini'); + expect(control.capabilities).toEqual({ reset: false, compact: false, interrupt: false }); + expect(control.plan('interrupt', ctx)).toBeNull(); + }); + + it.each(['claude', 'codex'])('supports all three commands for %s', (providerId) => { + expect(resolveSessionControl(providerId).capabilities).toEqual({ + reset: true, + compact: true, + interrupt: true, + }); + }); + + it.each(['claude', 'codex'])('interrupts %s with a bare ESC and no submit', (providerId) => { + // Ctrl+C would exit the CLI rather than the turn, so the recipe must stay + // ESC-only — and `raw` so no Enter is appended. + expect(resolveSessionControl(providerId).plan('interrupt', ctx)).toEqual([ + { kind: 'raw', data: ESC }, + ]); + }); + + it.each(['claude', 'codex'])('returns null for an unknown command on %s', (providerId) => { + expect(resolveSessionControl(providerId).plan('explode', ctx)).toBeNull(); + }); + + it.each(['claude', 'codex'])( + 'reconnects and re-assumes the role after %s reset', + (providerId) => { + const steps = resolveSessionControl(providerId).plan('reset', ctx); + expect(steps).not.toBeNull(); + + // `/clear` drops the context, so the follow-up must put the agent back in + // the room, back in its role, and tell the asker in their thread. + const announce = steps!.at(-1); + expect(announce).toMatchObject({ kind: 'prompt' }); + const text = (announce as { text: string }).text; + expect(text).toContain('connect to switch room "hub"'); + expect(text).toContain('assume the role reviewer'); + expect(text).toContain('send a targeted message to ada'); + expect(text).toContain('as a threaded reply to message msg-1'); + expect(text).toContain('session has been reset'); + } + ); + + // Codex gates both slash commands behind `available_during_task()` and drops + // them outright mid-turn, so each has to interrupt first — otherwise the + // command is discarded and the follow-up announces work that never happened. + it.each([ + ['reset', '/clear'], + ['compact', '/compact'], + ])('interrupts before %s on codex, which drops slash commands mid-turn', (command, slash) => { + expect(resolveSessionControl('codex').plan(command, ctx)?.slice(0, 2)).toEqual([ + { kind: 'raw', data: ESC }, + { kind: 'prompt', text: slash }, + ]); + }); + + // Claude queues a slash command typed mid-turn, so it needs no interrupt. + it('compacts claude with /compact and no preceding interrupt', () => { + expect(resolveSessionControl('claude').plan('compact', ctx)?.[0]).toEqual({ + kind: 'prompt', + text: '/compact', + }); + }); + + it.each(['claude', 'codex'])('announces the compaction back to the room on %s', (providerId) => { + const steps = resolveSessionControl(providerId).plan('compact', ctx); + expect(steps).not.toBeNull(); + expect((steps!.at(-1) as { text: string }).text).toContain('context has been compacted'); + }); + + it('omits the role and thread clauses when there is neither', () => { + const steps = resolveSessionControl('codex').plan('reset', { + room: 'hub', + role: null, + threadId: null, + user: null, + }); + const text = (steps!.at(-1) as { text: string }).text; + expect(text).not.toContain('assume the role'); + expect(text).not.toContain('threaded reply'); + expect(text).toContain('post a short message'); + }); +}); diff --git a/dash/apps/switchdash-desktop/src/main/core/switch-rooms/session-control.ts b/dash/apps/switchdash-desktop/src/main/core/switch-rooms/session-control.ts index f417f10bb..140593a8e 100644 --- a/dash/apps/switchdash-desktop/src/main/core/switch-rooms/session-control.ts +++ b/dash/apps/switchdash-desktop/src/main/core/switch-rooms/session-control.ts @@ -96,6 +96,42 @@ const CLAUDE_CONTROL: SessionControl = { }, }; +/** + * Codex: also a TUI. ESC interrupts the current turn (Ctrl+C would exit the + * session instead), `/clear` starts a fresh chat, `/compact` summarises the + * transcript. + * + * Both slash commands are gated behind Codex's `available_during_task()`, which + * rejects them outright while a turn is running — the command is dropped with an + * error cell, not queued. The follow-up step would still run and announce work + * that never happened, so each is preceded by an interrupt. Kept as its own + * object rather than aliasing CLAUDE_CONTROL: the two CLIs are free to diverge, + * and a shared reference would make a Claude-only change silently apply here. + */ +const CODEX_CONTROL: SessionControl = { + capabilities: { reset: true, compact: true, interrupt: true }, + plan(command, ctx) { + switch (command) { + case 'interrupt': + return [{ kind: 'raw', data: ESC }]; + case 'compact': + return [ + { kind: 'raw', data: ESC }, + { kind: 'prompt', text: '/compact' }, + { kind: 'prompt', text: reconnectAndAnnounce(ctx, 'context has been compacted') }, + ]; + case 'reset': + return [ + { kind: 'raw', data: ESC }, + { kind: 'prompt', text: '/clear' }, + { kind: 'prompt', text: reconnectAndAnnounce(ctx, 'session has been reset') }, + ]; + default: + return null; + } + }, +}; + const NO_CONTROL: SessionControl = { capabilities: { reset: false, compact: false, interrupt: false }, plan: () => null, @@ -103,6 +139,7 @@ const NO_CONTROL: SessionControl = { const BY_PROVIDER: Record = { claude: CLAUDE_CONTROL, + codex: CODEX_CONTROL, }; /** Resolve the session-control support + recipes for a provider. */ diff --git a/dash/apps/switchdash-desktop/src/main/core/switch-rooms/switch-credentials.test.ts b/dash/apps/switchdash-desktop/src/main/core/switch-rooms/switch-credentials.test.ts new file mode 100644 index 000000000..ade46c6f5 --- /dev/null +++ b/dash/apps/switchdash-desktop/src/main/core/switch-rooms/switch-credentials.test.ts @@ -0,0 +1,78 @@ +import type { PluginFs } from '@switchdash/core/agents/plugins'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { agentSettingsRelativePath } from '@main/core/agents/switch-settings-paths'; +import { parseSwitchAgentCredentials, readAgentSwitchEnvFromFs } from './switch-credentials'; + +const log = { warn: vi.fn() }; + +function credsJson(overrides: Record = {}): string { + return JSON.stringify({ + env: { + SWITCH_API_ENDPOINT: 'https://switch.example.com', + SWITCH_API_TOKEN: 'tok-123', + SWITCH_AGENT_ID: 'sw-1', + ...overrides, + }, + }); +} + +function memoryFs(files: Record = {}): PluginFs { + const store = new Map(Object.entries(files)); + return { + read: async (p) => store.get(p) ?? null, + write: async (p, c) => void store.set(p, c), + delete: async (p) => void store.delete(p), + exists: async (p) => store.has(p), + list: async () => [...store.keys()], + }; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('parseSwitchAgentCredentials', () => { + it('parses a complete env block', () => { + expect(parseSwitchAgentCredentials(credsJson(), log)).toEqual({ + apiEndpoint: 'https://switch.example.com', + token: 'tok-123', + agentId: 'sw-1', + }); + expect(log.warn).not.toHaveBeenCalled(); + }); + + it('treats an absent file as "not provisioned here", without warning', () => { + // The migration probes several candidate paths per agent; a missing one is + // the ordinary answer, not a malformed file. + expect(parseSwitchAgentCredentials(null, log)).toBeNull(); + expect(log.warn).not.toHaveBeenCalled(); + }); + + it('warns and returns null for text it cannot parse', () => { + expect(parseSwitchAgentCredentials('{', log)).toBeNull(); + expect(log.warn).toHaveBeenCalledTimes(1); + }); + + it('returns null without warning when a value is missing or blank', () => { + expect(parseSwitchAgentCredentials(credsJson({ SWITCH_API_TOKEN: ' ' }), log)).toBeNull(); + expect(parseSwitchAgentCredentials(JSON.stringify({}), log)).toBeNull(); + expect(log.warn).not.toHaveBeenCalled(); + }); +}); + +describe('readAgentSwitchEnvFromFs', () => { + it('reads the slug-keyed neutral file and returns it as launch env', async () => { + const fs = memoryFs({ [agentSettingsRelativePath('codex-hoot')]: credsJson() }); + + expect(await readAgentSwitchEnvFromFs(fs, 'codex-hoot', log)).toEqual({ + SWITCH_API_ENDPOINT: 'https://switch.example.com', + SWITCH_API_TOKEN: 'tok-123', + SWITCH_AGENT_ID: 'sw-1', + }); + }); + + it('returns an empty env when the agent has no neutral file', async () => { + expect(await readAgentSwitchEnvFromFs(memoryFs(), 'codex-hoot', log)).toEqual({}); + expect(log.warn).not.toHaveBeenCalled(); + }); +}); diff --git a/dash/apps/switchdash-desktop/src/main/core/switch-rooms/switch-credentials.ts b/dash/apps/switchdash-desktop/src/main/core/switch-rooms/switch-credentials.ts index fd44a05f9..1363a984f 100644 --- a/dash/apps/switchdash-desktop/src/main/core/switch-rooms/switch-credentials.ts +++ b/dash/apps/switchdash-desktop/src/main/core/switch-rooms/switch-credentials.ts @@ -1,6 +1,10 @@ import { promises as fs } from 'node:fs'; import path from 'node:path'; -import { SWITCH_SETTINGS_RELATIVE_PATH } from '@main/core/agents/switch-settings-paths'; +import type { PluginFs } from '@switchdash/core/agents/plugins'; +import { + agentSettingsRelativePath, + SWITCH_SETTINGS_RELATIVE_PATH, +} from '@main/core/agents/switch-settings-paths'; export interface SwitchAgentCredentials { agentId: string; @@ -64,35 +68,57 @@ export async function readSwitchAgentCredentialsFromSettings( return parseSwitchAgentCredentials(raw, log); } +/** The `SWITCH_*` env vars a launched session needs to act as the agent. */ +function credentialsAsEnv(creds: SwitchAgentCredentials | null): Record { + if (!creds) return {}; + return { + SWITCH_API_ENDPOINT: creds.apiEndpoint, + SWITCH_API_TOKEN: creds.token, + SWITCH_AGENT_ID: creds.agentId, + }; +} + /** * Read an agent's Switch credentials from a settings file and return them as the * `SWITCH_*` env vars a launched session needs, or `{}` when the file is * missing/incomplete. Used to inject an agent's identity at launch from its - * provider-neutral `.switch/agents/.json` (CHOO-1440). + * provider-neutral `.switch/agents/.json` (CHOO-1440). */ export async function readAgentSwitchEnv( settingsPath: string, log: CredentialsLogger ): Promise> { - const creds = await readSwitchAgentCredentialsFromSettings(settingsPath, log); - if (!creds) return {}; - return { - SWITCH_API_ENDPOINT: creds.apiEndpoint, - SWITCH_API_TOKEN: creds.token, - SWITCH_AGENT_ID: creds.agentId, - }; + return credentialsAsEnv(await readSwitchAgentCredentialsFromSettings(settingsPath, log)); +} + +/** + * The {@link readAgentSwitchEnv} equivalent over a {@link PluginFs} rooted at the + * agent's working directory, so the local and remote (SFTP) launch paths inject + * the same identity from the same `.switch/agents/.json`. + */ +export async function readAgentSwitchEnvFromFs( + workspaceFs: PluginFs, + slug: string, + log: CredentialsLogger +): Promise> { + const raw = await workspaceFs.read(agentSettingsRelativePath(slug)); + return credentialsAsEnv(parseSwitchAgentCredentials(raw, log)); } /** - * Parse Switch agent credentials from the raw text of a `.claude/settings.local.json`. - * Transport-agnostic (no filesystem): used by both the local readers above and - * the remote preflight, which fetches the file over SFTP. Returns null when the - * text is unparseable or any of the three values is absent. + * Parse Switch agent credentials from the raw text of a settings file, or `null` + * for an absent one. Transport-agnostic (no filesystem): used by both the local + * readers above and the remote preflight, which fetches the file over SFTP. + * Returns null when the file is absent, the text is unparseable, or any of the + * three values is missing — but only warns for text it could not parse, since an + * absent file is an ordinary "this agent isn't provisioned here" answer. */ export function parseSwitchAgentCredentials( - raw: string, + raw: string | null, log: CredentialsLogger ): SwitchAgentCredentials | null { + if (raw === null) return null; + let env: ClaudeSettingsEnv | undefined; try { env = (JSON.parse(raw) as { env?: ClaudeSettingsEnv })?.env; diff --git a/dash/apps/switchdash-desktop/src/main/core/switch-servers/controller.ts b/dash/apps/switchdash-desktop/src/main/core/switch-servers/controller.ts index b5e4d0f88..c8c6cad7a 100644 --- a/dash/apps/switchdash-desktop/src/main/core/switch-servers/controller.ts +++ b/dash/apps/switchdash-desktop/src/main/core/switch-servers/controller.ts @@ -1,5 +1,6 @@ import type { Result } from '@switchdash/shared'; import { suggestAgentDefaults } from '@main/core/agents/agent-defaults'; +import { knownAgentTypeForProvider } from '@main/core/agents/known-agent-type'; import { propagateServerApiUrl } from '@main/core/agents/propagate-server-api-url'; import { registerAgentIdentity } from '@main/core/agents/register-agent-identity'; import { resolveAgentServers } from '@main/core/agents/resolve-servers'; @@ -14,6 +15,7 @@ import { } from '@main/core/managed-switch-server/managed-server-status'; import { HostUnreachableError } from '@main/core/remote-hosts/host-reachability-service'; import { ensureSshConnected } from '@main/core/ssh/connect/connect-agent-ssh'; +import type { AgentProviderId } from '@shared/core/providers/agent-provider-registry'; import type { AddressingPolicy, AddServerParams, @@ -228,16 +230,17 @@ export const switchServersController = createRPCController({ } }, - suggestAgentDefaults: async (params: { dir: string }): Promise => - suggestAgentDefaults(params.dir), + suggestAgentDefaults: async (params: { + dir: string; + providerId: AgentProviderId; + }): Promise => suggestAgentDefaults(params.dir, params.providerId), /** - * Register a new Claude Code agent on the chosen server (owned by the - * signed-in user) and write its credentials into the directory's - * `.claude/settings.local.json`. This is the desktop equivalent of running - * the switch-connector `configure` skill. Recoverable gateway failures are - * mapped to a typed result; the minted token is written to disk and never - * returned. + * Register a new agent on the chosen server (owned by the signed-in user) and + * write its credentials into the directory's `.claude/settings.local.json`. + * This is the desktop equivalent of running the switch-connector `configure` + * skill. Recoverable gateway failures are mapped to a typed result; the minted + * token is written to disk and never returned. */ provisionAgent: async (params: ProvisionAgentParams): Promise => { const server = await requireServer(params.serverId); @@ -247,6 +250,9 @@ export const switchServersController = createRPCController({ description: params.description, repoDir: params.dir, autoSession: params.autoSession, + // Provisioning writes `.claude/settings.local.json` — this is the Claude + // Code path by construction, not a fallback. + agentType: knownAgentTypeForProvider('claude'), }); if (registered.kind !== 'created') return registered; @@ -278,6 +284,8 @@ export const switchServersController = createRPCController({ description: params.description, repoDir: params.remoteRepoDir, autoSession: params.autoSession, + // Remote provisioning likewise writes `.claude/settings.local.json`. + agentType: knownAgentTypeForProvider('claude'), }); if (registered.kind !== 'created') return registered; diff --git a/dash/apps/switchdash-desktop/src/main/core/switch-servers/gateway-client.test.ts b/dash/apps/switchdash-desktop/src/main/core/switch-servers/gateway-client.test.ts index 62aac0bad..40bf0ddbf 100644 --- a/dash/apps/switchdash-desktop/src/main/core/switch-servers/gateway-client.test.ts +++ b/dash/apps/switchdash-desktop/src/main/core/switch-servers/gateway-client.test.ts @@ -7,7 +7,7 @@ const reauthenticateManagedServer = vi.hoisted(() => vi.fn()); vi.mock('./servers-store', () => ({ getSessionCookie })); vi.mock('./auth', () => ({ refreshSession, reauthenticateManagedServer })); -const { fetchMe } = await import('./gateway-client'); +const { fetchMe, registerKnownAgent } = await import('./gateway-client'); const SERVER = { id: 'srv-1', @@ -196,3 +196,47 @@ describe('gatewayFetch managed-server silent re-auth', () => { expect(fetchMock).toHaveBeenCalledOnce(); }); }); + +describe('registerKnownAgent', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal('fetch', fetchMock); + getSessionCookie.mockResolvedValue(makeJwt(24 * 60 * 60)); + fetchMock.mockImplementation( + async () => + ({ + status: 200, + ok: true, + json: async () => ({ id: 'sw-1', api_key: 'tok-123' }), + headers: { getSetCookie: () => [] }, + text: async () => '', + }) as unknown as Response + ); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('sends the caller-supplied agent_type rather than a hardcoded default', async () => { + // The type governs the connector label and the hand-onboarding command the + // gateway shows, so a default here would silently mislabel every non-Claude + // agent (CHOO-1436). + const registered = await registerKnownAgent(SERVER, { + name: 'codex-hoot', + description: 'Codex running in repo', + agentType: 'codex', + options: { channels_enabled: true, repo_dir: '/repo' }, + }); + + expect(registered).toEqual({ id: 'sw-1', apiKey: 'tok-123' }); + const [, init] = fetchMock.mock.calls[0] as unknown as [string, { body: string }]; + expect(JSON.parse(init.body)).toEqual({ + agent_type: 'codex', + name: 'codex-hoot', + description: 'Codex running in repo', + options: { channels_enabled: true, repo_dir: '/repo' }, + overwrite: false, + }); + }); +}); diff --git a/dash/apps/switchdash-desktop/src/main/core/switch-servers/gateway-client.ts b/dash/apps/switchdash-desktop/src/main/core/switch-servers/gateway-client.ts index 9e77c9315..4247229ca 100644 --- a/dash/apps/switchdash-desktop/src/main/core/switch-servers/gateway-client.ts +++ b/dash/apps/switchdash-desktop/src/main/core/switch-servers/gateway-client.ts @@ -218,13 +218,21 @@ export type RegisteredAgent = { */ export async function registerKnownAgent( server: SwitchServer, - params: { name: string; description: string; options: RegisterKnownAgentOptions } + params: { + name: string; + description: string; + options: RegisterKnownAgentOptions; + /** Gateway known-agent type. Required — derive it from the provider via + * `knownAgentTypeForProvider` rather than letting a call site fall back to + * Claude Code's shape by omission (CHOO-1436). */ + agentType: string; + } ): Promise { const res = await gatewayFetch(server, '/agents/register', { authenticated: true, method: 'POST', body: { - agent_type: 'claude-code', + agent_type: params.agentType, name: params.name, description: params.description, options: params.options, diff --git a/dash/apps/switchdash-desktop/src/main/core/switch-setup/remote-switch-setup.test.ts b/dash/apps/switchdash-desktop/src/main/core/switch-setup/remote-switch-setup.test.ts new file mode 100644 index 000000000..414661bfb --- /dev/null +++ b/dash/apps/switchdash-desktop/src/main/core/switch-setup/remote-switch-setup.test.ts @@ -0,0 +1,324 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + exec: vi.fn(), + resolveCommandPath: vi.fn(), + getPlugin: vi.fn(), + listPlugins: vi.fn(), + ensureSshConnected: vi.fn(), +})); + +vi.mock('@main/core/execution-context/ssh-execution-context', () => ({ + SshExecutionContext: class { + exec = mocks.exec; + }, +})); + +vi.mock('@main/core/ssh/connect/connect-agent-ssh', () => ({ + ensureSshConnected: mocks.ensureSshConnected, +})); + +vi.mock('@switchdash/core/deps/runtime', () => ({ + resolveCommandPath: mocks.resolveCommandPath, +})); + +vi.mock('../providers/plugin-registry', () => ({ + getPlugin: mocks.getPlugin, + listPlugins: mocks.listPlugins, +})); + +vi.mock('@main/lib/logger', () => ({ + log: { warn: vi.fn(), info: vi.fn(), error: vi.fn() }, +})); + +import { getRemoteSwitchSetupService } from './remote-switch-setup'; + +const SSH_HOST = 'agent-host'; + +const CLAUDE_AGENT = { + metadata: { id: 'claude' }, + capabilities: { + switchSetup: { + kind: 'cli', + pluginName: 'switch-connector', + marketplaceName: 'switch-plugins', + marketplaceSource: 'sandbox-quantum/switch', + scope: 'user', + dialect: 'claude-code', + }, + hostDependency: { binaryNames: ['claude'] }, + }, +}; + +const CODEX_AGENT = { + metadata: { id: 'codex' }, + capabilities: { + switchSetup: { + kind: 'cli', + pluginName: 'switch-connector-codex', + marketplaceName: 'switch-plugins', + marketplaceSource: 'sandbox-quantum/switch', + scope: 'user', + dialect: 'codex', + }, + hostDependency: { binaryNames: ['codex'] }, + }, +}; + +const CODEX_REF = 'switch-connector-codex@switch-plugins'; + +/** + * A login shell sources the host's profile before the command runs, so its MOTD + * lands on stdout ahead of the JSON. This one carries brackets of its own, which + * a naive "slice from the first bracket" would latch onto. + */ +const MOTD = [ + '###############################################', + '# ACME [production] — authorized use only #', + '# Last login: Tue Jul 28 09:12:33 2026 #', + '###############################################', +].join('\n'); + +function withBanner(json: string): string { + return `${MOTD}\n${json}\nConnection to ${SSH_HOST} closed.\n`; +} + +function claudeExecImpl(installedVersion: string, advertisedVersion: string) { + return (_bin: string, args: string[] = []) => { + const a = args.join(' '); + if (a === 'plugin list --json') { + return Promise.resolve({ + stdout: withBanner( + JSON.stringify([ + { + id: 'switch-connector@switch-plugins', + version: installedVersion, + scope: 'user', + installPath: '/home/dev/.claude/plugins/switch-connector', + }, + ]) + ), + stderr: '', + }); + } + if (a === 'plugin marketplace list --json') { + return Promise.resolve({ + stdout: withBanner( + JSON.stringify([ + { + name: 'switch-plugins', + source: 'github', + repo: 'sandbox-quantum/switch', + installLocation: '/home/dev/.claude/marketplaces/switch-plugins', + plugins: [{ name: 'switch-connector', version: advertisedVersion }], + }, + ]) + ), + stderr: '', + }); + } + return Promise.resolve({ stdout: '', stderr: '' }); + }; +} + +function codexExecImpl(marketplaceSource: string) { + return (_bin: string, args: string[] = []) => { + const a = args.join(' '); + if (a === 'plugin list --json') { + return Promise.resolve({ + stdout: JSON.stringify({ + installed: [ + { + pluginId: CODEX_REF, + name: 'switch-connector-codex', + marketplaceName: 'switch-plugins', + version: '0.1.0', + installed: true, + enabled: true, + source: { source: 'local', path: '/home/dev/.codex/plugins/switch-connector-codex' }, + }, + ], + available: [], + }), + stderr: '', + }); + } + if (a === 'plugin marketplace list --json') { + return Promise.resolve({ + stdout: JSON.stringify({ + marketplaces: [ + { + name: 'switch-plugins', + root: '/home/dev/.codex/marketplaces/switch-plugins', + marketplaceSource: { sourceType: 'github', source: marketplaceSource }, + }, + ], + }), + stderr: '', + }); + } + return Promise.resolve({ stdout: '', stderr: '' }); + }; +} + +function calls(): string[] { + return mocks.exec.mock.calls.map((c) => (c[1] as string[]).join(' ')); +} + +beforeEach(() => { + vi.clearAllMocks(); + mocks.ensureSshConnected.mockResolvedValue({}); + mocks.resolveCommandPath.mockResolvedValue('/usr/bin/codex'); + mocks.getPlugin.mockReturnValue(CODEX_AGENT); +}); + +describe('RemoteSwitchSetupService.getStatus', () => { + it('parses CLI JSON printed after a login-shell banner', async () => { + mocks.getPlugin.mockReturnValue(CLAUDE_AGENT); + mocks.resolveCommandPath.mockResolvedValue('/usr/bin/claude'); + mocks.exec.mockImplementation(claudeExecImpl('0.1.0', '0.2.0')); + + const service = await getRemoteSwitchSetupService(SSH_HOST); + const status = await service.getStatus('claude'); + + expect(status).toMatchObject({ + supported: true, + installed: true, + installedVersion: '0.1.0', + latestVersion: '0.2.0', + updateAvailable: true, + }); + }); + + it('issues exactly one plugin list and one marketplace list for codex', async () => { + // Codex advertises no versions, so an unknown latest is the correct answer — + // re-reading the plugin list to look for one would only cost another SSH exec. + mocks.exec.mockImplementation(codexExecImpl('sandbox-quantum/switch')); + + const service = await getRemoteSwitchSetupService(SSH_HOST); + const status = await service.getStatus('codex'); + + expect(status).toMatchObject({ + supported: true, + installed: true, + installedVersion: '0.1.0', + latestVersion: null, + updateAvailable: false, + }); + expect(calls()).toEqual(['plugin list --json', 'plugin marketplace list --json']); + }); +}); + +describe('RemoteSwitchSetupService.update', () => { + it('repairs a stale marketplace before removing the installed plugin', async () => { + // With no per-plugin update verb the update is destructive: a marketplace + // still pointing at a pre-migration source would fail the re-add *after* the + // remove succeeded, leaving the host with no connector. + mocks.exec.mockImplementation(codexExecImpl('sandbox-quantum/switch-legacy')); + + const service = await getRemoteSwitchSetupService(SSH_HOST); + const result = await service.update('codex'); + + expect(result.success).toBe(true); + const seen = calls(); + expect(seen).toContain('plugin marketplace remove switch-plugins'); + expect(seen.indexOf('plugin marketplace add sandbox-quantum/switch')).toBeLessThan( + seen.indexOf(`plugin remove ${CODEX_REF}`) + ); + }); + + it('reports a marketplace failure without removing the installed plugin', async () => { + mocks.exec.mockImplementation((_bin: string, args: string[] = []) => { + if (args.join(' ') === 'plugin marketplace add sandbox-quantum/switch') { + return Promise.reject( + Object.assign(new Error('exit 1'), { code: 1, stderr: 'no network' }) + ); + } + return Promise.resolve({ stdout: '', stderr: '' }); + }); + + const service = await getRemoteSwitchSetupService(SSH_HOST); + const result = await service.update('codex'); + + expect(result.success).toBe(false); + expect(result.message).toMatch(/Could not add marketplace/); + expect(calls()).not.toContain(`plugin remove ${CODEX_REF}`); + }); + + it('surfaces a refreshError when the codex marketplace upgrade fails', async () => { + const base = codexExecImpl('sandbox-quantum/switch'); + mocks.exec.mockImplementation((bin: string, args: string[] = []) => { + if (args.join(' ') === 'plugin marketplace upgrade switch-plugins') { + return Promise.reject(Object.assign(new Error('exit 1'), { code: 1, stderr: 'offline' })); + } + return base(bin, args); + }); + + const service = await getRemoteSwitchSetupService(SSH_HOST); + const status = await service.checkForUpdates('codex'); + + expect(status.refreshError).toMatch(/offline/); + expect(status.installed).toBe(true); + }); + + it('removes then re-adds for codex, which has no per-plugin update verb', async () => { + mocks.exec.mockImplementation(codexExecImpl('sandbox-quantum/switch')); + + const service = await getRemoteSwitchSetupService(SSH_HOST); + const result = await service.update('codex'); + + expect(result.success).toBe(true); + // The marketplace is repaired first: the re-add resolves against it, so a + // stale source must not be discovered after the remove has succeeded. + expect(calls()).toEqual([ + 'plugin marketplace list --json', + `plugin remove ${CODEX_REF}`, + `plugin add ${CODEX_REF}`, + ]); + }); + + it('reports the plugin as removed-but-not-reinstalled when the re-add fails', async () => { + mocks.exec.mockImplementation((_bin: string, args: string[] = []) => { + if (args.join(' ') === `plugin add ${CODEX_REF}`) { + // With no stderr to relay, our own wording is all the user gets — and it + // has to say the host now has no connector, not just "update failed". + return Promise.reject(Object.assign(new Error('exit 1'), { code: 1, stderr: '' })); + } + return Promise.resolve({ stdout: '', stderr: '' }); + }); + + const service = await getRemoteSwitchSetupService(SSH_HOST); + const result = await service.update('codex'); + + expect(calls().slice(-2)).toEqual([`plugin remove ${CODEX_REF}`, `plugin add ${CODEX_REF}`]); + expect(result).toEqual({ + success: false, + message: + 'Update failed: the plugin was removed but could not be reinstalled. Install it again for this host.', + }); + }); +}); + +describe('RemoteSwitchSetupService.checkForUpdates', () => { + it('leaves a marketplace already pointing at the expected source alone', async () => { + mocks.exec.mockImplementation(codexExecImpl('sandbox-quantum/switch')); + + const service = await getRemoteSwitchSetupService(SSH_HOST); + const status = await service.checkForUpdates('codex'); + + expect(status.refreshError).toBeNull(); + expect(calls()).not.toContain('plugin marketplace remove switch-plugins'); + expect(calls()).toContain('plugin marketplace upgrade switch-plugins'); + }); + + it('re-points a same-named marketplace registered against a stale source', async () => { + mocks.exec.mockImplementation(codexExecImpl('sandbox-quantum/switch-legacy')); + + const service = await getRemoteSwitchSetupService(SSH_HOST); + const status = await service.checkForUpdates('codex'); + + expect(status.refreshError).toBeNull(); + expect(calls()).toContain('plugin marketplace remove switch-plugins'); + expect(calls()).toContain('plugin marketplace add sandbox-quantum/switch'); + }); +}); diff --git a/dash/apps/switchdash-desktop/src/main/core/switch-setup/remote-switch-setup.ts b/dash/apps/switchdash-desktop/src/main/core/switch-setup/remote-switch-setup.ts index 14e760ac9..961767f23 100644 --- a/dash/apps/switchdash-desktop/src/main/core/switch-setup/remote-switch-setup.ts +++ b/dash/apps/switchdash-desktop/src/main/core/switch-setup/remote-switch-setup.ts @@ -1,16 +1,12 @@ import { resolveCommandPath } from '@switchdash/core/deps/runtime'; -import semver from 'semver'; import { SshExecutionContext } from '@main/core/execution-context/ssh-execution-context'; import { sshConnectionIdForHost } from '@main/core/locations/location-transport'; import { ensureSshConnected } from '@main/core/ssh/connect/connect-agent-ssh'; import { log } from '@main/lib/logger'; import { getPlugin, listPlugins } from '../providers/plugin-registry'; -import type { - MarketplaceListEntry, - SwitchSetupResult, - SwitchSetupStatus, -} from './switch-setup-service'; -import { marketplaceMatchesSource } from './switch-setup-service'; +import { cliRulesFor, type SwitchSetupCliRules } from './switch-setup-cli-dialect'; +import type { SwitchSetupResult, SwitchSetupStatus } from './switch-setup-service'; +import { isNewerVersion, marketplaceMatchesSource } from './switch-setup-service'; const EXEC_TIMEOUT_MS = 120_000; @@ -59,13 +55,6 @@ function parseJsonLoose(stdout: string): unknown { return null; } -function isNewerVersion(installed: string, latest: string): boolean { - const a = semver.coerce(installed); - const b = semver.coerce(latest); - if (a === null || b === null) return false; - return semver.gt(b, a); -} - /** * Remote counterpart of SwitchSetupService: drives an agent type's * plugin-marketplace CLI (` plugin install/update/...`) on an SSH host to @@ -83,7 +72,7 @@ export class RemoteSwitchSetupService { if (!binaryName) return null; const bin = (await resolveCommandPath(binaryName, this.ctx)) ?? binaryName; const ref = `${descriptor.pluginName}@${descriptor.marketplaceName}`; - return { descriptor, bin, ref }; + return { descriptor, bin, ref, rules: cliRulesFor(descriptor.dialect) }; } private async run(bin: string, args: string[]): Promise { @@ -106,30 +95,27 @@ export class RemoteSwitchSetupService { } } - private async findInstalled(bin: string, ref: string) { + private async findInstalled(bin: string, ref: string, rules: SwitchSetupCliRules) { const { stdout } = await this.run(bin, ['plugin', 'list', '--json']); - const parsed = parseJsonLoose(stdout); - if (parsed === null) return null; - const list: Array<{ id?: string; version?: string }> = Array.isArray(parsed) - ? parsed - : (((parsed as { installed?: unknown[] })?.installed ?? []) as never[]); - return list.find((p) => p.id === ref) ?? null; + return rules.parsePluginList(parseJsonLoose(stdout)).find((p) => p.ref === ref) ?? null; } + /** + * Advertised version from CLI output alone — no SFTP round-trip to read + * manifests. Null means "unknown", which callers must not read as up to date; + * a dialect that does not report versions for uninstalled plugins always + * returns null here. + */ private async advertisedVersion( bin: string, marketplaceName: string, - pluginName: string + pluginName: string, + rules: SwitchSetupCliRules ): Promise { const { stdout } = await this.run(bin, ['plugin', 'marketplace', 'list', '--json']); - const parsed = parseJsonLoose(stdout); - if (!Array.isArray(parsed)) return null; - const markets = parsed as Array<{ - name?: string; - plugins?: Array<{ name?: string; version?: string }>; - }>; - const market = markets.find((m) => m.name === marketplaceName); - return market?.plugins?.find((p) => p.name === pluginName)?.version ?? null; + return ( + rules.parseAdvertisedVersions(parseJsonLoose(stdout), marketplaceName).get(pluginName) ?? null + ); } /** @@ -141,18 +127,18 @@ export class RemoteSwitchSetupService { private async ensureMarketplace( bin: string, marketplaceName: string, - marketplaceSource: string + marketplaceSource: string, + rules: SwitchSetupCliRules ): Promise { const { stdout } = await this.run(bin, ['plugin', 'marketplace', 'list', '--json']); - const parsed = parseJsonLoose(stdout); - const existing = Array.isArray(parsed) - ? (parsed as MarketplaceListEntry[]).find((m) => m.name === marketplaceName) - : undefined; + const existing = rules + .parseMarketplaceList(parseJsonLoose(stdout)) + .find((m) => m.name === marketplaceName); if (existing) { if (marketplaceMatchesSource(existing, marketplaceSource)) return; log.warn('remote-switch-setup: re-pointing marketplace to current source', { marketplaceName, - from: existing.repo ?? existing.path ?? null, + from: existing.source, to: marketplaceSource, }); const removed = await this.run(bin, ['plugin', 'marketplace', 'remove', marketplaceName]); @@ -171,14 +157,15 @@ export class RemoteSwitchSetupService { async getStatus(agentId: string): Promise { const resolved = await this.resolve(agentId); if (!resolved) return unsupported(agentId); - const { descriptor, bin, ref } = resolved; + const { descriptor, bin, ref, rules } = resolved; - const entry = await this.findInstalled(bin, ref); + const entry = await this.findInstalled(bin, ref, rules); const installedVersion = entry?.version ?? null; const latestVersion = await this.advertisedVersion( bin, descriptor.marketplaceName, - descriptor.pluginName + descriptor.pluginName, + rules ); const installed = entry !== null; const updateAvailable = @@ -205,16 +192,16 @@ export class RemoteSwitchSetupService { async checkForUpdates(agentId: string): Promise { const resolved = await this.resolve(agentId); if (!resolved) return unsupported(agentId); - const { descriptor, bin } = resolved; + const { descriptor, bin, rules } = resolved; let refreshError: string | null = null; try { - await this.ensureMarketplace(bin, descriptor.marketplaceName, descriptor.marketplaceSource); - const res = await this.run(bin, [ - 'plugin', - 'marketplace', - 'update', + await this.ensureMarketplace( + bin, descriptor.marketplaceName, - ]); + descriptor.marketplaceSource, + rules + ); + const res = await this.run(bin, rules.marketplaceRefreshArgs(descriptor.marketplaceName)); if (res.code !== 0) { throw new Error( res.stderr.trim() || `Failed to update marketplace ${descriptor.marketplaceName}` @@ -231,27 +218,71 @@ export class RemoteSwitchSetupService { const resolved = await this.resolve(agentId); if (!resolved) return { success: false, message: 'Switch setup is not supported for this agent.' }; - const { descriptor, bin, ref } = resolved; + const { descriptor, bin, ref, rules } = resolved; try { - await this.ensureMarketplace(bin, descriptor.marketplaceName, descriptor.marketplaceSource); + await this.ensureMarketplace( + bin, + descriptor.marketplaceName, + descriptor.marketplaceSource, + rules + ); } catch (err) { return { success: false, message: `Could not add marketplace: ${String(err)}` }; } - const res = await this.run(bin, ['plugin', 'install', ref, '-s', descriptor.scope]); + const res = await this.run(bin, rules.installArgs(ref, descriptor.scope)); return res.code === 0 ? { success: true } : { success: false, message: res.stderr.trim() || 'Install failed.' }; } + /** + * The marketplace is repaired first, exactly as `install` does: the re-add + * below resolves against whatever marketplace is registered, so a stale source + * would otherwise fail it after the uninstall has already succeeded. + */ async update(agentId: string): Promise { const resolved = await this.resolve(agentId); if (!resolved) return { success: false, message: 'Switch setup is not supported for this agent.' }; - const { descriptor, bin, ref } = resolved; - const res = await this.run(bin, ['plugin', 'update', ref, '-s', descriptor.scope]); - return res.code === 0 + const { descriptor, bin, ref, rules } = resolved; + + try { + await this.ensureMarketplace( + bin, + descriptor.marketplaceName, + descriptor.marketplaceSource, + rules + ); + } catch (err) { + return { success: false, message: `Could not add marketplace: ${String(err)}` }; + } + + const updateArgs = rules.updateArgs(ref, descriptor.scope); + if (updateArgs) { + const res = await this.run(bin, updateArgs); + return res.code === 0 + ? { success: true } + : { success: false, message: res.stderr.trim() || 'Update failed.' }; + } + + // No per-plugin update verb (Codex): remove then re-add. A failed re-add + // leaves the host with no connector, so say that rather than 'Update failed'. + const removed = await this.run(bin, rules.uninstallArgs(ref, descriptor.scope)); + if (removed.code !== 0) { + return { + success: false, + message: removed.stderr.trim() || 'Update failed: could not remove the installed plugin.', + }; + } + const added = await this.run(bin, rules.installArgs(ref, descriptor.scope)); + return added.code === 0 ? { success: true } - : { success: false, message: res.stderr.trim() || 'Update failed.' }; + : { + success: false, + message: + added.stderr.trim() || + 'Update failed: the plugin was removed but could not be reinstalled. Install it again for this host.', + }; } /** Status of every Switch-supported agent type's connector plugin on this host. */ diff --git a/dash/apps/switchdash-desktop/src/main/core/switch-setup/switch-setup-cli-dialect.test.ts b/dash/apps/switchdash-desktop/src/main/core/switch-setup/switch-setup-cli-dialect.test.ts new file mode 100644 index 000000000..ecb01bd1a --- /dev/null +++ b/dash/apps/switchdash-desktop/src/main/core/switch-setup/switch-setup-cli-dialect.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from 'vitest'; +import { cliRulesFor } from './switch-setup-cli-dialect'; + +/** + * Verbatim `codex plugin list --json` output, captured from Codex CLI 0.145.0. + * Kept literal so a future Codex change that renames a field fails here rather + * than silently reporting the connector as never installed. + */ +const CODEX_PLUGIN_LIST = JSON.stringify({ + installed: [ + { + pluginId: 'switch-connector-codex@switch-plugins', + name: 'switch-connector-codex', + marketplaceName: 'switch-plugins', + version: '0.1.0', + installed: true, + enabled: true, + source: { source: 'local', path: '/repo/connectors/codex-plugin' }, + }, + ], + available: [], +}); + +/** Verbatim `codex plugin marketplace list --json` output (Codex CLI 0.145.0). */ +const CODEX_MARKETPLACE_LIST = JSON.stringify({ + marketplaces: [ + { + name: 'switch-plugins', + root: '/repo', + marketplaceSource: { sourceType: 'local', source: '/repo' }, + }, + ], +}); + +describe('codex dialect', () => { + const rules = cliRulesFor('codex'); + + it('reads the installed plugin from the object-wrapped list', () => { + // Claude's shape uses `id`/`installPath`; Codex uses `pluginId`/`source.path`. + // Parsing Codex output with Claude's reader yields nothing, which is how the + // connector would look permanently uninstalled without this dialect. + // + // `manifestPath` is null on purpose: `source.path` is the marketplace source + // directory, not the install directory, so a manifest read there reports the + // advertised version as the installed one and a stale install looks current. + expect(rules.parsePluginList(JSON.parse(CODEX_PLUGIN_LIST))).toEqual([ + { + ref: 'switch-connector-codex@switch-plugins', + version: '0.1.0', + manifestPath: null, + }, + ]); + expect(cliRulesFor('claude-code').parsePluginList(JSON.parse(CODEX_PLUGIN_LIST))).toEqual([]); + }); + + it('drops a disabled plugin, which Codex lists but does not load', () => { + const disabled = JSON.parse(CODEX_PLUGIN_LIST) as { + installed: Array<{ enabled: boolean }>; + }; + disabled.installed[0].enabled = false; + + expect(rules.parsePluginList(disabled)).toEqual([]); + }); + + it('reads marketplaces from the object-wrapped list', () => { + expect(rules.parseMarketplaceList(JSON.parse(CODEX_MARKETPLACE_LIST))).toEqual([ + { name: 'switch-plugins', source: '/repo', root: '/repo' }, + ]); + }); + + it('uses add/remove, no scope flag, and has no per-plugin update verb', () => { + expect(rules.installArgs('p@m', 'user')).toEqual(['plugin', 'add', 'p@m']); + expect(rules.uninstallArgs('p@m', 'user')).toEqual(['plugin', 'remove', 'p@m']); + // Null is the signal for callers to fall back to remove-then-add. + expect(rules.updateArgs('p@m', 'user')).toBeNull(); + expect(rules.marketplaceRefreshArgs('m')).toEqual(['plugin', 'marketplace', 'upgrade', 'm']); + }); + + it("reports no advertised versions, since Codex's marketplace listing carries none", () => { + expect( + rules.parseAdvertisedVersions(JSON.parse(CODEX_MARKETPLACE_LIST), 'switch-plugins').size + ).toBe(0); + }); + + it('looks for plugin manifests under .codex-plugin but marketplaces under .claude-plugin', () => { + // Codex reads a Claude-style marketplace manifest, which is what lets one + // marketplace file serve both CLIs. + expect(rules.pluginManifestDir).toBe('.codex-plugin'); + expect(rules.marketplaceManifestDir).toBe('.claude-plugin'); + }); +}); + +describe('claude-code dialect', () => { + const rules = cliRulesFor('claude-code'); + + it('reads a bare array or an {installed} wrapper', () => { + const entry = { id: 'p@m', version: '1.2.3', installPath: '/cache/p' }; + const expected = [{ ref: 'p@m', version: '1.2.3', manifestPath: '/cache/p' }]; + expect(rules.parsePluginList([entry])).toEqual(expected); + expect(rules.parsePluginList({ installed: [entry] })).toEqual(expected); + }); + + it('matches a marketplace on either repo or path', () => { + expect( + rules.parseMarketplaceList([{ name: 'm', repo: 'owner/repo', installLocation: '/loc' }]) + ).toEqual([{ name: 'm', source: 'owner/repo', root: '/loc' }]); + expect(rules.parseMarketplaceList([{ name: 'm', path: '/src' }])).toEqual([ + { name: 'm', source: '/src', root: null }, + ]); + }); + + it('keeps install/uninstall/update with the scope flag', () => { + expect(rules.installArgs('p@m', 'user')).toEqual(['plugin', 'install', 'p@m', '-s', 'user']); + expect(rules.updateArgs('p@m', 'local')).toEqual(['plugin', 'update', 'p@m', '-s', 'local']); + expect(rules.marketplaceRefreshArgs('m')).toEqual(['plugin', 'marketplace', 'update', 'm']); + }); +}); + +describe('parser robustness', () => { + // These feed status reads on the settings page. Unexpected output must degrade + // to "nothing found" rather than throw — a crash here blanks the whole page. + it.each(['claude-code', 'codex'] as const)('%s tolerates junk input', (dialect) => { + const rules = cliRulesFor(dialect); + for (const junk of [null, undefined, 'not json', 42, [], {}, { installed: 'nope' }]) { + expect(rules.parsePluginList(junk)).toEqual([]); + expect(rules.parseMarketplaceList(junk)).toEqual([]); + } + }); + + it('drops entries missing the identifying field rather than emitting partials', () => { + expect(cliRulesFor('codex').parsePluginList({ installed: [{ version: '1.0.0' }] })).toEqual([]); + expect(cliRulesFor('claude-code').parsePluginList([{ version: '1.0.0' }])).toEqual([]); + }); + + it('fails loudly on an unknown dialect', () => { + expect(() => cliRulesFor('nope' as never)).toThrow(/No plugin-CLI rules for dialect/); + }); +}); diff --git a/dash/apps/switchdash-desktop/src/main/core/switch-setup/switch-setup-cli-dialect.ts b/dash/apps/switchdash-desktop/src/main/core/switch-setup/switch-setup-cli-dialect.ts new file mode 100644 index 000000000..63fb2b9f2 --- /dev/null +++ b/dash/apps/switchdash-desktop/src/main/core/switch-setup/switch-setup-cli-dialect.ts @@ -0,0 +1,190 @@ +import type { SwitchSetupCliDialect } from '@switchdash/core/agents/plugins'; + +/** + * One installed plugin, normalised across CLI dialects. + * + * `ref` is the `@` identifier used to address the plugin in + * install/remove commands. `manifestPath` is a directory that should contain the + * plugin manifest, when the CLI reports one worth reading. + */ +export type InstalledPlugin = { + ref: string; + version: string | null; + manifestPath: string | null; +}; + +/** One registered marketplace, normalised across CLI dialects. */ +export type RegisteredMarketplace = { + name: string; + /** The source it was registered from (a repo slug or a local path). */ + source: string | null; + /** Local root of the marketplace checkout, where its manifest lives. */ + root: string | null; +}; + +/** + * The surface differences between plugin-marketplace CLIs. + * + * Both dialects share the same model, so the driver stays generic; everything + * that genuinely differs — verbs, flags, and the JSON each command emits — is + * named here. Parsers are total: unparseable or unexpected output yields an + * empty list rather than throwing, because these feed status reads that must + * degrade to "not installed" rather than crash the settings page. + */ +export type SwitchSetupCliRules = { + /** Directory holding a plugin's manifest, relative to the plugin root. */ + pluginManifestDir: string; + /** Directory holding a marketplace's manifest, relative to its root. */ + marketplaceManifestDir: string; + installArgs(ref: string, scope: string): string[]; + uninstallArgs(ref: string, scope: string): string[]; + /** + * Args to update an installed plugin in place, or null when the CLI has no + * such verb — the driver then falls back to uninstall-then-install. + */ + updateArgs(ref: string, scope: string): string[] | null; + /** Args to refresh a marketplace snapshot. */ + marketplaceRefreshArgs(marketplaceName: string): string[]; + /** + * Shape-readers over already-parsed CLI JSON. Callers own extraction — the + * local driver can `JSON.parse` directly, while the remote one must first + * strip login-shell banner noise — so the dialect only owns the shape. + */ + parsePluginList(parsed: unknown): InstalledPlugin[]; + parseMarketplaceList(parsed: unknown): RegisteredMarketplace[]; + /** + * Versions the marketplace advertises, keyed by plugin name. Fed parsed + * `plugin marketplace list --json` output and nothing else — plugin-list + * output never carries advertised versions — so the remote driver can read + * them without a filesystem round-trip. An empty map means "unknown", which + * callers must treat as "no update detected" rather than "up to date". + */ + parseAdvertisedVersions(parsed: unknown, marketplaceName: string): Map; +}; + +const claudeCode: SwitchSetupCliRules = { + pluginManifestDir: '.claude-plugin', + marketplaceManifestDir: '.claude-plugin', + installArgs: (ref, scope) => ['plugin', 'install', ref, '-s', scope], + uninstallArgs: (ref, scope) => ['plugin', 'uninstall', ref, '-s', scope], + updateArgs: (ref, scope) => ['plugin', 'update', ref, '-s', scope], + marketplaceRefreshArgs: (name) => ['plugin', 'marketplace', 'update', name], + + parsePluginList(parsed) { + const list: unknown = Array.isArray(parsed) + ? parsed + : ((parsed as { installed?: unknown } | null)?.installed ?? []); + if (!Array.isArray(list)) return []; + return list.flatMap((raw) => { + const e = raw as { id?: string; version?: string; installPath?: string }; + if (typeof e.id !== 'string') return []; + return [{ ref: e.id, version: e.version ?? null, manifestPath: e.installPath ?? null }]; + }); + }, + + parseMarketplaceList(parsed) { + if (!Array.isArray(parsed)) return []; + return parsed.flatMap((raw) => { + const e = raw as { name?: string; repo?: string; path?: string; installLocation?: string }; + if (typeof e.name !== 'string') return []; + return [{ name: e.name, source: e.repo ?? e.path ?? null, root: e.installLocation ?? null }]; + }); + }, + + parseAdvertisedVersions(parsed, marketplaceName) { + if (!Array.isArray(parsed)) return new Map(); + const market = ( + parsed as Array<{ name?: string; plugins?: Array<{ name?: string; version?: string }> }> + ).find((m) => m.name === marketplaceName); + const versions = new Map(); + for (const p of market?.plugins ?? []) { + if (typeof p.name === 'string' && typeof p.version === 'string') + versions.set(p.name, p.version); + } + return versions; + }, +}; + +/** + * Codex's `plugin` subcommand. Verified against Codex CLI 0.145.0: + * `add`/`remove` rather than `install`/`uninstall`, no scope flag, no per-plugin + * update verb, `marketplace upgrade` rather than `update`, and both list + * commands return an object rather than a bare array. Its plugin manifests live + * under `.codex-plugin/`, but it reads a marketplace manifest from + * `.claude-plugin/` — so one marketplace file serves both CLIs. + */ +const codex: SwitchSetupCliRules = { + pluginManifestDir: '.codex-plugin', + marketplaceManifestDir: '.claude-plugin', + installArgs: (ref) => ['plugin', 'add', ref], + uninstallArgs: (ref) => ['plugin', 'remove', ref], + updateArgs: () => null, + marketplaceRefreshArgs: (name) => ['plugin', 'marketplace', 'upgrade', name], + + parsePluginList(parsed) { + const list = (parsed as { installed?: unknown } | null)?.installed; + if (!Array.isArray(list)) return []; + return list.flatMap((raw) => { + const e = raw as { + pluginId?: string; + version?: string; + enabled?: boolean; + source?: { path?: string }; + }; + if (typeof e.pluginId !== 'string') return []; + // Codex lists a disabled plugin among the installed ones, but does not + // load its skill. Treating that as installed would offer the agent type + // for onboarding with its Switch tooling inert, so drop it and let the + // caller report the connector as absent — which is what it is, in effect. + if (e.enabled === false) return []; + // No manifest path. Codex copies the plugin into a versioned cache + // (`/plugins/cache////`) but + // reports `source.path` as the marketplace SOURCE directory, so reading a + // manifest there yields the version the marketplace currently advertises, + // not the one installed. Handing that back as the installed version makes + // a stale install report itself up to date. `version` is the CLI's own + // account of what it installed, which is what this returns. + return [{ ref: e.pluginId, version: e.version ?? null, manifestPath: null }]; + }); + }, + + parseMarketplaceList(parsed) { + const list = (parsed as { marketplaces?: unknown } | null)?.marketplaces; + if (!Array.isArray(list)) return []; + return list.flatMap((raw) => { + const e = raw as { name?: string; root?: string; marketplaceSource?: { source?: string } }; + if (typeof e.name !== 'string') return []; + return [{ name: e.name, source: e.marketplaceSource?.source ?? null, root: e.root ?? null }]; + }); + }, + + /** + * Codex's marketplace listing carries no plugin versions at all, so there is + * no advertised version to compare against from CLI output alone. Callers get + * an empty map, i.e. "unknown", and must not read that as "up to date". + * + * This only limits the remote driver. Locally, `advertisedVersion` reads the + * marketplace's on-disk manifests instead, which works for both dialects. + */ + parseAdvertisedVersions() { + return new Map(); + }, +}; + +const RULES: Record = { + 'claude-code': claudeCode, + codex, +}; + +export function cliRulesFor(dialect: SwitchSetupCliDialect): SwitchSetupCliRules { + const rules = RULES[dialect]; + if (!rules) { + // Unreachable via a validated descriptor, but a missing entry would + // otherwise surface as an opaque "cannot read properties of undefined" + // several frames away from the actual cause. + throw new Error( + `No plugin-CLI rules for dialect '${dialect}'. Add an entry to the dialect table.` + ); + } + return rules; +} diff --git a/dash/apps/switchdash-desktop/src/main/core/switch-setup/switch-setup-service.test.ts b/dash/apps/switchdash-desktop/src/main/core/switch-setup/switch-setup-service.test.ts index 527d500c5..b0b893d4b 100644 --- a/dash/apps/switchdash-desktop/src/main/core/switch-setup/switch-setup-service.test.ts +++ b/dash/apps/switchdash-desktop/src/main/core/switch-setup/switch-setup-service.test.ts @@ -42,22 +42,47 @@ const CLI_AGENT = { marketplaceName: 'switch-plugins', marketplaceSource: 'sandbox-quantum/switch', scope: 'user', + dialect: 'claude-code', }, hostDependency: { binaryNames: ['claude'] }, }, }; -const NONE_AGENT = { +const CODEX_AGENT = { metadata: { id: 'codex' }, capabilities: { - switchSetup: { kind: 'none' }, + switchSetup: { + kind: 'cli', + pluginName: 'switch-connector-codex', + marketplaceName: 'switch-plugins', + marketplaceSource: 'sandbox-quantum/switch', + scope: 'user', + dialect: 'codex', + }, hostDependency: { binaryNames: ['codex'] }, }, }; +const NONE_AGENT = { + metadata: { id: 'no-switch-agent' }, + capabilities: { + switchSetup: { kind: 'none' }, + hostDependency: { binaryNames: ['nosw'] }, + }, +}; + const INSTALL_PATH = '/cache/switch-plugins/switch-connector/0.1.0'; const MARKET_LOCATION = '/marketplaces/switch-plugins'; +const CODEX_REF = 'switch-connector-codex@switch-plugins'; +// Codex reports `source.path` as the marketplace SOURCE directory, not a +// per-install cache — see the verbatim 0.145.0 capture in +// switch-setup-cli-dialect.test.ts. For a local-path marketplace the installed +// plugin therefore IS the checkout, and the installed and advertised manifests +// resolve to the same file. +const CODEX_MARKET_ROOT = '/repo'; +const CODEX_INSTALL_PATH = `${CODEX_MARKET_ROOT}/connectors/codex-plugin`; + /** Default happy-path exec: installed 0.1.0, marketplace present. */ function execImpl(installedVersion: string | null) { return (_bin: string, args: string[] = []) => { @@ -93,6 +118,75 @@ function execImpl(installedVersion: string | null) { }; } +/** + * Codex's CLI wraps both listings in an object and names its fields differently + * (`pluginId`/`source.path`, `marketplaces`/`marketplaceSource`), so the shapes + * are spelled out here rather than reusing the Claude fixtures. + */ +function codexExecImpl(installedVersion: string | null) { + return (_bin: string, args: string[] = []) => { + const a = args.join(' '); + if (a === 'plugin list --json') { + return Promise.resolve({ + stdout: JSON.stringify({ + installed: + installedVersion === null + ? [] + : [ + { + pluginId: CODEX_REF, + name: 'switch-connector-codex', + marketplaceName: 'switch-plugins', + version: installedVersion, + installed: true, + enabled: true, + source: { source: 'local', path: CODEX_INSTALL_PATH }, + }, + ], + available: [], + }), + stderr: '', + }); + } + if (a === 'plugin marketplace list --json') { + return Promise.resolve({ + stdout: JSON.stringify({ + marketplaces: [ + { + name: 'switch-plugins', + root: CODEX_MARKET_ROOT, + marketplaceSource: { sourceType: 'github', source: 'sandbox-quantum/switch' }, + }, + ], + }), + stderr: '', + }); + } + return Promise.resolve({ stdout: '', stderr: '' }); + }; +} + +function codexReadFileImpl(manifestVersion: string) { + return (path: string) => { + if (path === `${CODEX_INSTALL_PATH}/.codex-plugin/plugin.json`) { + return Promise.resolve(JSON.stringify({ version: manifestVersion })); + } + // Present, and wrong, so a reader that went to Claude's manifest dir is + // caught rather than accidentally passing. + if (path === `${CODEX_INSTALL_PATH}/.claude-plugin/plugin.json`) { + return Promise.resolve(JSON.stringify({ version: '9.9.9' })); + } + if (path === `${CODEX_MARKET_ROOT}/.claude-plugin/marketplace.json`) { + return Promise.resolve( + JSON.stringify({ + plugins: [{ name: 'switch-connector-codex', source: './connectors/codex-plugin' }], + }) + ); + } + return Promise.reject(new Error('ENOENT')); + }; +} + function readFileImpl(installedManifestVersion: string, advertisedVersion: string) { return (path: string) => { if (path === `${INSTALL_PATH}/.claude-plugin/plugin.json`) { @@ -121,7 +215,7 @@ beforeEach(() => { describe('switchSetupService.getStatus', () => { it('reports unsupported for agents with kind: none', async () => { mocks.getPlugin.mockReturnValue(NONE_AGENT); - const status = await switchSetupService.getStatus('codex'); + const status = await switchSetupService.getStatus('no-switch-agent'); expect(status.supported).toBe(false); expect(status.installed).toBe(false); expect(mocks.exec).not.toHaveBeenCalled(); @@ -212,7 +306,7 @@ describe('switchSetupService.checkForUpdates', () => { { name: 'switch-plugins', source: 'github', - repo: 'sandbox-quantum/napoleon', + repo: 'sandbox-quantum/switch-legacy', installLocation: MARKET_LOCATION, }, ]), @@ -325,3 +419,205 @@ describe('switchSetupService mutations', () => { expect(result.message).toBe('no write access'); }); }); + +describe('switchSetupService with the codex dialect', () => { + function calls(): string[] { + return mocks.exec.mock.calls.map((c) => (c[1] as string[]).join(' ')); + } + + beforeEach(() => { + mocks.getPlugin.mockReturnValue(CODEX_AGENT); + mocks.resolveCommandPath.mockResolvedValue('/usr/bin/codex'); + }); + + it('takes the installed version from the CLI and the advertised one from the manifest', async () => { + // Codex copies the plugin into a versioned cache but reports `source.path` + // as the marketplace SOURCE directory. Reading a manifest there would give + // the advertised version and report it as installed, so a stale install + // would claim to be up to date. The CLI's own `version` is the installed + // one; the marketplace manifest under `.codex-plugin/` is the advertised + // one. Claude's `.claude-plugin/plugin.json` sits alongside reporting + // 9.9.9, so a reader that went to the wrong manifest dir fails here. + mocks.exec.mockImplementation(codexExecImpl('0.1.0')); + mocks.readFile.mockImplementation(codexReadFileImpl('0.2.0')); + + const status = await switchSetupService.getStatus('codex'); + + expect(status).toMatchObject({ + supported: true, + installed: true, + installedVersion: '0.1.0', + latestVersion: '0.2.0', + updateAvailable: true, + }); + }); + + it('reports up to date when the installed version matches the marketplace', async () => { + mocks.exec.mockImplementation(codexExecImpl('0.2.0')); + mocks.readFile.mockImplementation(codexReadFileImpl('0.2.0')); + + expect(await switchSetupService.getStatus('codex')).toMatchObject({ + installedVersion: '0.2.0', + latestVersion: '0.2.0', + updateAvailable: false, + }); + }); + + it('reports not-installed when the object-wrapped list is empty', async () => { + mocks.exec.mockImplementation(codexExecImpl(null)); + mocks.readFile.mockImplementation(codexReadFileImpl('0.2.0')); + + const status = await switchSetupService.getStatus('codex'); + + expect(status).toMatchObject({ + supported: true, + installed: false, + installedVersion: null, + updateAvailable: false, + }); + }); + + it('installs with add and no scope flag', async () => { + mocks.exec.mockImplementation(codexExecImpl(null)); + + const result = await switchSetupService.install('codex'); + + expect(result.success).toBe(true); + expect(mocks.exec).toHaveBeenCalledWith( + '/usr/bin/codex', + ['plugin', 'add', CODEX_REF], + expect.anything() + ); + }); + + it('uninstalls with remove and no scope flag', async () => { + mocks.exec.mockImplementation(codexExecImpl('0.1.0')); + + const result = await switchSetupService.uninstall('codex'); + + expect(result.success).toBe(true); + expect(mocks.exec).toHaveBeenCalledWith( + '/usr/bin/codex', + ['plugin', 'remove', CODEX_REF], + expect.anything() + ); + }); + + it('refreshes the marketplace with upgrade rather than update', async () => { + mocks.exec.mockImplementation(codexExecImpl('0.1.0')); + mocks.readFile.mockImplementation(codexReadFileImpl('0.1.0')); + + const status = await switchSetupService.checkForUpdates('codex'); + + expect(status.refreshError).toBeNull(); + expect(calls()).toContain('plugin marketplace upgrade switch-plugins'); + expect(calls()).not.toContain('plugin marketplace update switch-plugins'); + }); + + it('repairs a stale marketplace before removing the installed plugin', async () => { + // The destructive branch: with no per-plugin update verb, a marketplace still + // pointing at a pre-migration source would fail the re-add *after* the remove + // succeeded, leaving no connector at all. + const base = codexExecImpl('0.1.0'); + mocks.exec.mockImplementation((bin: string, args: string[] = []) => { + if (args.join(' ') === 'plugin marketplace list --json') { + return Promise.resolve({ + stdout: JSON.stringify({ + marketplaces: [ + { + name: 'switch-plugins', + root: CODEX_MARKET_ROOT, + marketplaceSource: { + sourceType: 'github', + source: 'sandbox-quantum/switch-legacy', + }, + }, + ], + }), + stderr: '', + }); + } + return base(bin, args); + }); + + const result = await switchSetupService.update('codex'); + + expect(result.success).toBe(true); + const seen = calls(); + expect(seen).toContain('plugin marketplace remove switch-plugins'); + expect(seen).toContain('plugin marketplace add sandbox-quantum/switch'); + expect(seen.indexOf('plugin marketplace add sandbox-quantum/switch')).toBeLessThan( + seen.indexOf(`plugin remove ${CODEX_REF}`) + ); + }); + + it('reports a marketplace failure without removing the installed plugin', async () => { + mocks.exec.mockImplementation((_bin: string, args: string[] = []) => { + const a = args.join(' '); + if (a === 'plugin marketplace add sandbox-quantum/switch') { + return Promise.reject( + Object.assign(new Error('exit 1'), { code: 1, stderr: 'no network' }) + ); + } + return Promise.resolve({ stdout: '', stderr: '' }); + }); + + const result = await switchSetupService.update('codex'); + + expect(result.success).toBe(false); + expect(result.message).toMatch(/Could not add marketplace/); + // Repairing first is what keeps this safe: nothing destructive ran. + expect(calls()).not.toContain(`plugin remove ${CODEX_REF}`); + }); + + it('surfaces a refreshError when the codex marketplace upgrade fails', async () => { + const base = codexExecImpl('0.1.0'); + mocks.exec.mockImplementation((bin: string, args: string[] = []) => { + if (args.join(' ') === 'plugin marketplace upgrade switch-plugins') { + return Promise.reject(Object.assign(new Error('exit 1'), { code: 1, stderr: 'offline' })); + } + return base(bin, args); + }); + mocks.readFile.mockImplementation(codexReadFileImpl('0.1.0')); + + const status = await switchSetupService.checkForUpdates('codex'); + + expect(status.refreshError).toMatch(/offline/); + expect(status.installed).toBe(true); + }); + + it('updates by removing then re-adding, in that order', async () => { + mocks.exec.mockImplementation(codexExecImpl('0.1.0')); + + const result = await switchSetupService.update('codex'); + + expect(result.success).toBe(true); + // The marketplace is repaired first: the re-add resolves against it, so a + // stale source must not be discovered after the remove has succeeded. + expect(calls()).toEqual([ + 'plugin marketplace list --json', + `plugin remove ${CODEX_REF}`, + `plugin add ${CODEX_REF}`, + ]); + }); + + it('reports the plugin as removed-but-not-reinstalled when the re-add fails', async () => { + mocks.exec.mockImplementation((_bin: string, args: string[] = []) => { + if (args.join(' ') === `plugin add ${CODEX_REF}`) { + // With no stderr to relay, our own wording is all the user gets — and it + // has to say the host now has no connector, not just "update failed". + return Promise.reject(Object.assign(new Error('exit 1'), { code: 1, stderr: '' })); + } + return Promise.resolve({ stdout: '', stderr: '' }); + }); + + const result = await switchSetupService.update('codex'); + + expect(calls().slice(-2)).toEqual([`plugin remove ${CODEX_REF}`, `plugin add ${CODEX_REF}`]); + expect(result).toEqual({ + success: false, + message: + 'Update failed: the plugin was removed but could not be reinstalled. Install it again from Settings → Agents.', + }); + }); +}); diff --git a/dash/apps/switchdash-desktop/src/main/core/switch-setup/switch-setup-service.ts b/dash/apps/switchdash-desktop/src/main/core/switch-setup/switch-setup-service.ts index 8c838ef63..5b7ce4474 100644 --- a/dash/apps/switchdash-desktop/src/main/core/switch-setup/switch-setup-service.ts +++ b/dash/apps/switchdash-desktop/src/main/core/switch-setup/switch-setup-service.ts @@ -5,6 +5,12 @@ import semver from 'semver'; import { LocalExecutionContext } from '@main/core/execution-context/local-execution-context'; import { log } from '@main/lib/logger'; import { getPlugin, listPlugins } from '../providers/plugin-registry'; +import { + cliRulesFor, + type InstalledPlugin, + type RegisteredMarketplace, + type SwitchSetupCliRules, +} from './switch-setup-cli-dialect'; /** Status of an agent type's Switch connector plugin. */ export type SwitchSetupStatus = { @@ -23,18 +29,9 @@ export type SwitchSetupStatus = { refreshError: string | null; }; -/** Marketplace entry shape from `plugin marketplace list --json`. */ -export type MarketplaceListEntry = { - name?: string; - source?: string; - repo?: string; - path?: string; - installLocation?: string; -}; - -/** Whether a registered marketplace entry points at the expected source (repo or path). */ -export function marketplaceMatchesSource(entry: MarketplaceListEntry, source: string): boolean { - return entry.repo === source || entry.path === source; +/** Whether a registered marketplace entry points at the expected source. */ +export function marketplaceMatchesSource(entry: RegisteredMarketplace, source: string): boolean { + return entry.source === source; } /** Outcome of a mutating operation, mirroring the providers controller shape. */ @@ -44,6 +41,15 @@ const EXEC_TIMEOUT_MS = 120_000; type RunResult = { code: number; stdout: string; stderr: string }; +/** Parse CLI JSON, yielding null rather than throwing on unparseable output. */ +function parseJsonOrNull(stdout: string): unknown { + try { + return JSON.parse(stdout); + } catch { + return null; + } +} + function unsupported(agentId: string): SwitchSetupStatus { return { agentId, @@ -56,7 +62,8 @@ function unsupported(agentId: string): SwitchSetupStatus { }; } -function isNewerVersion(installed: string, latest: string): boolean { +/** Whether `latest` is a newer semver than `installed`; false when either is unparseable. */ +export function isNewerVersion(installed: string, latest: string): boolean { const a = semver.coerce(installed); const b = semver.coerce(latest); if (a === null || b === null) return false; @@ -81,7 +88,7 @@ class SwitchSetupService { if (!binaryName) return null; const bin = (await resolveCommandPath(binaryName, this.ctx)) ?? binaryName; const ref = `${descriptor.pluginName}@${descriptor.marketplaceName}`; - return { descriptor, bin, ref }; + return { descriptor, bin, ref, rules: cliRulesFor(descriptor.dialect) }; } /** Run a CLI command, capturing output and exit code without throwing. */ @@ -103,31 +110,25 @@ class SwitchSetupService { } } - /** Find the installed plugin entry from `plugin list --json` (array or {installed}). */ - private async findInstalled(bin: string, ref: string) { + /** Find the installed plugin entry from `plugin list --json`. */ + private async findInstalled( + bin: string, + ref: string, + rules: SwitchSetupCliRules + ): Promise { const { stdout } = await this.run(bin, ['plugin', 'list', '--json']); - let parsed: unknown; - try { - parsed = JSON.parse(stdout); - } catch { - return null; - } - const list: Array<{ id?: string; version?: string; installPath?: string }> = Array.isArray( - parsed - ) - ? parsed - : (((parsed as { installed?: unknown[] })?.installed ?? []) as never[]); - return list.find((p) => p.id === ref) ?? null; + return rules.parsePluginList(parseJsonOrNull(stdout)).find((p) => p.ref === ref) ?? null; } - /** Read the true installed version from the install dir's plugin.json (robust). */ + /** Read the true installed version from the plugin manifest, falling back to the CLI's. */ private async installedVersion( - entry: { version?: string; installPath?: string } | null + entry: InstalledPlugin | null, + rules: SwitchSetupCliRules ): Promise { if (!entry) return null; - if (entry.installPath) { + if (entry.manifestPath) { const manifest = await this.readJson<{ version?: string }>( - join(entry.installPath, '.claude-plugin', 'plugin.json') + join(entry.manifestPath, rules.pluginManifestDir, 'plugin.json') ); if (manifest?.version) return manifest.version; } @@ -138,24 +139,21 @@ class SwitchSetupService { private async advertisedVersion( bin: string, marketplaceName: string, - pluginName: string + pluginName: string, + rules: SwitchSetupCliRules ): Promise { const { stdout } = await this.run(bin, ['plugin', 'marketplace', 'list', '--json']); - let markets: Array<{ name?: string; installLocation?: string }>; - try { - markets = JSON.parse(stdout); - } catch { - return null; - } - const market = markets.find((m) => m.name === marketplaceName); - if (!market?.installLocation) return null; + const market = rules + .parseMarketplaceList(parseJsonOrNull(stdout)) + .find((m) => m.name === marketplaceName && m.root !== null); + if (!market?.root) return null; const manifest = await this.readJson<{ plugins?: Array<{ name?: string; source?: string }> }>( - join(market.installLocation, '.claude-plugin', 'marketplace.json') + join(market.root, rules.marketplaceManifestDir, 'marketplace.json') ); const entry = manifest?.plugins?.find((p) => p.name === pluginName); if (!entry?.source) return null; const pluginManifest = await this.readJson<{ version?: string }>( - join(market.installLocation, entry.source, '.claude-plugin', 'plugin.json') + join(market.root, entry.source, rules.pluginManifestDir, 'plugin.json') ); return pluginManifest?.version ?? null; } @@ -169,31 +167,27 @@ class SwitchSetupService { private async ensureMarketplace( bin: string, marketplaceName: string, - marketplaceSource: string + marketplaceSource: string, + rules: SwitchSetupCliRules ): Promise { const { stdout } = await this.run(bin, ['plugin', 'marketplace', 'list', '--json']); - try { - const markets: MarketplaceListEntry[] = JSON.parse(stdout); - const existing = markets.find((m) => m.name === marketplaceName); - if (existing) { - if (marketplaceMatchesSource(existing, marketplaceSource)) return; - log.warn('switch-setup: re-pointing marketplace to current source', { - marketplaceName, - from: existing.repo ?? existing.path ?? null, - to: marketplaceSource, - }); - const removed = await this.run(bin, ['plugin', 'marketplace', 'remove', marketplaceName]); - if (removed.code !== 0) { - throw new Error( - removed.stderr.trim() || `Failed to remove stale marketplace ${marketplaceName}` - ); - } - } - } catch (err) { - if (err instanceof SyntaxError) { - // Unparseable list output — fall through and attempt to add. - } else { - throw err; + // An unreadable listing yields no entries; the add below is idempotent, so + // attempting it is safer than treating an unparseable listing as fatal. + const existing = rules + .parseMarketplaceList(parseJsonOrNull(stdout)) + .find((m) => m.name === marketplaceName); + if (existing) { + if (marketplaceMatchesSource(existing, marketplaceSource)) return; + log.warn('switch-setup: re-pointing marketplace to current source', { + marketplaceName, + from: existing.source, + to: marketplaceSource, + }); + const removed = await this.run(bin, ['plugin', 'marketplace', 'remove', marketplaceName]); + if (removed.code !== 0) { + throw new Error( + removed.stderr.trim() || `Failed to remove stale marketplace ${marketplaceName}` + ); } } const res = await this.run(bin, ['plugin', 'marketplace', 'add', marketplaceSource]); @@ -206,14 +200,15 @@ class SwitchSetupService { async getStatus(agentId: string): Promise { const resolved = await this.resolve(agentId); if (!resolved) return unsupported(agentId); - const { descriptor, bin } = resolved; + const { descriptor, bin, rules } = resolved; - const entry = await this.findInstalled(bin, resolved.ref); - const installedVersion = await this.installedVersion(entry); + const entry = await this.findInstalled(bin, resolved.ref, rules); + const installedVersion = await this.installedVersion(entry, rules); const latestVersion = await this.advertisedVersion( bin, descriptor.marketplaceName, - descriptor.pluginName + descriptor.pluginName, + rules ); const installed = entry !== null; const updateAvailable = @@ -255,16 +250,16 @@ class SwitchSetupService { async checkForUpdates(agentId: string): Promise { const resolved = await this.resolve(agentId); if (!resolved) return unsupported(agentId); - const { descriptor, bin } = resolved; + const { descriptor, bin, rules } = resolved; let refreshError: string | null = null; try { - await this.ensureMarketplace(bin, descriptor.marketplaceName, descriptor.marketplaceSource); - const res = await this.run(bin, [ - 'plugin', - 'marketplace', - 'update', + await this.ensureMarketplace( + bin, descriptor.marketplaceName, - ]); + descriptor.marketplaceSource, + rules + ); + const res = await this.run(bin, rules.marketplaceRefreshArgs(descriptor.marketplaceName)); if (res.code !== 0) { throw new Error( res.stderr.trim() || `Failed to update marketplace ${descriptor.marketplaceName}` @@ -281,35 +276,82 @@ class SwitchSetupService { const resolved = await this.resolve(agentId); if (!resolved) return { success: false, message: 'Switch setup is not supported for this agent.' }; - const { descriptor, bin, ref } = resolved; + const { descriptor, bin, ref, rules } = resolved; try { - await this.ensureMarketplace(bin, descriptor.marketplaceName, descriptor.marketplaceSource); + await this.ensureMarketplace( + bin, + descriptor.marketplaceName, + descriptor.marketplaceSource, + rules + ); } catch (err) { return { success: false, message: `Could not add marketplace: ${String(err)}` }; } - const res = await this.run(bin, ['plugin', 'install', ref, '-s', descriptor.scope]); + const res = await this.run(bin, rules.installArgs(ref, descriptor.scope)); return res.code === 0 ? { success: true } : { success: false, message: res.stderr.trim() || 'Install failed.' }; } + /** + * Update the installed plugin. Dialects without a per-plugin update verb + * (Codex) are updated by uninstalling and reinstalling; a failed reinstall is + * reported as such rather than as a plain update failure, because it leaves + * the agent with no connector rather than with the previous version. + * + * The marketplace is repaired first, exactly as `install` does. The re-add + * resolves against whatever marketplace is registered, so a stale source would + * otherwise fail it — after the uninstall has already succeeded. + */ async update(agentId: string): Promise { const resolved = await this.resolve(agentId); if (!resolved) return { success: false, message: 'Switch setup is not supported for this agent.' }; - const { descriptor, bin, ref } = resolved; - const res = await this.run(bin, ['plugin', 'update', ref, '-s', descriptor.scope]); - return res.code === 0 + const { descriptor, bin, ref, rules } = resolved; + + try { + await this.ensureMarketplace( + bin, + descriptor.marketplaceName, + descriptor.marketplaceSource, + rules + ); + } catch (err) { + return { success: false, message: `Could not add marketplace: ${String(err)}` }; + } + + const updateArgs = rules.updateArgs(ref, descriptor.scope); + if (updateArgs) { + const res = await this.run(bin, updateArgs); + return res.code === 0 + ? { success: true } + : { success: false, message: res.stderr.trim() || 'Update failed.' }; + } + + const removed = await this.run(bin, rules.uninstallArgs(ref, descriptor.scope)); + if (removed.code !== 0) { + return { + success: false, + message: removed.stderr.trim() || 'Update failed: could not remove the installed plugin.', + }; + } + const added = await this.run(bin, rules.installArgs(ref, descriptor.scope)); + return added.code === 0 ? { success: true } - : { success: false, message: res.stderr.trim() || 'Update failed.' }; + : { + success: false, + message: + added.stderr.trim() || + 'Update failed: the plugin was removed but could not be reinstalled. Install it again from Settings → Agents.', + }; } async uninstall(agentId: string): Promise { const resolved = await this.resolve(agentId); if (!resolved) return { success: false, message: 'Switch setup is not supported for this agent.' }; - const { descriptor, bin, ref } = resolved; - const res = await this.run(bin, ['plugin', 'uninstall', ref, '-s', descriptor.scope]); + const { descriptor, bin, ref, rules } = resolved; + const res = await this.run(bin, rules.uninstallArgs(ref, descriptor.scope)); return res.code === 0 ? { success: true } : { success: false, message: res.stderr.trim() || 'Uninstall failed.' }; diff --git a/dash/apps/switchdash-desktop/src/renderer/features/locations/components/add-agent-modal/add-agent-modal.tsx b/dash/apps/switchdash-desktop/src/renderer/features/locations/components/add-agent-modal/add-agent-modal.tsx index 8be0de954..8bd657146 100644 --- a/dash/apps/switchdash-desktop/src/renderer/features/locations/components/add-agent-modal/add-agent-modal.tsx +++ b/dash/apps/switchdash-desktop/src/renderer/features/locations/components/add-agent-modal/add-agent-modal.tsx @@ -76,7 +76,7 @@ export const AddAgentModal = observer(function AddAgentModal({ onClose }: AddLoc const showAddServerModal = useShowModal('addServerModal'); const pickState = usePickMode(); - const configureForm = useConfigureAgentForm(pickState.path, false); + const configureForm = useConfigureAgentForm(pickState.path, false, pickState.providerId); // Run location: 'local' (default) or an onboarded remote host's SSH alias. A // remote agent runs its sessions on the host and needs a remote working dir. @@ -90,7 +90,11 @@ export const AddAgentModal = observer(function AddAgentModal({ onClose }: AddLoc const [remoteRepoDirDraft, setRemoteRepoDirDraft] = useState(''); // Configure form for onboarding a brand-new agent in the remote dir. Defaults // (name/description) are derived from the remote dir just like a local agent. - const remoteConfigureForm = useConfigureAgentForm(remoteRepoDir.trim(), true); + const remoteConfigureForm = useConfigureAgentForm( + remoteRepoDir.trim(), + true, + pickState.providerId + ); const { data: remoteHosts } = useQuery({ queryKey: ['remote-hosts'], queryFn: () => rpc.remoteHosts.listHosts(), @@ -425,7 +429,8 @@ export const AddAgentModal = observer(function AddAgentModal({ onClose }: AddLoc if (result.kind === 'name-conflict') { toast({ title: 'Agent name already taken', - description: 'An agent with this name already exists on the server. Pick another name.', + description: + 'An agent with this name already exists in this directory or on the server. Pick another name.', variant: 'destructive', }); return; diff --git a/dash/apps/switchdash-desktop/src/renderer/features/locations/components/add-agent-modal/agent-type-picker.tsx b/dash/apps/switchdash-desktop/src/renderer/features/locations/components/add-agent-modal/agent-type-picker.tsx index dce991809..c501d3ac5 100644 --- a/dash/apps/switchdash-desktop/src/renderer/features/locations/components/add-agent-modal/agent-type-picker.tsx +++ b/dash/apps/switchdash-desktop/src/renderer/features/locations/components/add-agent-modal/agent-type-picker.tsx @@ -17,8 +17,10 @@ import type { AgentProviderId } from '@shared/core/providers/agent-provider-regi * Picks the agent type for a new Switch agent. Only agent types that are both * Switch-supported and have their connector plugin installed (i.e. actually * usable) are offered; if none qualify, the user is pointed at the per-agent - * Switch setup. The selection is auto-set to the single option when there is - * only one, so the common case needs no extra click. + * Switch setup. Auto-selection is deliberately limited to the case where + * exactly one type is offered — there is nothing to choose, so the click is + * pure friction. With several connectors installed the user picks explicitly: + * pre-selecting one of them would silently decide which agent gets onboarded. */ export function AgentTypePicker({ value, diff --git a/dash/apps/switchdash-desktop/src/renderer/features/locations/components/add-agent-modal/modes.ts b/dash/apps/switchdash-desktop/src/renderer/features/locations/components/add-agent-modal/modes.ts index bdaa04744..4e552c26d 100644 --- a/dash/apps/switchdash-desktop/src/renderer/features/locations/components/add-agent-modal/modes.ts +++ b/dash/apps/switchdash-desktop/src/renderer/features/locations/components/add-agent-modal/modes.ts @@ -54,7 +54,11 @@ export type PickModeState = ReturnType; * the agent as a managed, session-addressable identity — there is no run-mode or * notify-handle choice (CHOO-1440). */ -export function useConfigureAgentForm(dir: string, defaultAutoApprove: boolean) { +export function useConfigureAgentForm( + dir: string, + defaultAutoApprove: boolean, + providerId: AgentProviderId | null +) { const [agentName, setAgentNameRaw] = useState(''); const [agentNameTouched, setAgentNameTouched] = useState(false); const [description, setDescriptionRaw] = useState(''); @@ -70,9 +74,12 @@ export function useConfigureAgentForm(dir: string, defaultAutoApprove: boolean) // the local user — pure, no filesystem read — so it works identically for a // local path or a remote working dir, giving remote agents the same defaults. const defaultsQuery = useQuery({ - queryKey: ['agentDefaults', trimmedDir], - queryFn: () => rpc.switchServers.suggestAgentDefaults({ dir: trimmedDir }), - enabled: trimmedDir.length > 0, + queryKey: ['agentDefaults', trimmedDir, providerId], + queryFn: () => { + if (providerId === null) throw new Error('providerId is required for agent defaults'); + return rpc.switchServers.suggestAgentDefaults({ dir: trimmedDir, providerId }); + }, + enabled: trimmedDir.length > 0 && providerId !== null, }); const defaults = defaultsQuery.data; diff --git a/dash/apps/switchdash-desktop/src/renderer/features/remote-hosts/remote-host-detail.tsx b/dash/apps/switchdash-desktop/src/renderer/features/remote-hosts/remote-host-detail.tsx index 0deee5c38..c1d6268db 100644 --- a/dash/apps/switchdash-desktop/src/renderer/features/remote-hosts/remote-host-detail.tsx +++ b/dash/apps/switchdash-desktop/src/renderer/features/remote-hosts/remote-host-detail.tsx @@ -16,6 +16,7 @@ import { Spinner } from '@renderer/lib/ui/spinner'; import { log } from '@renderer/utils/logger'; import { cn } from '@renderer/utils/utils'; import { sshConnectionEventChannel } from '@shared/core/ssh/sshEvents'; +import { updateCheckUnavailable } from '@shared/core/switch-setup/update-check'; import { GhAuthPanel } from './gh-auth-panel'; import { hostSetupQueryKey } from './query-keys'; @@ -502,12 +503,19 @@ function AgentTypeRow({ ? 'partial' : 'ready'; + // Not "no update available": this agent type advertises no versions on a + // remote host, so `updateAvailable` is structurally always false and saying + // nothing would read as "current". + const currencyUnknown = plugin !== undefined && updateCheckUnavailable(plugin); + const pluginStepLabel = plugin?.installed && plugin.updateAvailable ? 'Plugin · update available' - : plugin?.installed - ? `Plugin ${plugin.installedVersion ?? ''}`.trim() - : 'Switch plugin'; + : plugin?.installed && currencyUnknown + ? `Plugin ${plugin.installedVersion ?? ''} · updates not detectable`.trim() + : plugin?.installed + ? `Plugin ${plugin.installedVersion ?? ''}`.trim() + : 'Switch plugin'; const steps: Step[] = [ { @@ -562,14 +570,18 @@ function AgentTypeRow({ {checkUpdates.isPending ? 'Checking…' : 'Check for updates'} )} - {cliInstalled && plugin?.installed && plugin.updateAvailable && ( + {cliInstalled && plugin?.installed && (plugin.updateAvailable || currencyUnknown) && ( )} diff --git a/dash/apps/switchdash-desktop/src/renderer/features/settings/agents-page/SwitchSetupCard.tsx b/dash/apps/switchdash-desktop/src/renderer/features/settings/agents-page/SwitchSetupCard.tsx index c7975c45f..00319f78a 100644 --- a/dash/apps/switchdash-desktop/src/renderer/features/settings/agents-page/SwitchSetupCard.tsx +++ b/dash/apps/switchdash-desktop/src/renderer/features/settings/agents-page/SwitchSetupCard.tsx @@ -3,6 +3,7 @@ import { useSwitchSetup } from '@renderer/lib/stores/use-switch-setup'; import { Button } from '@renderer/lib/ui/button'; import { Field } from '@renderer/lib/ui/field'; import { Label } from '@renderer/lib/ui/label'; +import { updateCheckUnavailable } from '@shared/core/switch-setup/update-check'; import { InstalledBadge, InstallingBadge, @@ -34,6 +35,8 @@ export function SwitchSetupCard({ agentId }: { agentId: string }) { if (isLoading || !status?.supported) return null; const busy = isInstalling || isUpdating || isUninstalling || isChecking; + // Distinct from "no update available": there is nothing to compare against. + const currencyUnknown = updateCheckUnavailable(status); const badge = isInstalling ? ( @@ -89,6 +92,13 @@ export function SwitchSetupCard({ agentId }: { agentId: string }) { )} )} + {/* `updateAvailable` can never go true here, so without this the + plugin could only be refreshed by uninstalling first. */} + {currencyUnknown && !status.updateAvailable && ( + + )} @@ -101,6 +111,12 @@ export function SwitchSetupCard({ agentId }: { agentId: string }) { Couldn't refresh the plugin marketplace — showing cached status. {status.refreshError}

)} + {currencyUnknown && !status.refreshError && ( +

+ This agent type doesn't report plugin versions here, so switchdash can't tell whether an + update exists. Reinstall to be sure you are on the latest. +

+ )}

Connects this agent to a Switch instance. Credentials are managed when you add the agent to a Switch server. diff --git a/dash/apps/switchdash-desktop/src/renderer/lib/stores/use-switch-setup.ts b/dash/apps/switchdash-desktop/src/renderer/lib/stores/use-switch-setup.ts index 95a64a6ba..ebc75c2cf 100644 --- a/dash/apps/switchdash-desktop/src/renderer/lib/stores/use-switch-setup.ts +++ b/dash/apps/switchdash-desktop/src/renderer/lib/stores/use-switch-setup.ts @@ -1,6 +1,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { toast } from '@renderer/lib/hooks/use-toast'; import { rpc } from '@renderer/lib/ipc'; +import { updateCheckUnavailable } from '@shared/core/switch-setup/update-check'; function switchSetupQueryKey(agentId: string) { return ['switch-setup', agentId] as const; @@ -49,6 +50,13 @@ export function useSwitchSetup(agentId: string) { description: status.refreshError, variant: 'destructive', }); + } else if (updateCheckUnavailable(status)) { + // No advertised version to compare against, so the refresh proved + // nothing. Saying "up to date" here would assert what was not checked. + toast({ + title: 'Could not determine whether an update exists', + description: 'This agent type does not report plugin versions on a remote host.', + }); } else if (status.supported && status.installed && !status.updateAvailable) { toast({ title: 'Switch connector is up to date' }); } diff --git a/dash/apps/switchdash-desktop/src/shared/core/providers/agent-provider-registry.ts b/dash/apps/switchdash-desktop/src/shared/core/providers/agent-provider-registry.ts index 75d22d0a0..b54685cb8 100644 --- a/dash/apps/switchdash-desktop/src/shared/core/providers/agent-provider-registry.ts +++ b/dash/apps/switchdash-desktop/src/shared/core/providers/agent-provider-registry.ts @@ -94,24 +94,30 @@ export type AgentProviderDefinition = { supportsHooks?: boolean; }; +/** + * Provider ids and display metadata, plus a mirror of each provider's argv shape. + * + * The argv fields here are descriptive, not authoritative: nothing reads them at + * spawn time. `packages/plugins/src/agents/impl//index.ts` builds the real + * command, so change the plugin first and update the mirror to match. + */ export const AGENT_PROVIDERS: AgentProviderDefinition[] = [ { id: 'codex', name: 'Codex', description: - 'CLI that connects to OpenAI models for location-aware code assistance and terminal workflows.', + 'CLI that connects to OpenAI models for project-aware code assistance and terminal workflows.', docUrl: 'https://github.com/openai/codex', installCommand: 'npm install -g @openai/codex', commands: ['codex'], versionArgs: ['--version'], cli: 'codex', - // `--dangerously-bypass-hook-trust` lets Codex run switchdash's own hooks (notably the - // SessionStart hook that reports the rollout session id) without an interactive trust - // prompt. Automations always auto-approve and can't answer that prompt, so without this - // the session id is never captured and resume falls back to `codex resume --last`, - // reattaching the globally-most-recent Codex session instead of this one. - autoApproveFlag: - '-c approval_policy="never" -c sandbox_mode="danger-full-access" --dangerously-bypass-hook-trust', + // Hook trust is a default arg, not an auto-approve one: Codex skips any hook + // it has no persisted trust entry for, and switchdash's room tracking and + // rollout-id capture are hooks. Kept in sync with the plugin by the parity + // test in src/main/core/providers/provider-argv-parity.test.ts. + defaultArgs: ['--dangerously-bypass-hook-trust'], + autoApproveFlag: '-c approval_policy="never" -c sandbox_mode="danger-full-access"', initialPromptFlag: '', resumeFlag: 'resume', sessionIdFlag: ' ', diff --git a/dash/apps/switchdash-desktop/src/shared/core/sessions/sessions.ts b/dash/apps/switchdash-desktop/src/shared/core/sessions/sessions.ts index a441e8fa1..ffdcc2c41 100644 --- a/dash/apps/switchdash-desktop/src/shared/core/sessions/sessions.ts +++ b/dash/apps/switchdash-desktop/src/shared/core/sessions/sessions.ts @@ -48,7 +48,8 @@ export type Session = { archivedAt?: string; lastInteractedAt?: string; autoApprove?: boolean; - /** Set when this session runs as a Claude Code subagent of its agent. */ + /** The session's agent's name, read live from the agent row on every load + * (so it follows a rename). Absent only for a row that predates named agents. */ agentName?: string; createdAt: string; updatedAt: string; diff --git a/dash/apps/switchdash-desktop/src/shared/core/switch-rooms/switch-mcp-endpoint.ts b/dash/apps/switchdash-desktop/src/shared/core/switch-rooms/switch-mcp-endpoint.ts new file mode 100644 index 000000000..93da1f177 --- /dev/null +++ b/dash/apps/switchdash-desktop/src/shared/core/switch-rooms/switch-mcp-endpoint.ts @@ -0,0 +1,39 @@ +/** + * The agent-bridge endpoint an agent's Switch MCP server is addressed by. + * + * Lives in `shared` because both halves of the remote path need it: the main + * process bakes a placeholder into a precomputed launch spec, and the on-VM + * sidecar — which bundles free of Electron and the database — substitutes the + * real endpoint into it at spawn time. Normalising in one place keeps the two + * from disagreeing about the trailing slash. + */ + +/** Path appended to the agent-bridge endpoint to reach its MCP surface. */ +export const SWITCH_MCP_PATH_SUFFIX = '/mcp/'; + +/** + * Argv token switchdash emits in place of an agent's Switch API endpoint. + * + * Unlike the session id and prompt tokens this is substituted as a *substring*: + * the endpoint is embedded inside a provider-specific argument (Codex renders + * `mcp_servers.switch.url="/mcp/"`) rather than occupying an argv + * element of its own. + */ +export const SWITCH_API_ENDPOINT_PLACEHOLDER = '__SWITCHDASH_SWITCH_API_ENDPOINT__'; + +/** + * Strip trailing slashes so appending {@link SWITCH_MCP_PATH_SUFFIX} yields + * exactly one. Returns null when there is no usable endpoint — an agent with no + * Switch identity has nothing to point at, and a half-formed URL is worse than + * no MCP server at all. + */ +export function normalizeSwitchApiEndpoint(endpoint: string | undefined): string | null { + const trimmed = endpoint?.trim().replace(/\/+$/, ''); + return trimmed ? trimmed : null; +} + +/** The MCP URL for an agent-bridge endpoint, or null when there is no endpoint. */ +export function switchMcpUrl(endpoint: string | undefined): string | null { + const base = normalizeSwitchApiEndpoint(endpoint); + return base === null ? null : `${base}${SWITCH_MCP_PATH_SUFFIX}`; +} diff --git a/dash/apps/switchdash-desktop/src/shared/core/switch-setup/update-check.test.ts b/dash/apps/switchdash-desktop/src/shared/core/switch-setup/update-check.test.ts new file mode 100644 index 000000000..053131672 --- /dev/null +++ b/dash/apps/switchdash-desktop/src/shared/core/switch-setup/update-check.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; +import { updateCheckUnavailable } from './update-check'; + +const base = { supported: true, installed: true, latestVersion: '0.2.0' }; + +describe('updateCheckUnavailable', () => { + it('is true when there is no advertised version to compare against', () => { + // Codex on a remote host: its marketplace listing carries no plugin + // versions, so `updateAvailable: false` means "unknown", not "current". + expect(updateCheckUnavailable({ ...base, latestVersion: null })).toBe(true); + }); + + it('is false when a version is advertised, update or no update', () => { + expect(updateCheckUnavailable(base)).toBe(false); + }); + + it('is false before the plugin is installed — nothing to be stale yet', () => { + expect(updateCheckUnavailable({ ...base, installed: false, latestVersion: null })).toBe(false); + }); + + it('is false for an agent type with no Switch setup at all', () => { + expect(updateCheckUnavailable({ ...base, supported: false, latestVersion: null })).toBe(false); + }); +}); diff --git a/dash/apps/switchdash-desktop/src/shared/core/switch-setup/update-check.ts b/dash/apps/switchdash-desktop/src/shared/core/switch-setup/update-check.ts new file mode 100644 index 000000000..b81b4dc10 --- /dev/null +++ b/dash/apps/switchdash-desktop/src/shared/core/switch-setup/update-check.ts @@ -0,0 +1,21 @@ +/** + * Whether the installed connector plugin's currency is *unknowable*, as opposed + * to current. + * + * `updateAvailable: false` conflates the two: it is what you get both when the + * advertised version is older-or-equal and when there is no advertised version + * to compare against at all. The second case is real — Codex's marketplace + * listing carries no plugin versions, so the remote driver has nothing to read — + * and a caller that renders it as "up to date" asserts something it never + * checked. Branch on this to disclose the difference instead. + * + * Structural parameter rather than the main-process `SwitchSetupStatus`, so the + * renderer can share the one definition. + */ +export function updateCheckUnavailable(status: { + supported: boolean; + installed: boolean; + latestVersion: string | null; +}): boolean { + return status.supported && status.installed && status.latestVersion === null; +} diff --git a/dash/apps/switchdash-desktop/src/sidecar/agent-launch-spec.test.ts b/dash/apps/switchdash-desktop/src/sidecar/agent-launch-spec.test.ts index c26ab5ff8..001abeea5 100644 --- a/dash/apps/switchdash-desktop/src/sidecar/agent-launch-spec.test.ts +++ b/dash/apps/switchdash-desktop/src/sidecar/agent-launch-spec.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest'; +import { SWITCH_API_ENDPOINT_PLACEHOLDER } from '@shared/core/switch-rooms/switch-mcp-endpoint'; import { type AgentLaunchSpec, INITIAL_PROMPT_PLACEHOLDER, @@ -29,6 +30,7 @@ describe('materializeAgentCommand', () => { sessionId: 'session-9', initialPrompt: 'connect to switch room room-x', extraEnv: {}, + switchApiEndpoint: undefined, }); expect(cmd.command).toBe('/usr/bin/claude'); expect(cmd.args).toEqual([ @@ -44,27 +46,100 @@ describe('materializeAgentCommand', () => { sessionId: 'c', initialPrompt: 'p', extraEnv: { SHARED: 'override', HOOK: 'x' }, + switchApiEndpoint: undefined, }); expect(cmd.env).toEqual({ BASE: '1', SHARED: 'override', HOOK: 'x' }); }); - it('throws when the session-id token is missing', () => { + it('launches a provider that takes no session id on a fresh session', () => { + // Codex mints its own rollout id and only accepts one when resuming, so its + // spec carries no session-id token. switchdash correlates the spawn through + // the pty id in the hook env, not through argv. + const cmd = materializeAgentCommand(spec({ args: ['-c', 'x', INITIAL_PROMPT_PLACEHOLDER] }), { + sessionId: 'c', + initialPrompt: 'connect to switch room room-x', + extraEnv: {}, + switchApiEndpoint: undefined, + }); + + expect(cmd.args).toEqual(['-c', 'x', 'connect to switch room room-x']); + }); + + it('throws when the initial-prompt token is missing', () => { expect(() => - materializeAgentCommand(spec({ args: [INITIAL_PROMPT_PLACEHOLDER] }), { + materializeAgentCommand(spec({ args: ['--session-id', SESSION_ID_PLACEHOLDER] }), { sessionId: 'c', initialPrompt: 'p', extraEnv: {}, + switchApiEndpoint: undefined, }) - ).toThrow(SESSION_ID_PLACEHOLDER); + ).toThrow(INITIAL_PROMPT_PLACEHOLDER); }); +}); - it('throws when the initial-prompt token is missing', () => { +describe('materializeAgentCommand Switch endpoint substitution', () => { + /** A Codex-shaped spec: the endpoint rides inside a larger `-c` argument. */ + function codexSpec(): AgentLaunchSpec { + return spec({ + command: '/usr/bin/codex', + providerId: 'codex', + args: [ + 'resume', + SESSION_ID_PLACEHOLDER, + '-c', + `mcp_servers.switch.url="${SWITCH_API_ENDPOINT_PLACEHOLDER}/mcp/"`, + '-c', + 'mcp_servers.switch.bearer_token_env_var="SWITCH_API_TOKEN"', + INITIAL_PROMPT_PLACEHOLDER, + ], + }); + } + + it('substitutes the endpoint inside a larger argument, not as a whole token', () => { + const cmd = materializeAgentCommand(codexSpec(), { + sessionId: 's1', + initialPrompt: 'p', + extraEnv: {}, + switchApiEndpoint: 'https://switch.test/api', + }); + + expect(cmd.args).toContain('mcp_servers.switch.url="https://switch.test/api/mcp/"'); + expect(cmd.args.join(' ')).not.toContain(SWITCH_API_ENDPOINT_PLACEHOLDER); + }); + + it('normalises a trailing slash so the MCP path is not doubled', () => { + const cmd = materializeAgentCommand(codexSpec(), { + sessionId: 's1', + initialPrompt: 'p', + extraEnv: {}, + switchApiEndpoint: 'https://switch.test/api///', + }); + + expect(cmd.args).toContain('mcp_servers.switch.url="https://switch.test/api/mcp/"'); + }); + + it('refuses to launch with an unsubstituted placeholder rather than pointing at literal text', () => { + // A remote agent with no resolvable endpoint would otherwise come up with an + // MCP server addressed as `__SWITCHDASH_SWITCH_API_ENDPOINT__/mcp/` and fail + // on every tool call, looking configured the whole time. expect(() => - materializeAgentCommand(spec({ args: ['--session-id', SESSION_ID_PLACEHOLDER] }), { - sessionId: 'c', + materializeAgentCommand(codexSpec(), { + sessionId: 's1', initialPrompt: 'p', extraEnv: {}, + switchApiEndpoint: undefined, }) - ).toThrow(INITIAL_PROMPT_PLACEHOLDER); + ).toThrow(/unsubstituted placeholder/); + }); + + it('leaves a spec with no endpoint token alone', () => { + const cmd = materializeAgentCommand(spec(), { + sessionId: 's1', + initialPrompt: 'p', + extraEnv: {}, + switchApiEndpoint: 'https://switch.test/api', + }); + + expect(cmd.args).toEqual(['--session-id', 's1', '--dangerously-skip-permissions', 'p']); }); }); diff --git a/dash/apps/switchdash-desktop/src/sidecar/agent-launch-spec.ts b/dash/apps/switchdash-desktop/src/sidecar/agent-launch-spec.ts index 68a9716a0..9c571c52d 100644 --- a/dash/apps/switchdash-desktop/src/sidecar/agent-launch-spec.ts +++ b/dash/apps/switchdash-desktop/src/sidecar/agent-launch-spec.ts @@ -1,3 +1,8 @@ +import { + normalizeSwitchApiEndpoint, + SWITCH_API_ENDPOINT_PLACEHOLDER, +} from '@shared/core/switch-rooms/switch-mcp-endpoint'; + /** * A serialised recipe for launching a fresh agent CLI session on the VM. * @@ -14,9 +19,10 @@ export interface AgentLaunchSpec { /** Executable to run (absolute path resolved on the host at deploy time). */ command: string; /** - * Argv for a fresh session. Exactly one element equals - * {@link SESSION_ID_PLACEHOLDER} and one equals {@link INITIAL_PROMPT_PLACEHOLDER}; - * the watcher swaps those for the real values per spawn. + * Argv for a fresh session. One element equals {@link INITIAL_PROMPT_PLACEHOLDER}; + * the watcher swaps it for the real prompt per spawn. {@link SESSION_ID_PLACEHOLDER} + * and {@link SWITCH_API_ENDPOINT_PLACEHOLDER} appear only for providers that take + * those on argv, and are substituted when present. */ args: string[]; /** Base provider env (custom env + provider env); the per-spawn hook env is merged on top. */ @@ -33,6 +39,9 @@ export const SESSION_ID_PLACEHOLDER = '__SWITCHDASH_SESSION_ID__'; /** Argv token switchdash emits in place of the fresh session's initial prompt. */ export const INITIAL_PROMPT_PLACEHOLDER = '__SWITCHDASH_INITIAL_PROMPT__'; +/** Shared prefix of every launch-spec placeholder, used to catch leftovers. */ +const PLACEHOLDER_PREFIX = '__SWITCHDASH_'; + export interface MaterializedAgentCommand { command: string; args: string[]; @@ -41,28 +50,56 @@ export interface MaterializedAgentCommand { /** * Resolve a launch spec into a concrete command for one spawn by substituting - * the session id and initial prompt into the placeholder argv elements and - * merging the per-spawn env (hook env) over the base env. + * the session id, initial prompt and Switch API endpoint into the spec's argv, + * and merging the per-spawn env (hook env) over the base env. + * + * Throws if the prompt placeholder is missing: the prompt is what tells the + * agent which room to connect to, so a spec that cannot carry it would spawn a + * session that just sits there. + * + * The session id and endpoint tokens are substituted when present and are not + * required. Not every provider takes a session id on a fresh session — Codex + * mints its own rollout id and only accepts one when resuming — and switchdash + * does not depend on the argv value either way: it correlates the spawn through + * the pty id in the hook env, and learns the provider's own id from the + * SessionStart hook. Likewise only providers that receive their MCP server on + * argv emit an endpoint token. * - * Throws if either placeholder is missing from the spec's argv — a spec that - * cannot carry the session id or prompt would silently spawn a session - * that never connects to the room, so we fail loud instead. + * Throws if any `__SWITCHDASH_` token survives substitution, so a provider that + * grows a new placeholder cannot quietly launch an agent pointed at literal + * placeholder text. */ export function materializeAgentCommand( spec: AgentLaunchSpec, - params: { sessionId: string; initialPrompt: string; extraEnv: Record } + params: { + sessionId: string; + initialPrompt: string; + extraEnv: Record; + switchApiEndpoint: string | undefined; + } ): MaterializedAgentCommand { const substitutions: Record = { [SESSION_ID_PLACEHOLDER]: params.sessionId, [INITIAL_PROMPT_PLACEHOLDER]: params.initialPrompt, }; - for (const placeholder of [SESSION_ID_PLACEHOLDER, INITIAL_PROMPT_PLACEHOLDER]) { - if (!spec.args.includes(placeholder)) { - throw new Error(`agent launch spec is missing the ${placeholder} argv token`); - } + if (!spec.args.includes(INITIAL_PROMPT_PLACEHOLDER)) { + throw new Error(`agent launch spec is missing the ${INITIAL_PROMPT_PLACEHOLDER} argv token`); + } + + const endpoint = normalizeSwitchApiEndpoint(params.switchApiEndpoint); + const args = spec.args.map((arg) => { + const whole = substitutions[arg]; + if (whole !== undefined) return whole; + // The endpoint is embedded inside a larger argument, so it is replaced as a + // substring rather than swapped for a whole argv element. + return endpoint === null ? arg : arg.replaceAll(SWITCH_API_ENDPOINT_PLACEHOLDER, endpoint); + }); + + const unresolved = args.find((arg) => arg.includes(PLACEHOLDER_PREFIX)); + if (unresolved !== undefined) { + throw new Error(`agent launch spec has an unsubstituted placeholder in argv: ${unresolved}`); } - const args = spec.args.map((arg) => substitutions[arg] ?? arg); return { command: spec.command, args, env: { ...spec.env, ...params.extraEnv } }; } diff --git a/dash/apps/switchdash-desktop/src/sidecar/session-spawner.ts b/dash/apps/switchdash-desktop/src/sidecar/session-spawner.ts index 8a08dd1bc..2c954e2e1 100644 --- a/dash/apps/switchdash-desktop/src/sidecar/session-spawner.ts +++ b/dash/apps/switchdash-desktop/src/sidecar/session-spawner.ts @@ -146,6 +146,7 @@ export class InProcessSessionSpawner implements SessionSpawner { sessionId, initialPrompt: `connect to switch room ${roomId}`, extraEnv: hookEnv, + switchApiEndpoint: this.deps.switchEnv.SWITCH_API_ENDPOINT, }); await this.startDetachedTmux(tmuxTarget, spec.cwd, command.env, command.command, command.args); diff --git a/dash/apps/switchdash-desktop/src/sidecar/sidecar-runtime.ts b/dash/apps/switchdash-desktop/src/sidecar/sidecar-runtime.ts index 08542383a..6ffc25d32 100644 --- a/dash/apps/switchdash-desktop/src/sidecar/sidecar-runtime.ts +++ b/dash/apps/switchdash-desktop/src/sidecar/sidecar-runtime.ts @@ -120,7 +120,7 @@ export class SidecarRuntime { let parsed: ParsedHookEvent; try { - parsed = await parseHookEvent(raw, this.resolveContext); + parsed = await parseHookEvent(raw, this.resolveContext, this.deps.log); } catch (error) { this.deps.log.warn('SidecarRuntime: failed to parse hook event', { type: raw.type, diff --git a/dash/packages/core/src/agents/plugins/capabilities/mcp.ts b/dash/packages/core/src/agents/plugins/capabilities/mcp.ts index 8484414fd..37d408f78 100644 --- a/dash/packages/core/src/agents/plugins/capabilities/mcp.ts +++ b/dash/packages/core/src/agents/plugins/capabilities/mcp.ts @@ -8,6 +8,20 @@ export type IMcpBehavior = { readServers(fs: PluginFs): Promise; writeServers(fs: PluginFs, servers: McpServerRegistration[]): Promise; removeServer(fs: PluginFs, name: string): Promise; + /** + * Render a server as launch arguments, for agents that must receive a + * per-session MCP server on the command line rather than from a config file. + * + * Implement this only when the agent cannot resolve a server whose address + * varies per session any other way. An agent whose connector plugin expands + * environment variables in its bundled MCP config (Claude Code) leaves this + * undefined: the config file is already per-session because the values are. + * Codex performs no such expansion — a plugin-bundled `.mcp.json` reaches the + * server with `${VAR}` intact — and writing the resolved address into its + * single global config would make two agents in one location overwrite each + * other, so its address must ride on argv. + */ + launchArgsForServer?(server: McpServerRegistration): string[]; }; export type McpServerRegistration = { @@ -19,6 +33,13 @@ export type McpServerRegistration = { url?: string; headers?: Record; env?: Record; + /** + * Name of an environment variable holding the bearer token, for agents that + * resolve it at request time rather than taking the secret inline. Declared + * rather than left to the index signature so a typo is a compile error and + * not a silently unauthenticated server. + */ + bearer_token_env_var?: string; [key: string]: unknown; }; diff --git a/dash/packages/core/src/agents/plugins/capabilities/repo-agents.ts b/dash/packages/core/src/agents/plugins/capabilities/repo-agents.ts index 7e027798e..5c7d5ae19 100644 --- a/dash/packages/core/src/agents/plugins/capabilities/repo-agents.ts +++ b/dash/packages/core/src/agents/plugins/capabilities/repo-agents.ts @@ -28,14 +28,6 @@ export type RepoAgentDefinition = { registered: boolean; }; -/** Credentials written for an agent so its sessions act under its own identity. */ -export type RepoAgentCredentials = { - agentName: string; - apiEndpoint: string; - apiToken: string; - agentId: string; -}; - /** * The MCP permission rules that keep a Switch agent connected to the platform: * the connector's two MCP servers. Used both as `tools` allowlist entries (so an @@ -109,8 +101,6 @@ export type IRepoAgentsBehavior = { launchArgs(workingDir: string, agentName: string): string[]; /** The named agent's Switch credentials as env vars, for the launched session. */ readLaunchEnv(workspaceFs: PluginFs, agentName: string): Promise>; - /** Write a named agent's credentials so it is immediately runnable. */ - writeCredentials(workspaceFs: PluginFs, credentials: RepoAgentCredentials): Promise; /** The attribute fields this provider supports, in display order. Drives the * create/edit form; the first two are always `name` and `description`. */ attributeFields(): RepoAgentField[]; @@ -120,7 +110,10 @@ export type IRepoAgentsBehavior = { /** The current attribute values for an existing agent definition, keyed to * {@link attributeFields}, or null if no definition exists. */ readDefinition(workspaceFs: PluginFs, name: string): Promise; - /** Remove a named agent's definition and credentials files (workspace scope). */ + /** Remove a named agent's provider-specific files — its definition and any + * legacy per-agent settings (workspace scope). The provider-neutral Switch + * credentials are not this hook's to remove: they are written for every + * provider, so they are torn down by the caller for every provider too. */ removeLocal(workspaceFs: PluginFs, name: string): Promise; }; diff --git a/dash/packages/core/src/agents/plugins/capabilities/switch-setup.ts b/dash/packages/core/src/agents/plugins/capabilities/switch-setup.ts index 636dbd818..275924133 100644 --- a/dash/packages/core/src/agents/plugins/capabilities/switch-setup.ts +++ b/dash/packages/core/src/agents/plugins/capabilities/switch-setup.ts @@ -18,17 +18,35 @@ export type ISwitchSetupBehavior = { removeCredentials(fs: PluginFs): Promise; }; +/** + * Which plugin-marketplace CLI dialect an agent speaks. + * + * Both dialects share the marketplace model — register a marketplace source, + * install a named plugin from it — but they disagree on verbs, flags and the + * JSON shapes they emit, so the driver cannot assume one from the other: + * + * - `claude-code`: `plugin install|update|uninstall -s `, + * `plugin marketplace update`; `plugin list --json` entries carry `id` and + * `installPath`; manifests live under `.claude-plugin/`. + * - `codex`: `plugin add|remove ` (no scope flag, and **no per-plugin + * update verb** — updating is remove-then-add), `plugin marketplace upgrade`; + * `plugin list --json` returns `{ installed, available }` whose entries carry + * `pluginId` and `source.path`; manifests live under `.codex-plugin/`. + */ +export const SWITCH_SETUP_CLI_DIALECTS = ['claude-code', 'codex'] as const; +export type SwitchSetupCliDialect = (typeof SWITCH_SETUP_CLI_DIALECTS)[number]; + /** * Describes how an agent type installs and manages its Switch connector plugin. * - * kind: 'cli' — the agent exposes a Claude-Code-style plugin marketplace CLI - * (` plugin install/update/uninstall`, ` plugin - * marketplace add/update/list`). The main-process switch-setup - * service drives that CLI from these descriptor fields. + * kind: 'cli' — the agent exposes a plugin marketplace CLI. The main-process + * switch-setup service drives that CLI from these descriptor + * fields, using the verb/parse rules for the declared `dialect`. * kind: 'none' — the agent has no Switch connector setup; the UI surfaces nothing. * - * The descriptor is purely declarative — the generic CLI driver handles plugin - * install/update for every agent that shares the marketplace model. The optional + * The descriptor is purely declarative — one generic driver serves every agent + * that shares the marketplace model, with `dialect` naming the surface + * differences rather than forking the code path. The optional * {@link ISwitchSetupBehavior} is only for the one thing that can be genuinely * provider-specific: where credentials live on disk, so they can be torn down on * delete. Providers using the default `.claude` layout omit it. @@ -44,8 +62,10 @@ export const switchSetupCapability = definePluginCapability -): Promise<{ url: string; token: string }> { +function runSh(command: string, env: NodeJS.ProcessEnv, stdin: string): Promise { + return new Promise((resolve, reject) => { + const child = spawn('sh', ['-c', command], { env, stdio: ['pipe', 'ignore', 'ignore'] }); + child.on('error', reject); + child.on('close', () => resolve()); + child.stdin.end(stdin); + }); +} + +/** + * Run a POSIX hook command under a real `sh` with `curl` stubbed out, and report + * what it would have posted. Both the endpoint resolution and the payload + * plumbing are shell code, so asserting on the string alone would not catch a + * quoting mistake or a pipeline that runs an assignment in a subshell — only + * executing it does. + * + * Exported so provider packages can point it at the command strings their own + * hook config writes. + */ +export async function runHookCommand( + command: string, + { env, stdin }: { env: Record; stdin?: string } +): Promise<{ url: string; token: string; body: string }> { const dir = await mkdtemp(path.join(tmpdir(), 'hook-cmd-')); try { - // A `curl` that records its own argv instead of making a request. + // A `curl` that records its own argv — and, when the command asked it to + // read the request body from stdin, that body too. const stub = path.join(dir, 'curl'); - const out = path.join(dir, 'argv'); - await writeFile(stub, `#!/bin/sh\nprintf '%s\\n' "$@" > ${JSON.stringify(out)}\n`, { - mode: 0o755, - }); - - const command = makeStdinHookCommand('notification', { platform: 'linux' }); - await execFileAsync('sh', ['-c', command], { - env: { ...env, PATH: `${dir}:${process.env.PATH ?? ''}` }, - }); - - const { stdout } = await execFileAsync('cat', [out]); + const argvOut = path.join(dir, 'argv'); + const bodyOut = path.join(dir, 'body'); + await writeFile( + stub, + '#!/bin/sh\n' + + `printf '%s\\n' "$@" > ${JSON.stringify(argvOut)}\n` + + `for a in "$@"; do [ "$a" = "@-" ] && cat > ${JSON.stringify(bodyOut)}; done\n` + + 'exit 0\n', + { mode: 0o755 } + ); + await writeFile(bodyOut, ''); + + await runSh(command, { ...env, PATH: `${dir}:${process.env.PATH ?? ''}` }, stdin ?? ''); + + const { stdout } = await execFileAsync('cat', [argvOut]); const argv = stdout.split('\n'); const url = argv.find((a) => a.startsWith('http://')) ?? ''; const tokenIdx = argv.findIndex((a) => a.startsWith('X-Switchdash-Token:')); - return { url, token: argv[tokenIdx]?.replace('X-Switchdash-Token: ', '') ?? '' }; + const { stdout: body } = await execFileAsync('cat', [bodyOut]); + return { url, token: argv[tokenIdx]?.replace('X-Switchdash-Token: ', '') ?? '', body }; } finally { await rm(dir, { recursive: true, force: true }); } } +async function resolveEndpoint( + env: Record +): Promise<{ url: string; token: string }> { + return runHookCommand(makeStdinHookCommand('notification', { platform: 'linux' }), { env }); +} + describe('makeStdinHookCommand endpoint resolution', () => { it('uses the env port and token when no endpoint file is configured', async () => { const { url, token } = await resolveEndpoint({ @@ -107,6 +138,27 @@ describe('makeStdinHookCommand endpoint resolution', () => { } }); + it('forwards the hook payload on stdin as the request body', async () => { + // The port and the body travel through the same shell command, so a + // restructuring that puts either behind a pipeline subshell silently drops + // one or both — curl's `|| true` swallows the resulting failure. + const payload = '{"tool_response":{"room_id":"r1","agent_id":"a1"}}'; + const { url, body } = await runHookCommand( + makeStdinHookCommand('switch_room_connect', { platform: 'linux' }), + { + env: { + SWITCHDASH_HOOK_PORT: '5001', + SWITCHDASH_HOOK_TOKEN: 'env-token', + SWITCHDASH_PTY_ID: 'codex:s1', + }, + stdin: payload, + } + ); + + expect(url).toBe('http://127.0.0.1:5001/hook'); + expect(body).toBe(payload); + }); + it('stays recognisable to filterUserHooks so managed entries are replaced, not duplicated', () => { const command = makeStdinHookCommand('notification', { platform: 'linux' }); diff --git a/dash/packages/core/src/agents/plugins/helpers/mcp.test.ts b/dash/packages/core/src/agents/plugins/helpers/mcp.test.ts index 490abac39..244932f52 100644 --- a/dash/packages/core/src/agents/plugins/helpers/mcp.test.ts +++ b/dash/packages/core/src/agents/plugins/helpers/mcp.test.ts @@ -252,3 +252,56 @@ describe('codexMcpAdapter', () => { expect(result[0].http_headers).toBeUndefined(); }); }); + +describe('codexMcpAdapter.launchArgsForServer', () => { + const adapter = codexMcpAdapter('.codex/config.toml'); + + it('emits url and token env var together as -c overrides', () => { + // A `-c mcp_servers..` override replaces that server's whole + // table rather than merging into it, so both keys have to travel together. + expect( + adapter.launchArgsForServer!({ + name: 'switch', + transport: 'http', + url: 'https://switch.test/api/mcp/', + bearer_token_env_var: 'SWITCH_API_TOKEN', + }) + ).toEqual([ + '-c', + 'mcp_servers.switch.url="https://switch.test/api/mcp/"', + '-c', + 'mcp_servers.switch.bearer_token_env_var="SWITCH_API_TOKEN"', + ]); + }); + + it('names the token env var rather than embedding the secret', () => { + const args = adapter.launchArgsForServer!({ + name: 'switch', + transport: 'http', + url: 'https://switch.test/api/mcp/', + bearer_token_env_var: 'SWITCH_API_TOKEN', + }).join(' '); + + // argv is world-readable via `ps`; only the variable's name may appear. + expect(args).toContain('bearer_token_env_var'); + expect(args).not.toMatch(/Bearer\s/); + }); + + it('omits the token override when the server has no token env var', () => { + expect( + adapter.launchArgsForServer!({ name: 'docs', transport: 'http', url: 'https://d/mcp/' }) + ).toEqual(['-c', 'mcp_servers.docs.url="https://d/mcp/"']); + }); + + it('refuses a stdio server rather than emitting a partial table', () => { + // Dropping `args`/`env` would register a server that launches and then + // misbehaves, which is worse than refusing to register it at all. + expect(() => + adapter.launchArgsForServer!({ name: 'local', command: 'bun', args: ['server.ts'] }) + ).toThrow(/stdio MCP server 'local'/); + }); + + it('refuses a server with no url', () => { + expect(() => adapter.launchArgsForServer!({ name: 'broken' })).toThrow(/has no url/); + }); +}); diff --git a/dash/packages/core/src/agents/plugins/helpers/mcp.ts b/dash/packages/core/src/agents/plugins/helpers/mcp.ts index 8a2698854..14f8ff834 100644 --- a/dash/packages/core/src/agents/plugins/helpers/mcp.ts +++ b/dash/packages/core/src/agents/plugins/helpers/mcp.ts @@ -54,6 +54,9 @@ type McpConfigShape = { serversKey: string; toNative(server: McpServerRegistration): Record; fromNative(name: string, raw: Record): McpServerRegistration; + /** See {@link IMcpBehavior.launchArgsForServer}. Omit when the agent has no + * need to receive a server on argv. */ + launchArgsForServer?(server: McpServerRegistration): string[]; }; function parseMcpFile(content: string, format: 'json' | 'toml'): Record { @@ -135,6 +138,9 @@ export function createMcpAdapter(shape: McpConfigShape) { await removeFromPath(fs, legacyPath, name); } }, + ...(shape.launchArgsForServer + ? { launchArgsForServer: shape.launchArgsForServer.bind(shape) } + : {}), }; } @@ -189,6 +195,43 @@ export function codexMcpAdapter(configPath = '.codex/config.toml') { configPath, format: 'toml', serversKey: 'mcp_servers', + /** + * Codex takes config overrides as `-c =`, at higher + * precedence than any config file. + * + * Only HTTP servers can be expressed this way, so anything else is rejected + * rather than rendered into a server that would misbehave: a stdio server's + * `args` and `env` are arrays and tables rather than scalars. + * + * Each override is merged into the config's table for that server, not + * substituted for it. Verified against Codex 0.146.0: overriding + * `mcp_servers..url` on a name the config already defines as a stdio + * server yields a table with both `command` and `url`, and Codex then + * refuses to load its config at all — the session does not start. So the + * server name here must be one the user's `~/.codex/config.toml` does not + * already define. + */ + launchArgsForServer(server: McpServerRegistration): string[] { + if (server.command !== undefined || server.args !== undefined || server.env !== undefined) { + throw new Error( + `Codex cannot receive the stdio MCP server '${server.name}' on argv; register it in config instead.` + ); + } + if (typeof server.url !== 'string' || !server.url) { + throw new Error( + `Codex can only receive an HTTP MCP server on argv; '${server.name}' has no url.` + ); + } + + const entries: Array<[string, string]> = [['url', server.url]]; + if (server.bearer_token_env_var !== undefined) { + entries.push(['bearer_token_env_var', server.bearer_token_env_var]); + } + return entries.flatMap(([key, value]) => [ + '-c', + `mcp_servers.${server.name}.${key}=${JSON.stringify(value)}`, + ]); + }, toNative(s) { const entry = deepClone(s) as Record; const isHttp = diff --git a/dash/packages/core/src/agents/plugins/helpers/standard-command.ts b/dash/packages/core/src/agents/plugins/helpers/standard-command.ts index 7a7eb012d..820b0fc61 100644 --- a/dash/packages/core/src/agents/plugins/helpers/standard-command.ts +++ b/dash/packages/core/src/agents/plugins/helpers/standard-command.ts @@ -101,7 +101,7 @@ export function buildStandardCommand(ctx: CommandContext, spec: StandardCommandS // Use switchdash UUID args.push(spec.resumeFlag, ctx.sessionId!); } else if (spec.resumeWithoutSessionFlag) { - args.push(spec.resumeWithoutSessionFlag); + args.push(...splitFlag(spec.resumeWithoutSessionFlag)); } else { args.push(spec.resumeFlag); } diff --git a/dash/packages/core/src/agents/plugins/index.ts b/dash/packages/core/src/agents/plugins/index.ts index bd4108ee8..354387e4b 100644 --- a/dash/packages/core/src/agents/plugins/index.ts +++ b/dash/packages/core/src/agents/plugins/index.ts @@ -77,14 +77,18 @@ export type { LocalRepoAgent, RepoAgentAttributes, RepoAgentAttributeValue, - RepoAgentCredentials, RepoAgentDefinition, RepoAgentField, RepoAgentFieldOption, RepoAgentFieldType, RepoAgentsDescriptor, } from './capabilities/repo-agents'; -export type { ISwitchSetupBehavior, SwitchSetupDescriptor } from './capabilities/switch-setup'; +export type { + ISwitchSetupBehavior, + SwitchSetupCliDialect, + SwitchSetupDescriptor, +} from './capabilities/switch-setup'; +export { SWITCH_SETUP_CLI_DIALECTS } from './capabilities/switch-setup'; // Typed registry factory export { createPluginRegistry } from '../../lib/plugins/registry'; diff --git a/dash/packages/plugins/src/agents/impl/claude/index.ts b/dash/packages/plugins/src/agents/impl/claude/index.ts index 293a61779..68e52d0ef 100644 --- a/dash/packages/plugins/src/agents/impl/claude/index.ts +++ b/dash/packages/plugins/src/agents/impl/claude/index.ts @@ -112,6 +112,7 @@ export const plugin = definePlugin( marketplaceName: 'switch-plugins', marketplaceSource: SWITCH_MARKETPLACE_SOURCE, scope: 'user', + dialect: 'claude-code', }, }, { icon } diff --git a/dash/packages/plugins/src/agents/impl/claude/subagents.test.ts b/dash/packages/plugins/src/agents/impl/claude/subagents.test.ts index 8a16fa775..dc07c1a27 100644 --- a/dash/packages/plugins/src/agents/impl/claude/subagents.test.ts +++ b/dash/packages/plugins/src/agents/impl/claude/subagents.test.ts @@ -1,4 +1,3 @@ -import path from 'node:path'; import type { PluginFs } from '@switchdash/core/agents/plugins'; import { describe, expect, it } from 'vitest'; import { CLAUDE_SUBAGENTS, claudeRepoAgentsBehavior } from './subagents'; @@ -28,11 +27,13 @@ function fakeFs(files: Record): PluginFs { }; } +// Forward-slash literals, not `path.join`: these keys are what a PluginFs sees, +// and on Windows `path.join` would produce backslashes here *and* in the code +// under test, so the two would agree with each other and with nothing else. const settingsRel = (name: string) => - path.join(CLAUDE_SUBAGENTS.dirRelative, `${name}${CLAUDE_SUBAGENTS.settingsSuffix}`); + `${CLAUDE_SUBAGENTS.dirRelative}/${name}${CLAUDE_SUBAGENTS.settingsSuffix}`; /** Provider-neutral per-agent credentials file (the current write location). */ -const neutralRel = (name: string) => path.join('.switch', 'agents', `${name}.json`); -const defRel = (name: string) => path.join(CLAUDE_SUBAGENTS.definitionsDirRelative, `${name}.md`); +const defRel = (name: string) => `${CLAUDE_SUBAGENTS.definitionsDirRelative}/${name}.md`; describe('claudeRepoAgentsBehavior.launchArgs', () => { it('builds --agent and --settings for a subagent', () => { @@ -40,9 +41,17 @@ describe('claudeRepoAgentsBehavior.launchArgs', () => { '--agent', 'reviewer', '--settings', - path.join('/repo/agent', '.switch', 'agents', 'reviewer.json'), + '/repo/agent/.switch/agents/reviewer.json', ]); }); + + it('keeps a remote POSIX path POSIX', () => { + // launchArgs is handed `remoteRepoDir` / the SSH session path for a remote + // agent, and the flag is parsed by a shell on that Linux host. + const args = claudeRepoAgentsBehavior.launchArgs('/home/agent/repo', 'reviewer'); + expect(args[3]).toBe('/home/agent/repo/.switch/agents/reviewer.json'); + expect(args[3]).not.toContain('\\'); + }); }); describe('claudeRepoAgentsBehavior.discoverDefinitions', () => { @@ -208,7 +217,7 @@ describe('claudeRepoAgentsBehavior.attributeFields', () => { }); describe('claudeRepoAgentsBehavior.removeLocal', () => { - it('deletes both the definition and credentials files', async () => { + it('deletes the definition and the legacy per-agent settings', async () => { const workspaceFs = fakeFs({ [defRel('reviewer')]: '---\nname: reviewer\ndescription: x\n---\n', [settingsRel('reviewer')]: '{"env":{}}', @@ -219,52 +228,16 @@ describe('claudeRepoAgentsBehavior.removeLocal', () => { expect(await workspaceFs.exists(defRel('reviewer'))).toBe(false); expect(await workspaceFs.exists(settingsRel('reviewer'))).toBe(false); }); -}); - -describe('claudeRepoAgentsBehavior.writeSettings', () => { - it('writes the credentials JSON, permissions.allow, and a gitignore', async () => { - const workspaceFs = fakeFs({}); - await claudeRepoAgentsBehavior.writeCredentials(workspaceFs, { - agentName: 'reviewer', - apiEndpoint: 'https://s', - apiToken: 'secret', - agentId: 'a1', - }); - const written = await workspaceFs.read(neutralRel('reviewer')); - expect(JSON.parse(written!)).toEqual({ - permissions: { - allow: [ - 'mcp__plugin_switch-connector_switch', - 'mcp__plugin_switch-connector_switch-channel', - ], - }, - env: { SWITCH_API_ENDPOINT: 'https://s', SWITCH_API_TOKEN: 'secret', SWITCH_AGENT_ID: 'a1' }, - }); - expect(await workspaceFs.read(path.join('.switch', 'agents', '.gitignore'))).toBe('*\n'); - }); - - it('preserves existing permissions and env, unioning the Switch rules', async () => { + it('leaves the provider-neutral credentials to the caller, which removes them for every provider', async () => { + const neutralRel = '.switch/agents/reviewer.json'; const workspaceFs = fakeFs({ - [neutralRel('reviewer')]: JSON.stringify({ - permissions: { allow: ['Bash'] }, - env: { KEEP: 'me' }, - }), - }); - await claudeRepoAgentsBehavior.writeCredentials(workspaceFs, { - agentName: 'reviewer', - apiEndpoint: 'https://s', - apiToken: 'secret', - agentId: 'a1', + [defRel('reviewer')]: '---\nname: reviewer\ndescription: x\n---\n', + [neutralRel]: '{"env":{}}', }); - const settings = JSON.parse((await workspaceFs.read(neutralRel('reviewer')))!); - expect(settings.permissions.allow).toEqual([ - 'Bash', - 'mcp__plugin_switch-connector_switch', - 'mcp__plugin_switch-connector_switch-channel', - ]); - expect(settings.env.KEEP).toBe('me'); - expect(settings.env.SWITCH_AGENT_ID).toBe('a1'); + await claudeRepoAgentsBehavior.removeLocal(workspaceFs, 'reviewer'); + + expect(await workspaceFs.exists(neutralRel)).toBe(true); }); }); diff --git a/dash/packages/plugins/src/agents/impl/claude/subagents.ts b/dash/packages/plugins/src/agents/impl/claude/subagents.ts index e37e2b8c0..991b75bd0 100644 --- a/dash/packages/plugins/src/agents/impl/claude/subagents.ts +++ b/dash/packages/plugins/src/agents/impl/claude/subagents.ts @@ -4,7 +4,6 @@ import { type LocalRepoAgent, type PluginFs, type RepoAgentAttributes, - type RepoAgentCredentials, type RepoAgentDefinition, type RepoAgentField, SWITCH_AGENT_SETTINGS_DIR, @@ -19,10 +18,16 @@ import { * also injected as real env vars (the credentials file's `env` block is not * reliably propagated to the spawned MCP server otherwise). */ +/** + * Forward-slash literals, never `path.join`: these are relative paths handed to + * a `PluginFs`, which is either the local disk or a remote POSIX host over SFTP, + * and `path.join` emits backslashes when switchdash runs on Windows. Same rule + * as `switch-settings-paths.ts`. + */ export const CLAUDE_SUBAGENTS = { - dirRelative: path.join('.claude', 'switch-subagents'), + dirRelative: '.claude/switch-subagents', settingsSuffix: '.settings.json', - definitionsDirRelative: path.join('.claude', 'agents'), + definitionsDirRelative: '.claude/agents', } as const; const SWITCH_ENV_KEYS = ['SWITCH_API_ENDPOINT', 'SWITCH_API_TOKEN', 'SWITCH_AGENT_ID'] as const; @@ -327,12 +332,12 @@ function parseSettingsObject(raw: string | null): Record { /** Legacy per-subagent credentials file under `.claude/switch-subagents/`. */ function settingsRelPath(name: string): string { - return path.join(CLAUDE_SUBAGENTS.dirRelative, `${name}${CLAUDE_SUBAGENTS.settingsSuffix}`); + return `${CLAUDE_SUBAGENTS.dirRelative}/${name}${CLAUDE_SUBAGENTS.settingsSuffix}`; } /** Provider-neutral per-agent credentials file (the current location). */ function neutralSettingsRelPath(name: string): string { - return path.join(SWITCH_AGENT_SETTINGS_DIR, `${name}.json`); + return `${SWITCH_AGENT_SETTINGS_DIR}/${name}.json`; } /** @@ -350,7 +355,7 @@ async function readCredsObject( } function definitionRelPath(name: string): string { - return path.join(CLAUDE_SUBAGENTS.definitionsDirRelative, `${name}${MD_SUFFIX}`); + return `${CLAUDE_SUBAGENTS.definitionsDirRelative}/${name}${MD_SUFFIX}`; } /** Description/model from a subagent's definition, project scope then user scope. */ @@ -425,7 +430,7 @@ export const claudeRepoAgentsBehavior: IRepoAgentsBehavior = { return Promise.all( files.map(async (file) => { const content = - (await workspaceFs.read(path.join(CLAUDE_SUBAGENTS.definitionsDirRelative, file))) ?? ''; + (await workspaceFs.read(`${CLAUDE_SUBAGENTS.definitionsDirRelative}/${file}`)) ?? ''; const fm = parseFrontmatter(content); const name = fm.name ?? file.slice(0, -MD_SUFFIX.length); const registered = await workspaceFs.exists(settingsRelPath(name)); @@ -441,11 +446,15 @@ export const claudeRepoAgentsBehavior: IRepoAgentsBehavior = { }, launchArgs(workingDir, agentName): string[] { + // `path.posix`: workingDir is the agent's dir on whatever host it runs on, + // which for a remote agent is a POSIX path on the VM. Plain `path.join` on a + // Windows switchdash would emit backslash separators into a flag that a + // Linux shell then has to parse. return [ '--agent', agentName, '--settings', - path.join(workingDir, neutralSettingsRelPath(agentName)), + path.posix.join(workingDir, neutralSettingsRelPath(agentName)), ]; }, @@ -460,42 +469,6 @@ export const claudeRepoAgentsBehavior: IRepoAgentsBehavior = { return result; }, - async writeCredentials(workspaceFs, credentials: RepoAgentCredentials): Promise { - // Keep the tokens out of git — `*` ignores everything in the directory. - const gitignoreRel = path.join(SWITCH_AGENT_SETTINGS_DIR, '.gitignore'); - if (!(await workspaceFs.exists(gitignoreRel))) { - await workspaceFs.write(gitignoreRel, '*\n'); - } - - const relPath = neutralSettingsRelPath(credentials.agentName); - const existing = parseSettingsObject(await workspaceFs.read(relPath)); - const currentEnv = (existing.env ?? {}) as Record; - const currentPerms = - existing.permissions && typeof existing.permissions === 'object' - ? (existing.permissions as Record) - : {}; - const currentAllow = Array.isArray(currentPerms.allow) - ? (currentPerms.allow as unknown[]).map(String) - : []; - - const settings = { - ...existing, - // Auto-approve the Switch connector tools so the subagent never has to ask - // ("don't ask"), on top of whatever the file already allowed. - permissions: { - ...currentPerms, - allow: dedupe([...currentAllow, ...SWITCH_CONNECTOR_TOOL_RULES]), - }, - env: { - ...currentEnv, - SWITCH_API_ENDPOINT: credentials.apiEndpoint, - SWITCH_API_TOKEN: credentials.apiToken, - SWITCH_AGENT_ID: credentials.agentId, - }, - }; - await workspaceFs.write(relPath, `${JSON.stringify(settings, null, 2)}\n`); - }, - attributeFields(): RepoAgentField[] { return CLAUDE_SUBAGENT_FIELDS; }, @@ -534,7 +507,6 @@ export const claudeRepoAgentsBehavior: IRepoAgentsBehavior = { async removeLocal(workspaceFs, name): Promise { await workspaceFs.delete(definitionRelPath(name)); - await workspaceFs.delete(neutralSettingsRelPath(name)); await workspaceFs.delete(settingsRelPath(name)); }, }; diff --git a/dash/packages/plugins/src/agents/impl/codex/command.test.ts b/dash/packages/plugins/src/agents/impl/codex/command.test.ts new file mode 100644 index 000000000..a9fce255d --- /dev/null +++ b/dash/packages/plugins/src/agents/impl/codex/command.test.ts @@ -0,0 +1,89 @@ +import type { CommandContext } from '@switchdash/core/agents/plugins'; +import { describe, expect, it } from 'vitest'; +import { provider } from './index'; + +function build(ctx: CommandContext) { + return provider.behavior.prompt!.buildCommand(ctx); +} + +const base: CommandContext = { + cli: 'codex', + autoApprove: false, + isResuming: false, + sessionId: 'switchdash-session', + model: '', +}; + +// Emitted for every session: Codex runs no hook it has no persisted trust +// entry for, and switchdash's hooks are how it tracks rooms and captures the +// rollout id. +const TRUST_FLAG = '--dangerously-bypass-hook-trust'; + +// The sandbox/approval flag, split the way buildStandardCommand splits it on +// whitespace. +const AUTO_FLAGS = ['-c', 'approval_policy="never"', '-c', 'sandbox_mode="danger-full-access"']; + +describe('codex buildCommand', () => { + it('starts a fresh session with auto-approve flags then the positional prompt', () => { + const cmd = build({ ...base, autoApprove: true, initialPrompt: 'Fix the bug' }); + + expect(cmd.command).toBe('codex'); + // sessionIdOnResumeOnly → the switchdash UUID is never injected on a fresh run. + // Full structural check: auto-approve flags in order, prompt last. + expect(cmd.args).toEqual([TRUST_FLAG, ...AUTO_FLAGS, 'Fix the bug']); + }); + + it('omits auto-approve args when autoApprove is false, but keeps hook trust', () => { + // Hook trust is orthogonal to the sandbox: gate it on auto-approve and a + // default agent runs none of switchdash's hooks, taking room tracking and + // rollout-id capture with them. + const cmd = build({ ...base, initialPrompt: 'hello' }); + expect(cmd.args).not.toContain('approval_policy="never"'); + expect(cmd.args).toEqual([TRUST_FLAG, 'hello']); + }); + + it('keeps hook trust on resume, where the flag precedes the subcommand', () => { + const cmd = build({ ...base, isResuming: true, providerSessionId: 'rollout-9' }); + expect(cmd.args.slice(0, 3)).toEqual([TRUST_FLAG, 'resume', 'rollout-9']); + }); + + it('resumes with the captured rollout session id', () => { + const cmd = build({ ...base, isResuming: true, providerSessionId: 'rollout-9' }); + expect(cmd.args[1]).toBe('resume'); + expect(cmd.args[2]).toBe('rollout-9'); + // No positional prompt is added on resume. + expect(cmd.args).not.toContain('Fix the bug'); + }); + + it('orders resume subcommand + id BEFORE the auto-approve flags on resume', () => { + const cmd = build({ + ...base, + isResuming: true, + providerSessionId: 'rollout-9', + autoApprove: true, + }); + // Regression guard on arg order: `resume ` must precede the -c flags, + // and no positional prompt is appended on resume. + expect(cmd.args).toEqual([TRUST_FLAG, 'resume', 'rollout-9', ...AUTO_FLAGS]); + }); + + it('falls back to `resume --last` as split args when no rollout id was captured', () => { + const cmd = build({ ...base, isResuming: true }); + // Regression guard: the multi-token fallback must be two argv elements, + // not a single "resume --last" string. + expect(cmd.args.slice(1, 3)).toEqual(['resume', '--last']); + }); + + it('deduplicates the bypass-approvals-and-sandbox singleton flag', () => { + const cmd = build({ + ...base, + extraArgs: [ + '--dangerously-bypass-approvals-and-sandbox', + '--dangerously-bypass-approvals-and-sandbox', + ], + }); + expect(cmd.args.filter((a) => a === '--dangerously-bypass-approvals-and-sandbox')).toHaveLength( + 1 + ); + }); +}); diff --git a/dash/packages/plugins/src/agents/impl/codex/hooks.test.ts b/dash/packages/plugins/src/agents/impl/codex/hooks.test.ts new file mode 100644 index 000000000..b09c1f94b --- /dev/null +++ b/dash/packages/plugins/src/agents/impl/codex/hooks.test.ts @@ -0,0 +1,342 @@ +import { execFile, spawn } from 'node:child_process'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { promisify } from 'node:util'; +import type { PluginFs } from '@switchdash/core/agents/plugins'; +import { describe, expect, it } from 'vitest'; +import { + CODEX_CONFIG_PATH, + CODEX_HOOKS_PATH, + CODEX_ROOM_CONNECT_MATCHER, + buildCodexHookConfig, +} from './hooks'; + +const execFileAsync = promisify(execFile); + +/** + * Execute a generated hook command under a real `sh` with `curl` stubbed out, + * feeding it an event payload on stdin the way Codex does, and report the URL + * and request body it would have posted. + * + * A copy of the harness in `@switchdash/core`'s `helpers/hooks.test.ts`; this + * package resolves that one through `dist` subpath exports, which do not carry + * test files. + */ +async function runHookCommand( + command: string, + { env, stdin }: { env: Record; stdin: string } +): Promise<{ url: string; body: string }> { + const dir = await mkdtemp(path.join(tmpdir(), 'codex-hook-')); + try { + const stub = path.join(dir, 'curl'); + const argvOut = path.join(dir, 'argv'); + const bodyOut = path.join(dir, 'body'); + await writeFile( + stub, + '#!/bin/sh\n' + + `printf '%s\\n' "$@" > ${JSON.stringify(argvOut)}\n` + + `for a in "$@"; do [ "$a" = "@-" ] && cat > ${JSON.stringify(bodyOut)}; done\n` + + 'exit 0\n', + { mode: 0o755 } + ); + await writeFile(bodyOut, ''); + await writeFile(argvOut, ''); + + await new Promise((resolve, reject) => { + const child = spawn('sh', ['-c', command], { + env: { ...env, PATH: `${dir}:${process.env.PATH ?? ''}` }, + stdio: ['pipe', 'ignore', 'ignore'], + }); + child.on('error', reject); + child.on('close', () => resolve()); + child.stdin.end(stdin); + }); + + const { stdout: argv } = await execFileAsync('cat', [argvOut]); + const { stdout: body } = await execFileAsync('cat', [bodyOut]); + return { url: argv.split('\n').find((a) => a.startsWith('http://')) ?? '', body }; + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +/** The `command` strings switchdash writes into Codex's `hooks.json`, by event. */ +async function installedCommands(): Promise> { + const fs = createMemoryFs(); + await buildCodexHookConfig().writeHooks(fs, []); + const config = JSON.parse((await fs.read(CODEX_HOOKS_PATH))!) as { + hooks: Record }>>; + }; + return Object.fromEntries( + Object.entries(config.hooks).map(([key, entries]) => [key, entries[0].hooks[0].command]) + ); +} + +function createMemoryFs(initial: Record = {}): PluginFs { + const files = new Map(Object.entries(initial)); + return { + async read(path) { + return files.get(path) ?? null; + }, + async write(path, content) { + files.set(path, content); + }, + async delete(path) { + files.delete(path); + }, + async exists(path) { + return files.has(path); + }, + async list(path) { + return [...files.keys()].filter((file) => file.startsWith(path)); + }, + }; +} + +describe('buildCodexHookConfig.parseHookEvent', () => { + const { parseHookEvent } = buildCodexHookConfig(); + + it('captures the rollout session id from session-start', () => { + expect(parseHookEvent('session-start', { session_id: 'abc123' })).toEqual({ + kind: 'session', + providerSessionId: 'abc123', + }); + }); + + it('falls back through resource_id / resourceId / sessionId', () => { + expect(parseHookEvent('session-start', { resource_id: 'r1' })).toEqual({ + kind: 'session', + providerSessionId: 'r1', + }); + expect(parseHookEvent('session-start', { resourceId: 'r2' })).toEqual({ + kind: 'session', + providerSessionId: 'r2', + }); + expect(parseHookEvent('session-start', { sessionId: 'r3' })).toEqual({ + kind: 'session', + providerSessionId: 'r3', + }); + }); + + it('trims the id and ignores a blank or missing session id', () => { + expect(parseHookEvent('session-start', { session_id: ' x ' })).toEqual({ + kind: 'session', + providerSessionId: 'x', + }); + expect(parseHookEvent('session-start', { session_id: ' ' })).toEqual({ kind: 'ignore' }); + expect(parseHookEvent('session-start', {})).toEqual({ kind: 'ignore' }); + }); + + it('maps an idle_prompt notification to a stop status', () => { + expect(parseHookEvent('notification', { notification_type: 'idle_prompt' })).toEqual({ + kind: 'status', + type: 'stop', + }); + }); + + it('maps agent-turn-complete (no notification_type) to a stop status', () => { + expect(parseHookEvent('notification', { type: 'agent-turn-complete' })).toEqual({ + kind: 'status', + type: 'stop', + }); + }); + + it('maps a permission_prompt notification to a notification status', () => { + expect(parseHookEvent('notification', { notification_type: 'permission_prompt' })).toEqual({ + kind: 'status', + type: 'notification', + notificationType: 'permission_prompt', + }); + }); + + it('defers unrelated events to the default parser', () => { + expect(parseHookEvent('stop', {})).toMatchObject({ kind: 'status', type: 'stop' }); + expect(parseHookEvent('totally-unknown', {})).toEqual({ kind: 'ignore' }); + }); + + it('handles an empty notification body without a stop/permission misclassification', () => { + // nt undefined and no `type: agent-turn-complete` → falls to the default + // parser, which yields a plain notification status (not a spurious stop). + expect(parseHookEvent('notification', {})).toMatchObject({ + kind: 'status', + type: 'notification', + }); + }); +}); + +describe('buildCodexHookConfig install/read/delete', () => { + it('installs Stop / PermissionRequest / SessionStart hooks and reports the written path', async () => { + const fs = createMemoryFs(); + const paths = await buildCodexHookConfig().writeHooks(fs, []); + + expect(paths).toEqual([CODEX_HOOKS_PATH]); + const config = JSON.parse((await fs.read(CODEX_HOOKS_PATH))!) as { + hooks: Record; + }; + for (const key of ['Stop', 'PermissionRequest', 'SessionStart', 'PostToolUse']) { + expect(config.hooks[key]).toHaveLength(1); + expect(JSON.stringify(config.hooks[key][0])).toContain('SWITCHDASH_HOOK_PORT'); + } + }); + + it('tracks the room via a PostToolUse hook scoped to the Switch connect tool', async () => { + // Without this the poller stays pinned to the room the session spawned in: + // an agent that hops rooms keeps getting room A's messages while sitting in + // room B, with nothing raised. The hook makes every connect_to_room correct + // the association. + const fs = createMemoryFs(); + await buildCodexHookConfig().writeHooks(fs, []); + const config = JSON.parse((await fs.read(CODEX_HOOKS_PATH))!) as { + hooks: Record>; + }; + + const entry = config.hooks.PostToolUse[0]; + expect(entry.matcher).toBe(CODEX_ROOM_CONNECT_MATCHER); + // Matches the Switch tool on any server name, but not unrelated tools. + const matcher = new RegExp(`^${CODEX_ROOM_CONNECT_MATCHER}$`); + expect(matcher.test('mcp__switch__connect_to_room')).toBe(true); + expect(matcher.test('mcp__plugin_switch-connector_switch__connect_to_room')).toBe(true); + expect(matcher.test('mcp__switch__post_message')).toBe(false); + expect(matcher.test('Bash')).toBe(false); + // Reports the same event type switchdash's Claude hook config emits, so both + // providers land on one enricher path. + expect(JSON.stringify(entry)).toContain('switch_room_connect'); + }); + + it('reflects installation state through getHooksInstalled + readHooks', async () => { + const fs = createMemoryFs(); + const cfg = buildCodexHookConfig(); + + expect(await cfg.getHooksInstalled(fs)).toBe(false); + expect(await cfg.readHooks(fs)).toEqual([]); + + await cfg.writeHooks(fs, []); + + expect(await cfg.getHooksInstalled(fs)).toBe(true); + expect(await cfg.readHooks(fs)).toEqual([ + { event: 'switchdash', command: 'SWITCHDASH_HOOK_PORT' }, + ]); + }); + + it('preserves user hooks and removes only switchdash entries on delete', async () => { + const userEntry = { hooks: [{ type: 'command', command: 'echo hi' }] }; + const fs = createMemoryFs({ + [CODEX_HOOKS_PATH]: JSON.stringify({ hooks: { Stop: [userEntry] } }), + }); + const cfg = buildCodexHookConfig(); + + await cfg.writeHooks(fs, []); + let config = JSON.parse((await fs.read(CODEX_HOOKS_PATH))!) as { + hooks: Record; + }; + // The user's own Stop hook survives alongside the injected switchdash one. + expect(config.hooks.Stop).toHaveLength(2); + + await cfg.deleteHooks(fs); + config = JSON.parse((await fs.read(CODEX_HOOKS_PATH))!) as { hooks: Record }; + expect(config.hooks.Stop).toEqual([userEntry]); + expect(await cfg.getHooksInstalled(fs)).toBe(false); + }); + + it('migrates away a legacy config.toml notify command on write', async () => { + const configToml = [ + 'notify = ["powershell.exe", "-NoProfile", "-File", "/tmp/switchdash-codex-notify.ps1"]', + 'model = "gpt-5"', + '', + ].join('\n'); + const fs = createMemoryFs({ [CODEX_CONFIG_PATH]: configToml }); + + await buildCodexHookConfig().writeHooks(fs, []); + + const rewritten = (await fs.read(CODEX_CONFIG_PATH))!; + expect(rewritten).not.toContain('notify'); + // Unrelated config is left intact. + expect(rewritten).toContain('gpt-5'); + }); + + it('refuses to install hooks over an unparseable hooks file', async () => { + // Rewriting from scratch would silently discard every hook the user has + // configured, so a file we cannot parse must stop the install. + const fs = createMemoryFs({ [CODEX_HOOKS_PATH]: '{ not json' }); + + await expect(buildCodexHookConfig().writeHooks(fs, [])).rejects.toThrow(/not valid JSON/); + expect(await fs.read(CODEX_HOOKS_PATH)).toBe('{ not json'); + }); + + it('refuses to delete hooks from an unparseable hooks file', async () => { + const fs = createMemoryFs({ [CODEX_HOOKS_PATH]: '{ not json' }); + + await expect(buildCodexHookConfig().deleteHooks(fs)).rejects.toThrow(/not valid JSON/); + expect(await fs.read(CODEX_HOOKS_PATH)).toBe('{ not json'); + }); + + it('propagates a failed hooks-file read instead of rewriting from scratch', async () => { + const fs = createMemoryFs(); + fs.read = async () => { + throw new Error('transport failure'); + }; + + await expect(buildCodexHookConfig().writeHooks(fs, [])).rejects.toThrow('transport failure'); + }); +}); + +describe('the installed Codex hook commands actually post', () => { + // Codex runs each command as `$SHELL -lc ""` and writes the event + // JSON to its stdin. These commands resolve their endpoint in shell, so + // asserting on the string only proves the text is present — a command that + // resolves an empty port, or never reads the payload, passes a string check + // and then fails silently behind curl's `|| true`. + const ENV = { + SWITCHDASH_HOOK_PORT: '5001', + SWITCHDASH_HOOK_TOKEN: 'env-token', + SWITCHDASH_PTY_ID: 'codex:s1', + }; + + it.each(['SessionStart', 'PostToolUse'])( + 'the %s command reaches the hook server with the payload intact', + async (event) => { + const payload = JSON.stringify({ + session_id: 's1', + tool_response: { room_id: 'r1', agent_id: 'a1' }, + }); + const { url, body } = await runHookCommand((await installedCommands())[event], { + env: ENV, + stdin: payload, + }); + + expect(url).toBe('http://127.0.0.1:5001/hook'); + expect(body).toBe(payload); + } + ); + + it.each(['Stop', 'PermissionRequest'])( + 'the %s command reaches the hook server with its fixed body', + async (event) => { + const { url } = await runHookCommand((await installedCommands())[event], { + env: ENV, + stdin: '', + }); + + expect(url).toBe('http://127.0.0.1:5001/hook'); + } + ); + + it('prefers the sidecar endpoint file over the baked-in env, for every event', async () => { + const dir = await mkdtemp(path.join(tmpdir(), 'codex-ep-')); + const endpointFile = path.join(dir, 'endpoint'); + await writeFile(endpointFile, '6002\nfresh-token\n'); + + try { + for (const command of Object.values(await installedCommands())) { + const { url } = await runHookCommand(command, { + env: { ...ENV, SWITCHDASH_HOOK_ENDPOINT_FILE: endpointFile }, + stdin: '{}', + }); + expect(url).toBe('http://127.0.0.1:6002/hook'); + } + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/dash/packages/plugins/src/agents/impl/codex/hooks.ts b/dash/packages/plugins/src/agents/impl/codex/hooks.ts index c405012a6..1fcc4cbd5 100644 --- a/dash/packages/plugins/src/agents/impl/codex/hooks.ts +++ b/dash/packages/plugins/src/agents/impl/codex/hooks.ts @@ -1,19 +1,36 @@ import type { PluginFs } from '@switchdash/core/agents/plugins'; import type { CanonicalHookEvent, HookRegistration } from '@switchdash/core/agents/plugins'; import { - SWITCHDASH_MARKER, - buildNestedEntry, + buildNestedJsonHookConfig, defaultHookEventParser, - filterUserHooks, - makeHookPostCommand, makeNotificationHookCommand, - readJsonConfig, - writeJsonConfig, + makeStdinHookCommand, } from '@switchdash/core/agents/plugins/helpers'; import * as toml from 'smol-toml'; export const CODEX_HOOKS_PATH = '.codex/hooks.json'; -const CODEX_CONFIG_PATH = '.codex/config.toml'; +export const CODEX_CONFIG_PATH = '.codex/config.toml'; + +/** + * Lets Codex run the hooks switchdash installed without a persisted trust entry. + * + * Codex keys hook trust per entry in `~/.codex/config.toml` + * (`[hooks.state.":::"] trusted_hash`) and + * skips any hook it has no entry for. Verified against 0.146.0: in `codex exec` + * that skip is silent — no dump, no mention of the hook in the transcript — and + * in the TUI it is a blocking startup review pane that a detached session has + * nobody to answer. Either way switchdash's own hooks would not run, taking + * room tracking and rollout-id capture with them, and rewriting a hook command + * invalidates the entry a user had already granted. + * + * switchdash writes those hooks itself, which is the case the flag is documented + * for ("automation that already vets hook sources"). It is per-invocation and + * covers every enabled hook, so a hook the user added to `~/.codex/hooks.json` + * also runs unreviewed in switchdash-launched sessions. Writing per-entry trust + * instead would be narrower, but the hash input is undocumented and not + * derivable from the command text, so it would break silently on a Codex change. + */ +export const CODEX_HOOK_TRUST_FLAG = '--dangerously-bypass-hook-trust'; const LEGACY_CODEX_NOTIFY_COMMAND = [ 'bash', @@ -58,11 +75,21 @@ async function removeLegacyCodexNotify(fs: PluginFs): Promise { await fs.write(CODEX_CONFIG_PATH, toml.stringify(config)); } -function makeCodexSessionStartCommand(): string { - const post = makeHookPostCommand('session-start', 'stdin', {}); - if (process.platform === 'win32') return post; - return `INPUT="\${1:-$(cat)}"; printf '%s' "$INPUT" | ${post}`; -} +/** + * Tool-name pattern for the Switch `connect_to_room` MCP tool. The server name + * is part of the tool name, and switchdash registers the server as `switch`, + * but a session may reach Switch through a differently-named server — so match + * any server exposing the tool rather than pinning to one name. + */ +export const CODEX_ROOM_CONNECT_MATCHER = 'mcp__.*__connect_to_room'; + +/** + * Event type the room-tracking hook reports. Consumed by the hook service's + * event enricher, which reads `room_id`/`agent_id` out of the tool response and + * repoints the session's room. Claude's hook config emits the same event from + * its equivalent PostToolUse hook, so both providers share one enricher path. + */ +const SWITCH_ROOM_CONNECT_EVENT = 'switch_room_connect'; /** * Codex sends `{ type: 'agent-turn-complete' }` as its stop signal instead @@ -94,53 +121,25 @@ function parseCodexHookEvent(eventType: string, body: Record): } export function buildCodexHookConfig() { - const stopCmd = makeNotificationHookCommand('idle_prompt'); - const permCmd = makeNotificationHookCommand('permission_prompt'); - const sessionCmd = makeCodexSessionStartCommand(); + const base = buildNestedJsonHookConfig(CODEX_HOOKS_PATH, [ + { hookKey: 'Stop', command: makeNotificationHookCommand('idle_prompt') }, + { hookKey: 'PermissionRequest', command: makeNotificationHookCommand('permission_prompt') }, + { hookKey: 'SessionStart', command: makeStdinHookCommand('session-start') }, + // Matcher-scoped to the Switch connect tool; the rest are lifecycle events + // that carry no matcher. + { + hookKey: 'PostToolUse', + command: makeStdinHookCommand(SWITCH_ROOM_CONNECT_EVENT), + matcher: CODEX_ROOM_CONNECT_MATCHER, + }, + ]); return { - async readHooks(fs: PluginFs): Promise { - const config = await readJsonConfig(fs, CODEX_HOOKS_PATH); - const hooks = (config.hooks ?? {}) as Record; - const installed = ['Stop', 'PermissionRequest', 'SessionStart'].some((k) => { - const entries = Array.isArray(hooks[k]) ? hooks[k] : []; - return entries.some((e) => JSON.stringify(e).includes(SWITCHDASH_MARKER)); - }); - return installed ? [{ event: 'switchdash', command: SWITCHDASH_MARKER }] : []; - }, - async writeHooks(fs: PluginFs, _hooks: HookRegistration[]): Promise { - const config = await readJsonConfig(fs, CODEX_HOOKS_PATH); - const hooks = (config.hooks ?? {}) as Record; - for (const [key, cmd] of [ - ['Stop', stopCmd], - ['PermissionRequest', permCmd], - ['SessionStart', sessionCmd], - ] as [string, string][]) { - const existing = Array.isArray(hooks[key]) ? hooks[key] : []; - hooks[key] = [ - ...filterUserHooks(existing as Record[]), - buildNestedEntry(cmd), - ]; - } - await writeJsonConfig(fs, CODEX_HOOKS_PATH, { ...config, hooks }); - await removeLegacyCodexNotify(fs).catch(() => {}); - return [CODEX_HOOKS_PATH]; - }, - async deleteHooks(fs: PluginFs): Promise { - const config = await readJsonConfig(fs, CODEX_HOOKS_PATH); - const hooks = (config.hooks ?? {}) as Record; - for (const key of Object.keys(hooks)) { - hooks[key] = filterUserHooks(hooks[key] as Record[]); - } - await writeJsonConfig(fs, CODEX_HOOKS_PATH, { ...config, hooks }); - }, - async getHooksInstalled(fs: PluginFs): Promise { - const config = await readJsonConfig(fs, CODEX_HOOKS_PATH); - const hooks = (config.hooks ?? {}) as Record; - return ['Stop', 'PermissionRequest', 'SessionStart'].some((k) => { - const entries = Array.isArray(hooks[k]) ? hooks[k] : []; - return entries.some((e) => JSON.stringify(e).includes(SWITCHDASH_MARKER)); - }); + ...base, + async writeHooks(fs: PluginFs, hooks: HookRegistration[]): Promise { + const paths = await base.writeHooks(fs, hooks); + await removeLegacyCodexNotify(fs); + return paths; }, parseHookEvent: parseCodexHookEvent, }; diff --git a/dash/packages/plugins/src/agents/impl/codex/index.ts b/dash/packages/plugins/src/agents/impl/codex/index.ts index fdcafa59d..75216d7e3 100644 --- a/dash/packages/plugins/src/agents/impl/codex/index.ts +++ b/dash/packages/plugins/src/agents/impl/codex/index.ts @@ -5,7 +5,8 @@ import { homebrewOption, npmDependency, } from '@switchdash/core/agents/plugins/helpers'; -import { buildCodexHookConfig } from './hooks'; +import { SWITCH_MARKETPLACE_SOURCE } from '../../../distribution'; +import { buildCodexHookConfig, CODEX_HOOK_TRUST_FLAG } from './hooks'; import { icon } from './icon'; export const plugin = definePlugin( @@ -26,7 +27,7 @@ export const plugin = definePlugin( hooks: { kind: 'config', scope: 'global', - supportedEvents: ['notification', 'stop', 'session'], + supportedEvents: ['notification', 'stop', 'session', 'tool-done'], }, hostDependency: npmDependency({ id: 'codex', @@ -64,7 +65,15 @@ export const plugin = definePlugin( kind: 'resumable', }, repoAgents: { kind: 'none' }, - switchSetup: { kind: 'none' }, + switchSetup: { + kind: 'cli', + pluginName: 'switch-connector-codex', + marketplaceName: 'switch-plugins', + marketplaceSource: SWITCH_MARKETPLACE_SOURCE, + // Codex has no install-scope flag; the value is unused for this dialect. + scope: 'user', + dialect: 'codex', + }, }, { icon } ); @@ -73,8 +82,14 @@ export const provider = registerPluginBehavior(plugin, { prompt: { buildCommand: (ctx) => buildStandardCommand(ctx, { - autoApproveFlag: - '-c approval_policy="never" -c sandbox_mode="danger-full-access" --dangerously-bypass-hook-trust', + // Every session, not just auto-approving ones. See the flag's docblock. + defaultArgs: [CODEX_HOOK_TRUST_FLAG], + // Deliberately overrides any sandbox_mode in the user's + // ~/.codex/config.toml. Codex's own default, workspace-write, blocks + // network access including loopback, and switchdash's hooks are curls + // to 127.0.0.1 that end in `|| true` — under a sandbox they fail + // silently, taking room tracking and rollout-id capture with them. + autoApproveFlag: '-c approval_policy="never" -c sandbox_mode="danger-full-access"', initialPromptFlag: '', resumeFlag: 'resume', sessionIdFlag: ' ', diff --git a/docs/TELEMETRY.md b/docs/TELEMETRY.md new file mode 100644 index 000000000..3568ea778 --- /dev/null +++ b/docs/TELEMETRY.md @@ -0,0 +1,335 @@ +# Switch Console telemetry — what we collect, where it goes, and why it cannot be traced to a person + +**Audience:** InfoSec. +**Scope:** the Switch Console desktop app. Switch server-side logging is out of +scope and needs its own pass. +**Claim:** the data we transmit is anonymous. No field identifies a person, and +no combination of the fields we transmit can be resolved back to one. + +Sections 1–6 describe the Console as it is today, verified against the source. +Section 7 states the requirements on the relay; items there marked **[TO +CONFIRM]** are the target state and have not yet been verified against the +relay's configuration. + +--- + +## 1. What Switch is, in two lines + +Switch runs AI coding agents (Claude Code, Codex, …) and connects them to chat +channels like Slack and Mattermost so people and agents work in shared rooms. +**Switch Console** is the desktop app used to drive it: create agents, start +sessions, connect to a server, set up rooms and bridges. + +Telemetry here is product-usage analytics from that desktop app — *how the app is +used*, never *what is done with it*. + +--- + +## 2. A real event, in full + +This is the complete wire payload for one event — nothing is omitted or +abbreviated: + +```json +{ + "resource": { + "service.name": "switch-console", + "service.version": "0.9.14", + "flint.client_id": "3f2a9c41-8d7e-4b16-9a55-c0e1d2f47b83", + "os.type": "darwin", + "os.version": "23.6.0" + }, + "event.name": "session_started", + "timeUnixNano": "1789572278158000000", + "severityText": "INFO", + "attributes": { + "build": "stable", + "agent_type": "claude", + "location": "local", + "outcome": "success", + "failure_reason": "none", + "entry_point": "command_palette", + "start_source": "user", + "has_initial_prompt": true, + "connected_to_room": false + } +} +``` + +Read it as: *an installation identified only by a random UUID, on macOS, started +a Claude session locally from the command palette; it had some initial prompt +text, and was not connected to a room.* Nothing in it says who, where, on what +machine, in what repository, or what the prompt said. + +--- + +## 3. Every field we collect, with example values + +### 3.1 Attached to every event + +| Field | What it is | Example values | +|---|---|---| +| `service.name` | constant | `switch-console` | +| `service.version` | app version | `0.9.14`, `1.0.2` | +| `build` | release channel | `dev`, `canary`, `stable` | +| `os.type` | OS family | `darwin`, `windows`, `linux`, `other` | +| `os.version` | OS release string | `23.6.0`, `10.0.22631`, `6.1.0-53-cloud-amd64` | +| `flint.client_id` | random install UUID | `3f2a9c41-8d7e-4b16-9a55-c0e1d2f47b83` | +| `event.name` | the event | `session_started` | +| timestamp | event time | `1789572278158000000` | + +That is the entire ambient set. No hostname, no username, no account, no machine +id, no IP field, no Switch identity. + +### 3.2 Values shared across events + +| Field | Complete set of possible values | +|---|---| +| `outcome` | `success`, `failure` (on `session_ended`: `normal`, `failed`) | +| `agent_type` | the AI provider, from a fixed registry: `claude`, `codex`, `gemini`, `cursor`, `copilot`, `opencode`, `grok`, `devin`, `qwen`, `droid`, `amp`, `goose`, `cline`, `continue`, `mistral`, `kiro`, `junie`, … and `unknown`. **Never an agent's name.** | +| `location` | `local`, `remote`, `unknown`. **Never a path, directory or project name.** | +| `server_kind` | `local`, `remote_managed`, `external`. **Never a server name or URL.** | +| `bridge_platform` | `slack`, `mattermost`, `discord`, `teams`, `telegram`, `other`, `unknown`. **Never a workspace or channel name.** | +| `entry_point` | `command_palette`, `sidebar`, `server_page`, `onboarding`, `agent_page`, `session_list`, `room_row`, `unknown` | +| `target` | `local`, `remote`, `unknown` | + +### 3.3 Every event and its fields + +**App lifecycle** + +| Event | Fields, with example values | +|---|---| +| `app_launched` | *(no fields)* | +| `renderer_crashed` | *(no fields)* | +| `update_checked` | `trigger`: `user` / `startup` / `scheduled` · `result`: `available` / `up_to_date` / `failed` | +| `update_downloaded` | `outcome`: `success` / `failure` | +| `update_install_started` | `outcome`: `success` / `failure` | +| `telemetry_consent_changed` | `source`: `first_run` / `settings` | +| `setting_changed` | `setting_key`, one of exactly 15: `theme`, `notifications`, `terminal`, `defaultAgent`, `sessions`, `location`, `localLocation`, `openIn`, `interface`, `browser`, `browserPreview`, `changesViewMode`, `remote`, `onboarding`, `telemetry`. **The new value is never sent** — we learn that someone changed their theme, not to what. | +| `search_performed` | `status`: `ok` / `recents` / `query-too-short` / `failed` · `result_count`: `0`, `3`, `17`. **The query is never sent.** | + +**Navigation and onboarding** + +| Event | Fields, with example values | +|---|---| +| `view_opened` | `view_id`, one of exactly 10: `home`, `location`, `session`, `room`, `settings`, `server`, `serverAgents`, `serverRooms`, `remoteHosts`, `remoteHost` | +| `command_executed` | `command_id`, one of 28 known commands: `app.settings`, `app.newSession`, `app.addServer`, `app.toggleTheme`, `session.newTerminal`, `session.gitPush`, … · `invoked_by`: `palette` / `shortcut` | +| `deeplink_opened` | `resolved`: `true` / `false` · `cold_start`: `true` / `false`. **The URL is never sent.** | +| `onboarding_step_started` | `step_id`, one of exactly 4: `addServer`, `agentProviders`, `onboardAgents`, `createRoom` | +| `onboarding_checklist_dismissed` | *(no fields)* | +| `onboarding_completed` | *(no fields)* | +| `add_server_step` | `step`: `choose` / `local` / `remoteHost` / `external` / `signIn` / `linkAccounts` · `choice`: `none` / `local` / `remoteHost` / `external` | + +**Agents and sessions** + +| Event | Fields, with example values | +|---|---| +| `agent_created` | `agent_type`: `codex` · `location`: `remote` · `outcome`: `failure` · `failure_reason`: `none` / `unauthenticated` / `name_conflict` / `credentials_conflict` / `invalid_name` / `not_configured` / `agent_not_on_server` / `error` · `entry_point`: `sidebar` | +| `agent_removed` | `agent_type` · `location` · `delete_in_switch`: `true` · `trigger`: `user` / `server_teardown` · `outcome` · `failure_reason`: `none` / `not_linked_to_switch` / `gateway_unauthorized` / `gateway_http` / `gateway_network` / `error` | +| `agent_reset` | `agent_type` · `outcome` · `failure_reason`: `none` / `agent_not_found` / `not_remote` / `connect` / `error` | +| `agent_cli_action` | `agent_type` · `target`: `local` / `remote` · `install_method`: `homebrew` / `npm` / `winget` / `powershell` / `apt` / `curl` / `pip` / `cargo` / `installer-macos` / `installer-windows` / `installer-linux` / `other` / `unspecified` · `action`: `install` / `update` / `uninstall` · `outcome` · `failure_reason`: `none` / `unknown_dependency` / `no_install_command` / `no_update_strategy` / `no_uninstall_strategy` / `no_uninstall_command` / `permission_denied` / `command_failed` / `pty_open_failed` / `not_detected_after_install` / `not_detected_after_update` / `still_present` / `error` | +| `session_started` | `agent_type`: `claude` · `location`: `local` · `outcome`: `success` · `failure_reason`: `none` / `agent_not_found` / `already_exists` / `spawn_failed` · `entry_point`: `command_palette` · `start_source`: `user` / `auto` / `adopted` / `unknown` · `has_initial_prompt`: `true` (**a boolean — never the prompt**) · `connected_to_room`: `false` | +| `session_ended` | `agent_type` · `location` · `outcome`: `normal` / `failed` | +| `session_attached` | `agent_type` · `outcome` | +| `session_provision_retried` | `agent_type` · `location` · `trigger`: `auto` / `retry_button` · `outcome` | + +Note the shape of `failure_reason` everywhere: a short enumerated code such as +`permission_denied` or `docker_daemon_down`. It is **never** an exception message, +a stack trace, or a command's stderr — those are mapped to `error` if they don't +match a known code. + +**Connector** + +| Event | Fields, with example values | +|---|---| +| `connector_installed` | `agent_type`: `claude` · `target`: `local` · `outcome`: `success` | +| `connector_updated` | `agent_type` · `target`: `remote` · `outcome` · `was_reinstall`: `false` | +| `connector_uninstalled` | `agent_type` · `target`: `local` · `outcome` | + +**Servers and sign-in** + +| Event | Fields, with example values | +|---|---| +| `server_added` | `server_kind`: `remote_managed` · `outcome`: `success` | +| `server_removed` | `server_kind`: `external` | +| `server_sign_in` | `auth_method`: `password` / `oidc` · `server_kind` · `outcome` · `failure_reason`: `none` / `invalid_credentials` / `cancelled` / `failed` / `unreachable` | +| `server_sign_out` | `server_kind`: `local` | +| `managed_server_action` | `action`: `start` / `stop` / `reset` · `target`: `local` / `remote` · `outcome` · `failure_reason`: `none` / `docker_not_installed` / `docker_daemon_down` / `version_downgrade` / `matrix_migration_failed` / `error` · `docker_available`: `available` / `unavailable` / `unknown` | + +No server name, URL, hostname or username appears in any of these. A failed +sign-in records `invalid_credentials` — not the username tried, not the server. + +**Rooms and bridges** + +| Event | Fields, with example values | +|---|---| +| `bridge_connected` | `bridge_platform`: `slack` · `outcome`: `failure` · `failure_reason`: `none` / `unauthenticated` / `forbidden` / `invalid` / `error` | +| `bridge_disconnected` | `bridge_platform`: `mattermost` · `outcome` | +| `bridge_identity_claimed` | `bridge_platform` · `outcome` | +| `room_created` | `server_kind`: `local` · `bridge_platform`: `slack` · `agent_count`: `3` · `has_instructions`: `true` · `outcome` · `failure_reason`: `none` / `unauthenticated` / `bridge_unavailable` / `invalid` / `unreachable` / `error` | +| `room_deleted` | `server_kind` · `outcome` | +| `room_agents_added` | `agent_count`: `2` · `direction`: `agents_to_room` / `room_to_agents` | + +A room creation tells us *"someone made a Slack-bridged room with 3 agents and it +worked"*. It does not tell us the room name, the channel, the workspace, or which +agents. + +**Remote hosts** + +| Event | Fields, with example values | +|---|---| +| `host_setup_step` | `step_kind`: `core-dependency` / `agent-cli` / `agent-plugin` / `unknown` · `agent_type` · `action`: `install` / `update` / `skip` · `outcome` | +| `host_onboarded` | `outcome`: `success` · `picked_from_ssh_config`: `true` (**a boolean — the SSH host is never sent**) | +| `host_removed` | `outcome` | + +### 3.4 What is never sent, at all + +Prompts · code · file paths · working directories · repository names · project +or location names · room names or ids · agent names or ids · server names or +URLs · hostnames · SSH hosts · usernames · emails · account or tenant ids · +Switch user ids · IP or MAC addresses in the payload · error messages · stack +traces · log content · search queries · setting values · deeplink URLs. + +--- + +## 4. How it is collected — why free text cannot leak + +Not a policy; three independent mechanisms in the code, each of which alone +would stop a leak. + +1. **The catalogue is closed at the type level.** Every event property is + declared as a boolean, a number, or one of a fixed list of literal values — + the lists reproduced in full above. A free-text property cannot be declared. + A compile-time assertion fails the build if an event declares a property that + is not on the runtime allowlist. +2. **A send-time allowlist rebuilds the payload.** At transmission, only the + properties named for that specific event are copied across; anything else + present on the object is dropped. A value that is not a string, finite number + or boolean causes the whole event to be discarded rather than sent. This + closes the "someone spread an extra object in" hole. +3. **External values are narrowed before they are ever attached.** Anything + originating outside the app — a server response, a CLI error, a UI string — is + mapped onto a known enum first. Unrecognised input becomes `unknown`, `other` + or `error`; the original string is never carried through. Values arriving from + the UI process are additionally validated against schemas at that boundary, + and a failing value is dropped and logged *without* the value. + +**The design rule behind it:** where a value would reveal content, we send a +derived flag instead. `has_initial_prompt` not the prompt; `has_instructions` not +the instructions; `agent_count` not the agents; `result_count` not the query; +`setting_key` not the value; `resolved`/`cold_start` not the link; +`picked_from_ssh_config` not the host. + +**Consent.** Opt-in: telemetry is off by default. Nothing leaves the machine +until the user explicitly enables it at first run or in Settings, and the setting +is re-read before every single event, so turning it off stops transmission +immediately with no further requests and no queued backlog. Dev builds never +transmit regardless of the setting. Opting out is itself not reported. + +--- + +## 5. The identifier + +Exactly one identifier is attached: `flint.client_id`, e.g. +`3f2a9c41-8d7e-4b16-9a55-c0e1d2f47b83` — a random UUID generated on the machine +the first time telemetry runs, stored in the app's local database. + +- **Generated randomly.** Not derived from hardware, MAC address, disk serial, OS + account, network, email, licence, or any Switch identity. +- **No Switch identity travels with it** — no user id, agent id, room id, tenant + id, server id. +- Deleting the app's data directory produces a **new, uncorrelated** UUID; there + is no mechanism to relink the old one. +- Downstream it is used as the analytics `device_id` — it groups one + installation's events together, and nothing more. + +**There is no join key.** To resolve a UUID to a person you would need a second +dataset holding that UUID next to an identity. No such dataset exists: the UUID +lives only on the user's own machine and in the analytics store, and is never +sent to, or recorded by, any account, licensing, billing or support system. It is +an anonymous installation counter, not an identity. + +--- + +## 6. Where it is sent + +1. **App → our relay.** One plain HTTPS POST per event (OTLP logs format; no + batching, no retries, 10-second timeout) to `telemetry.flintai.dev`, an + endpoint we operate. No third-party analytics SDK runs inside the app, and no + vendor credential is shipped in the app. Released builds cannot be pointed at + a different endpoint — the override exists only in dev builds. +2. **Relay → destinations.** The relay forwards to **Amplitude** and **Datadog**, + holding the vendor keys server-side. +3. **Storage and analysis** happen in those two products. + +Putting a relay in the middle is deliberate: the vendors never receive a +connection from a user's machine, so nothing vendor-side observes the user's +network address, and vendor keys stay off end-user devices. + +--- + +## 7. The client IP, and the relay's obligations + +An HTTPS request necessarily reveals the client's IP address to the server +terminating it. The IP is not in the payload — it is a property of the connection +— and it is the only value anywhere in this pipeline that could re-identify a +user. The relay is therefore the single control point, and it is held to the +following requirements. + +**R1 — The client IP is never persisted.** Access logging at the relay is +configured not to record the remote address; the IP exists only in memory for the +duration of the request. **[TO CONFIRM against the relay's configuration]** + +**R2 — The client IP is never forwarded.** The relay originates its own +connections to Amplitude and Datadog and does not set `X-Forwarded-For` or any +equivalent header. Amplitude's IP-based geolocation enrichment is explicitly +disabled, so no country/region/city is derived from the request and attached to +the event. **[TO CONFIRM]** + +**R3 — The client IP never reaches the cloud audit and security tooling.** +Request-level IP data is excluded from what is streamed to CloudTrail and Orca, +so there is no secondary copy of the address in the security estate. **[TO +CONFIRM]** + +**R4 — Abuse protection without retaining addresses.** The endpoint is +unauthenticated by design (shipping a credential in a desktop app protects +nothing), so it needs rate limiting — but naive rate limiting works by keeping a +table of IPs, which would undo R1. The approach is a probabilistic membership +structure: a Bloom filter / counting filter keyed on a **salted hash of the +client IP, with the salt rotated on a short window**, so the relay can throttle a +flooding source without ever storing, logging or being able to recover an address, +and the structure itself is unusable as a lookup table. Rotation bounds how long +even the hash is meaningful. **[TO CONFIRM — design agreed, implementation to be +verified]** + +**R5 — Injection is bounded, and doesn't matter much.** An unauthenticated +endpoint can be sent junk events. Because nothing downstream is used for billing +or security decisions, the worst case is polluted product analytics; R4's rate +limiting caps the volume. Payloads that don't match the expected schema, or that +carry no client id, are rejected at the relay. + +This section is the part of the pipeline **outside the Console codebase**. R1–R4 +are stated as commitments and need confirming against the relay's actual +configuration before this document is treated as verified end to end. + +--- + +## 8. Why this cannot be traced to a person + +- **No direct identifier is transmitted.** §3.4 is exhaustive, and it is enforced + by the three mechanisms in §4 — not by convention or code review. +- **No indirect identifier is transmitted.** The values most often used to + re-identify — file paths, project and repository names, hostnames, usernames, + workspace and channel names, error text — are precisely the ones replaced by + enums, counts and booleans. §3 lists every permitted value; none of them is + user-supplied. +- **No join key exists.** The only stable value is a locally generated random + UUID present in no other system (§5). +- **Field entropy is very low.** Every field is drawn from a short fixed + vocabulary — 2 to 30 possible values — so any event is one of a small number of + shapes. Fingerprinting by field combination fails because the combinations are + not distinctive. +- **The IP is controlled at a single point** and is neither logged, forwarded, + nor used for enrichment (§7). +- **The user opts in**, and one toggle stops it — with effect on the very next + event, since consent is checked per event rather than cached. diff --git a/scripts/codex-hook-probe/mcp_probe.py b/scripts/codex-hook-probe/mcp_probe.py new file mode 100644 index 000000000..d1d5b08c4 --- /dev/null +++ b/scripts/codex-hook-probe/mcp_probe.py @@ -0,0 +1,32 @@ +"""A stand-in for the Switch MCP server, faithful in the one way that matters. + +`connect_to_room` is declared exactly as the real tool is — an async FastMCP +tool returning `dict[str, Any]` — so FastMCP serialises the result the same way +here as in `switch_core.bridges.agent.mcp.server`. Running the probe shows what +Codex puts in the PostToolUse hook's `tool_response` for that call. +""" + +from typing import Any + +from fastmcp import FastMCP + +mcp: FastMCP = FastMCP("switch") + + +@mcp.tool +async def connect_to_room(room_id: str) -> dict[str, Any]: + """Connect this session to a room. + + Args: + room_id: The Switch room id to connect to. + """ + return { + "room_id": room_id, + "agent_id": "probe-agent-1", + "name": "Probe Room", + "participants": [], + } + + +if __name__ == "__main__": + mcp.run() diff --git a/scripts/codex-hook-probe/run.sh b/scripts/codex-hook-probe/run.sh new file mode 100755 index 000000000..4f2cbdc2e --- /dev/null +++ b/scripts/codex-hook-probe/run.sh @@ -0,0 +1,210 @@ +#!/usr/bin/env bash +# +# Measure two things about Codex's hook contract that cannot be settled by +# reading the binary: +# +# 1. Does Codex deliver a hook's event payload on stdin, with no positional +# operands? switchdash's hook commands read stdin and rely on `$#` being 0, +# so a positional payload would leave them posting an empty body. +# +# 2. What shape does `tool_response` take for an MCP tool call? Claude Code +# unwraps the MCP result; Codex forwards the `CallToolResult` envelope, so +# the payload sits under `structuredContent` / `content[0].text`. The hook +# enricher handles either shape. +# +# Runs against an isolated CODEX_HOME so your real ~/.codex is untouched. It +# does spend one Codex turn on your account. Nothing is written outside the +# probe directory. +# +# Usage: scripts/codex-hook-probe/run.sh [--keep] + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +PROBE_DIR="$(mktemp -d "${TMPDIR:-/tmp}/codex-hook-probe.XXXXXX")" +CODEX_HOME="$PROBE_DIR/home" +DUMPS="$PROBE_DIR/dumps" +KEEP="${1:-}" + +# Anything short of a clean run keeps the directory: a probe you cannot inspect +# after it fails is worse than no probe. +KEEP_ON_EXIT=1 +cleanup() { + # The auth copy goes regardless of why we are exiting — a kept probe + # directory is for reading hook dumps, not for leaving credentials in /tmp. + rm -f "$CODEX_HOME/auth.json" + if [ "$KEEP" = "--keep" ] || [ "$KEEP_ON_EXIT" = "1" ]; then + echo + echo "Probe directory: $PROBE_DIR (auth copy removed)" + echo " codex output: $PROBE_DIR/codex.log" + echo " hook dumps: $DUMPS" + echo " remove with: rm -rf $PROBE_DIR" + else + rm -rf "$PROBE_DIR" + fi +} +trap cleanup EXIT INT TERM + +mkdir -p "$CODEX_HOME" "$DUMPS" + +# Codex resolves credentials from CODEX_HOME, so the isolated home needs a copy. +# mktemp -d gives 0700; the copy is narrowed to 0600 and removed on every exit +# path by the trap above, including when the probe directory itself is kept. +if [ ! -r "$HOME/.codex/auth.json" ]; then + echo "error: no ~/.codex/auth.json — run 'codex login' first." >&2 + exit 1 +fi +( umask 077 && cp "$HOME/.codex/auth.json" "$CODEX_HOME/auth.json" ) +chmod 600 "$CODEX_HOME/auth.json" + +# The hook commands record what Codex actually handed them: the operand count, +# the first operand, and stdin. Deliberately NOT the commands switchdash +# generates — this measures the delivery mechanism, not our shell. +# +# Built with json.dumps rather than a heredoc: the commands contain quotes, and +# Codex responds to malformed hooks.json with a warning buried in the transcript +# and then runs no hooks at all, which reads exactly like "hooks don't fire". +python3 - "$CODEX_HOME/hooks.json" "$DUMPS" <<'PY' +import json, sys + +out_path, dumps = sys.argv[1], sys.argv[2] + + +def probe(event: str) -> str: + meta, stdin = f"{dumps}/{event}.meta", f"{dumps}/{event}.stdin" + return ( + f'printf "argc=%s\\narg1=%s\\n" "$#" "${{1:-}}" > {meta}; ' + f"cat > {stdin}" + ) + + +config = { + "hooks": { + "SessionStart": [{"hooks": [{"type": "command", "command": probe("session-start")}]}], + "PostToolUse": [ + { + "matcher": "mcp__.*__connect_to_room", + "hooks": [{"type": "command", "command": probe("post-tool-use")}], + } + ], + } +} + +with open(out_path, "w") as fh: + json.dump(config, fh, indent=2) +PY + +python3 -c "import json,sys; json.load(open(sys.argv[1]))" "$CODEX_HOME/hooks.json" \ + || { echo "error: generated hooks.json is not valid JSON" >&2; exit 1; } + +cat > "$CODEX_HOME/config.toml" <"$PROBE_DIR/codex.log" 2>&1 || { + echo "codex exec failed; tail of its output:" >&2 + tail -30 "$PROBE_DIR/codex.log" >&2 + exit 1 + } + +if grep -q 'failed to parse hooks config' "$PROBE_DIR/codex.log"; then + echo "error: Codex rejected the hooks config, so no hook ran:" >&2 + grep 'failed to parse hooks config' "$PROBE_DIR/codex.log" | sed 's/^/ /' >&2 + exit 1 +fi + +if ! grep -qi 'connect_to_room' "$PROBE_DIR/codex.log"; then + echo "warning: the transcript never mentions connect_to_room — the model may" >&2 + echo " not have called the tool, so PostToolUse would not fire." >&2 +fi + +echo "Codex version under test: $(codex --version 2>/dev/null || echo unknown)" + +echo "──────────────────────────────────────────────────────────────────────" +echo "Q1 Payload delivery — expect argc=0 and non-empty stdin" +echo "──────────────────────────────────────────────────────────────────────" +fired=0 +for event in session-start post-tool-use; do + echo + echo "[$event]" + if [ -r "$DUMPS/$event.meta" ]; then + fired=1 + sed 's/^/ /' "$DUMPS/$event.meta" + bytes=$(wc -c <"$DUMPS/$event.stdin" | tr -d ' ') + echo " stdin_bytes=$bytes" + else + echo " HOOK DID NOT FIRE" + fi +done + +if [ "$fired" = "0" ]; then + echo + echo "Neither hook fired. Last 25 lines of the Codex transcript:" + tail -25 "$PROBE_DIR/codex.log" | sed 's/^/ /' +fi + +if [ -s "$DUMPS/session-start.stdin" ]; then + echo + echo " session-start body keys:" + python3 -c " +import json, sys +body = json.load(open(sys.argv[1])) +print(' ', sorted(body)) +for key in ('session_id', 'resource_id', 'resourceId', 'sessionId'): + if key in body: + print(f' parseCodexHookEvent reads {key!r} -> {body[key]!r}') +" "$DUMPS/session-start.stdin" || true +fi + +echo +echo "──────────────────────────────────────────────────────────────────────" +echo "Q2 tool_response shape — payload direct, or CallToolResult envelope?" +echo "──────────────────────────────────────────────────────────────────────" +if [ -s "$DUMPS/post-tool-use.stdin" ]; then + python3 - "$DUMPS/post-tool-use.stdin" <<'PY' +import json, sys + +body = json.load(open(sys.argv[1])) +tr = body.get("tool_response") +print(f" tool_name : {body.get('tool_name')}") +print(f" tool_response : {type(tr).__name__}") +if isinstance(tr, dict): + print(f" top-level keys : {sorted(tr)}") + if "room_id" in tr: + print(" VERDICT : UNWRAPPED — payload is at the top level (Claude-like)") + elif "structuredContent" in tr or "content" in tr: + print(" VERDICT : ENVELOPE — CallToolResult forwarded intact") + else: + print(" VERDICT : UNKNOWN — neither payload nor envelope") +else: + print(f" VERDICT : non-dict ({tr!r:.120})") +print() +print(" raw tool_response:") +print(json.dumps(tr, indent=2)[:1500]) +PY +else + echo " no PostToolUse payload captured — see $PROBE_DIR/codex.log" +fi + +# Only a run that answered both questions is clean enough to discard. +if [ -s "$DUMPS/session-start.stdin" ] && [ -s "$DUMPS/post-tool-use.stdin" ]; then + KEEP_ON_EXIT=0 +fi