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}
+ 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