Skip to content

docs(telemetry): document what Switch Console collects and why it is anonymous - #489

Closed
amaudruz wants to merge 51 commits into
mainfrom
docs/telemetry-transparency
Closed

amaudruz wants to merge 51 commits into
mainfrom
docs/telemetry-transparency

Conversation

@amaudruz

Copy link
Copy Markdown
Collaborator

Written for the Switch × InfoSec telemetry review, so the claim "our telemetry cannot be traced to a person" is evidenced somewhere durable rather than re-argued in chat.

What's here

docs/TELEMETRY.md — the evidence document:

  • A complete real JSON payload for one event, so there's no ambiguity about what goes over the wire.
  • Every event and every field, with example values taken verbatim from the source — including the full failure_reason enum per event, all 15 setting_key values, all 10 view_id values, install methods and providers.
  • The three independent code-level mechanisms that make free text impossible to transmit (closed literal types + compile-time assertion, send-time allowlist, narrowing of all external values).
  • The install identifier, and why no join key to an identity exists.
  • The send path: app → our relay → Amplitude/Datadog.
  • Client-IP requirements on the relay (not logged, not forwarded, Amplitude geo-enrichment off, not streamed to CloudTrail/Orca, rate limiting via a Bloom filter over a salted and rotated IP hash).
  • The closing argument on non-traceability.

README.md — a short Telemetry section: data/example/purpose table, an explicit "what we never collect" list, where it goes, how to opt out, and a link to the full document.

Please check

  • The relay requirements in §7 are marked [TO CONFIRM] — they describe the target state, verified against the Console source only. Someone with access to the relay's configuration needs to confirm them before the markers come off.
  • Both documents describe telemetry as opt-in, matching the code today (default off, explicit consent, setting re-read per event). If the default changes, both need updating together.

🤖 Generated with Claude Code

christian-mcdermott and others added 30 commits July 30, 2026 14:53
…ts (CHOO-1436)

Codex's sandbox and approval behavior was documented in AGENTS.md as
configurable via CODEX_SANDBOX_MODE / CODEX_APPROVAL_POLICY, but both were
unimplemented: the launch command hardcoded sandbox_mode=danger-full-access
and approval_policy=never.

- Add codex/auto-approve.ts: buildCodexAutoApproveFlag(env) builds the -c
  flags from the two env vars, validating values and throwing on an unknown
  one (a typo must never silently re-widen the sandbox), defaulting to
  today's danger-full-access / never for headless auto-sessions.
- Wire it into the codex plugin buildCommand.
- Split resumeWithoutSessionFlag like every other flag in buildStandardCommand
  so codex's multi-token 'resume --last' fallback becomes ['resume','--last']
  instead of a single broken argv element (no-op for single-token providers).
- Add Codex test coverage (auto-approve, buildCommand, hooks parser +
  install/read/delete + legacy notify migration) — previously zero tests.
- Align the legacy registry's codex description with the authoritative plugin
  so the info card and agent list no longer diverge.
- Document valid values/effects for the two env vars in AGENTS.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… (CHOO-1436)

suggestAgentDefaults hardcoded the 'claude-code.' name prefix and a 'Claude
Code running in ...' description, so a new Codex agent was still suggested
as claude-code.<repo>.<user>. Thread the selected providerId through the
suggestAgentDefaults RPC + the add-agent form and derive the prefix from the
provider's display name via the existing slugifier: slugify('Codex')='codex',
slugify('Claude Code')='claude-code' — Codex now defaults to codex.<repo>.<user>
and Claude is unchanged. Description uses the provider name too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…gents (CHOO-1436)

addAgent only wrote per-agent Switch credentials inside the repoAgents
branch (behavior.writeCredentials). Providers with repoAgents: none — e.g.
Codex — skipped that block entirely, so the minted token never landed on
disk. The launched session then had no SWITCH_* to inject, the MCP-based
Switch setup no-op'd (no [mcp_servers.switch] in ~/.codex/config.toml),
and the auto-session watcher reported missing credentials.

Write the provider-neutral .switch/agents/<agentId>.json directly from the
freshly-minted token for providers without a writeCredentials hook, via a
new PluginFs-based writeNeutralAgentSettingsFs (works local + remote),
keyed by the agent id the launch path reads.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A Codex agent's no-live-session reply suggested a `claude …` command because
switchdash registered every agent as the `claude-code` known-agent type and the
gateway had no Codex known-agent, so it fell back to Claude's command builder.

- core: add CodexKnownAgent (connector_type "Codex") with a codex-flavored
  start_session_instructions that emits `cd <dir> && codex "connect to switch
  room …"` (never `claude`, no Claude-only flags), auto_session→auto_session
  else session_addressable, and no tool-call mediation/reporting (Codex runs
  auto-approved). Registered under KNOWN_AGENTS["codex"].
- dash: thread the provider through registration — knownAgentTypeForProvider
  maps codex→'codex' (else 'claude-code'); registerKnownAgent sends it as
  agent_type; add-agent passes it. Other providers unchanged.

Existing Codex agents registered as claude-code keep that type until re-onboarded.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
PR-1 subset of the original self-review pass: gateway known-agent
wording, Codex auto-approve validation timing, hook/command test
coverage, and the agent-name prefix helper.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…HOO-1436)

`.switch/agents/<name>.json` is switchdash-owned state, but the only
create-time writer was `behavior.writeCredentials` — a hook bundled into
Claude's repoAgents capability. A provider without repoAgents (Codex)
fell through and got no credentials on disk at all, leaving the session
with no SWITCH_* to inject.

Make the write unconditional core behavior for every provider, keyed by
the agent's name — the single key-space every reader already uses (launch
path, auto-session watcher, notification poller). Providers with repo-agent
definitions layer their definition on top; that is the only provider-specific
extra. `mergeSwitchSettings` already emits the same connector permission
rules as Claude's hook, so the collapse is behaviour-preserving.

Also collapses the equivalent branch in the storage migration, and covers
the Codex (no-repoAgents) migration path so a pre-rework id-keyed agent
does not silently lose its unrecoverable token.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`registerKnownAgent` defaulted `agent_type` to 'claude-code', so a call
site that forgot to pass one silently registered the agent as Claude Code
whatever it actually ran. Make the type required at both layers and state
it explicitly at every call site; the provision paths pass Claude Code
because they write `.claude/settings.local.json` by construction, not by
omission.

Only `claude-code` and `codex` exist in the gateway's KNOWN_AGENTS, so
`knownAgentTypeForProvider` now maps those two explicitly and warns for
any other provider before falling back — a disclosed degradation instead
of a silent one. Erroring would strand the ~29 other providers that
register today, so the fallback stays, but it is no longer invisible.

Also renames the Codex connector label to "Codex CLI", matching the
product-name convention "Claude Code" already follows.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`agentSettingsRelativePath` and friends built their paths with `path.join`,
which emits backslashes when switchdash runs on Windows. These are relative
paths handed to a `PluginFs` that is either the local disk or a remote POSIX
host over SFTP, and PR #91 newly routes the per-agent credentials path and
its gitignore through the remote fs at agent-create and migration time.

Three call sites already worked around this with their own forward-slash
literals; with the constants fixed they can use the shared ones instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…HOO-1436)

The migration probes several candidate paths per agent and passed `?? ''`
for a missing one, so `JSON.parse('')` threw and logged "failed to parse
Claude settings file for credentials". PR #91 dropped the `if (!behavior)
return` early-out, so this now fires twice per agent for every provider.

An absent file is the ordinary "this agent isn't provisioned here" answer,
not a malformed one. `parseSwitchAgentCredentials` now takes `string | null`
and returns null for null without a warning; a file it genuinely cannot
parse still warns.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…OO-1436)

`SshAgentRuntime` gated `identityVars` on the Claude-only `repoAgents`
behavior and fell through to `{}`. `LocalAgentRuntime` was fixed to read the
provider-neutral `.switch/agents/<slug>.json` instead; its remote twin was
not — so a remote agent of a provider without repo-agent definitions launched
with no SWITCH_* env at all, even though `addAgent` now writes that very file
onto the VM over SFTP.

Both runtimes now share one reader (`readAgentSwitchEnvFromFs`) over their
respective `PluginFs`, and one slug rule (`agentCredsSlug`: the agent row
first, the session's denormalised name next, the local id last) that
`session-builder` and the sidecar paths already used.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Before PR #91, `migrateOne` returned "complete" for any provider without a
`repoAgents` behavior without looking at it, so a full pass latched the
marker. #91 broadens step 1 to every provider — but every already-latched
install short-circuits at the top of `migrateAgentStorage` and never runs it.

The marker now carries a generation, so broadening the migration re-runs it
exactly once per install instead of being silently inert. Bump `MARKER_VALUE`
whenever the migration learns to fix something it previously skipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…436)

Three paths wrote `.switch/agents/<name>.json` three ways: `addAgent` via
`writeNeutralAgentSettingsFs`, `onboardAgent` via `writeAgentNeutralSettings`,
and `onboardLocationAgents` via the Claude-only `repoAgents.writeCredentials`
hook. They produced byte-identical output, which is how a divergence went
unnoticed: the capability hook wrote the `.gitignore` before the token file,
both neutral writers wrote it after — leaving a window where an un-ignored
SWITCH_API_TOKEN sits in a git worktree. #91 made the inverted one the
unconditional create-time path for every provider.

Now: `writeNeutralAgentSettingsFs` is the single writer and writes the
gitignore first, `writeAgentNeutralSettings` is a thin wrapper over it for
callers holding a plain directory path, and the redundant `writeCredentials`
capability member (plus `RepoAgentCredentials`) is gone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…py (CHOO-1436)

Three things the Codex plugin did differently from Claude's, all of them the
worse way round:

- `writeHooks`/`deleteHooks` used the lenient `readJsonConfig`, so a
  `.codex/hooks.json` that failed to parse was silently rewritten from
  scratch, discarding every hook the user had configured. Rebuilding on
  `buildNestedJsonHookConfig` — the helper Claude already uses — picks up the
  strict `readJsonConfigForUpdate` and removes the hand-rolled
  `readHooks`/`getHooksInstalled` duplication with it. The legacy config.toml
  notify migration no longer swallows its own write failures either.
- `buildCodexAutoApproveFlag(process.env)` was evaluated while constructing
  the command spec, before `buildStandardCommand`'s `ctx.autoApprove` guard,
  so an unrecognised CODEX_SANDBOX_MODE broke every Codex session start
  rather than just the ones that use the flag.
- `AGENT_PROVIDERS` still carried a literal copy of the auto-approve string
  that #91 made configurable. It is metadata only, so it could only go stale.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…-1436)

`CodexKnownAgent.build_profile` omitted `command_capabilities`, so it
defaulted to all-"unsupported" and `!reset` / `!compact` / `!interrupt`
replied that Codex does not support them. That was honest — switchdash's
`BY_PROVIDER` had only a `claude` entry — but Codex is a TUI switchdash
drives the same way, so the gap was avoidable.

Both halves land together on purpose: declaring `session_dependent`
server-side without a switchdash recipe would reach the session_dependent
branch, find no control capability, and emit a more confusing "can't be
controlled from here" than plain "unsupported".

Recipes verified against Codex 0.145.0: ESC interrupts the turn (Ctrl+C would
exit the CLI), `/clear` starts a fresh chat, `/compact` summarises. Reset
sends ESC first because Codex refuses `/clear` while a turn is running.

Also drops the inert `channels_enabled` from `CodexOptions`: the gateway
renders its options form from this schema, so a declared field is an
interactive control that changes nothing. Pydantic still ignores the key
switchdash sends for every provider.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`agentType` is now required at both layers, but nothing asserted
`registerKnownAgent` actually forwards it into the POST body — so a default
could quietly come back and mislabel every non-Claude agent again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…n (CHOO-1436)

`reset` prepended ESC because Codex refuses `/clear` while a turn is running,
but `compact` was left without one — and Codex gates both behind the same
`available_during_task()` arm. Confirmed against the installed 0.145.0, whose
binary carries "'{}' is disabled while a task is in progress.".

So a mid-turn `!compact` was dropped with an error cell while switchdash ran
the follow-up step regardless (it only bails on a thrown write error), and the
agent announced a compaction that never happened.

The shared `it.each(['claude','codex'])` compact case pinned the ungated shape,
so it is split: Claude queues a slash command typed mid-turn and needs no
interrupt, Codex does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Collapsing the credential writers removed `writeCredentials` from
`IRepoAgentsBehavior`, but this file kept spying on it and asserting
`not.toHaveBeenCalled()` in four places. All four were vacuous, and in "does
nothing when the name-keyed file already exists" that dead spy was the only
guard on the credential step — the real write goes through the unmocked
`writeNeutralAgentSettingsFs`, which a behavior spy cannot see.

Verified by mutation: forcing `neutral` to null (so every boot re-derives and
rewrites the token file) left the old test green and fails the new one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…O-1436)

`resolveAgentCredsSlug` awaited `getAgentById` to prefer the agent row over
`session.agentName`, on the stated grounds that the row is the source of truth.
It isn't a different value: `mapSessionRowToSession` takes `agentName` as a
required argument and all seven callers pass the joined `agents.name`, so the
query could only ever return what the session already carried — an extra SELECT
on every session start, twice per `startInternal` on the SSH path.

The justification also contradicted the `sessions.ts` comment added in the same
change ("read live from the agent row on every load, so it follows a rename").

Both runtime test fixtures now carry an `agentName`, which every real Session
has, so they exercise the slug the way production resolves it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ix (CHOO-1436)

Bumping the marker re-armed the whole pass for every install, but generation 2
only broadened the credential step to providers WITHOUT a `repoAgents`
behavior. `migrateOne` opened the workspace filesystem before looking at the
provider, so a user with remote Claude agents paid an SSH connect and an SFTP
channel per agent — every one a no-op — and the pass gates session restore and
the auto-session watchers. One unreachable host (20s connect timeout) leaves
`allComplete` false, so it repeats on every boot until that host returns.

The marker now records the generation rather than a boolean, and a re-run skips
agents whose provider the previous generation already handled — checked before
the workspace is opened, off an in-memory registry lookup. On an all-Claude
install the re-run now touches no filesystem at all.

`MigrateResult.complete` went with it: both return sites hardcoded `true`, so
only the catch ever cleared `allComplete`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… (CHOO-1436)

Codex has a plugin marketplace CLI that mirrors Claude Code's, so it joins
Switch the same way every other agent does — a connector plugin — instead
of the bespoke `switchSetup: kind: 'mcp'` variant, which was permanent
public capability surface that two readiness gates had already missed.

Adds `connectors/codex-plugin/` (manifest + the Switch room-workflow skill)
and registers it in the existing marketplace, which Codex reads: it accepts
Claude's `.claude-plugin/marketplace.json`, including the string `source`
form, so one marketplace file serves both CLIs.

The two CLIs agree on the model but not the surface, so the `cli` descriptor
gains a `dialect`. 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`, `pluginId`/`source.path` rather
than `id`/`installPath`, and both listings wrapped in an object rather than
returned as arrays. Driving Codex with Claude's assumptions reported it as
permanently not-installed with no error, so both the local and remote
drivers now read the dialect table. Codex has no update verb, so that path
falls back to remove-then-add and says so when the reinstall fails, since
that leaves no connector rather than the previous version.

Per-session MCP registration moves onto argv. Codex does not expand `${VAR}`
in a plugin-bundled `.mcp.json` — the placeholders reach the server intact —
and writing a resolved endpoint into its single global config would make two
agents in one location overwrite each other. The `mcp` behavior gains an
optional `launchArgsForServer` so the provider owns the argv shape; only the
endpoint is passed, with the token named rather than embedded so it never
reaches a process listing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The notification poller was pinned to whichever room the session was
spawned for. Codex can change rooms mid-session — connect_to_room is in
its toolset and list_linked_rooms invites it — after which switchdash kept
delivering the old room's messages to an agent sitting somewhere else.
Nothing surfaced: no error, no warning, just messages that never arrived.

Register a PostToolUse hook matched to the Switch connect tool, reporting
the same `switch_room_connect` event Claude's connector emits, so both
providers converge on the existing enricher path — which repoints the
session and reconnects the poller on every connect, not just the first.

Matching on `mcp__.*__connect_to_room` rather than pinning the server name
keeps this working for a session that reaches Switch through a
differently-named MCP server.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…OO-1436)

Codex's hook commands wrapped the shared post command in
`printf '%s' "$INPUT" | ...`, but that command starts with a `;`-separated
preamble resolving the hook port. The pipe binds to the first command of the
list, so `_sd_p="$SWITCHDASH_HOOK_PORT"` ran in a pipeline subshell and was
lost: every local Codex session posted to `http://127.0.0.1:/hook` with an
empty body, and `curl ... || true` swallowed it. Only sidecar-backed sessions
worked, because they set an endpoint file whose `if` block runs in the parent.

That silently disabled session-id capture on SessionStart, and would have
disabled the new room-tracking PostToolUse hook the moment it shipped.

Codex runs hooks as `$SHELL -lc "<command>"` with no operands, so `$1` is
unset by construction and the payload arrives on stdin — the `${1:-$(cat)}`
fallback the wrapper existed for was inherited from the legacy `config.toml`
`notify` array, a different mechanism this file already migrates away. Drop
the wrapper and use `makeStdinHookCommand`, the same helper Claude's hook
config uses.

The existing test asserted the command *string* contained
`SWITCHDASH_HOOK_PORT`, which the broken command did. Replace it with a
harness that executes each installed command under a real `sh` with `curl`
stubbed, feeding a payload on stdin, and asserts the resolved URL and the
request body — for every managed event, with and without an endpoint file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`switchMcpLaunchArgs` was wired into the local runtime only, so a provider
that receives its MCP server on argv got nothing on either remote path:

- The SSH runtime never called it. A remote Codex session had its
  `SWITCH_API_TOKEN` (the neutral-creds read landed earlier) but no `switch`
  server to spend it on.
- Auto-started sessions were worse: the on-VM sidecar injects the same
  credentials, so the session came up looking configured and had no Switch
  tools at all.

The SSH runtime now resolves the agent's identity before building the command
and passes the endpoint through, mirroring the local runtime.

Auto-started sessions cannot resolve the endpoint at spec-generation time —
`buildLauncher` regenerates the spec on every status read and the sidecar host
exposes no read — so they reuse the placeholder protocol the spec already has
for the session id and prompt. Unlike those two the endpoint is embedded inside
a larger argument (`mcp_servers.switch.url="<endpoint>/mcp/"`), so it is
substituted as a substring, and it is optional rather than required since only
argv-registering providers emit one. `materializeAgentCommand` now refuses to
launch when any `__SWITCHDASH_` token survives substitution, so a future
placeholder cannot quietly start an agent pointed at literal placeholder text.

The endpoint constants and the trailing-slash normalisation move to `shared`,
which is how the sidecar — bundled free of Electron — shares wiring with the
main process, and keeps the two ends from disagreeing about the slash.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… (CHOO-1436)

`launchArgsForServer`'s comment claimed every key is emitted on every call,
but the implementation skipped each key it did not find and emitted `command`
while dropping `args` and `env`. Since a `-c mcp_servers.<name>.<key>`
override replaces the whole table rather than merging into it, that would have
registered a stdio server with no arguments and no environment — one that
launches and then misbehaves. Narrow it to the HTTP servers it can actually
express and throw on anything else, rather than emitting a partial table.

`bearer_token_env_var` was reaching the adapter through
`McpServerRegistration`'s index signature, typed `unknown`, so a typo in the
key compiled fine and silently produced an unauthenticated server. Declare it.

Codex's `supportedEvents` did not list a tool event even though switchdash now
installs a `PostToolUse` hook for it. Add `tool-done`; `start` stays off, since
the hook service reads it to decide whether to synthesise a start event and
Codex registers no `UserPromptSubmit` hook.

The onboarding error told every provider to run the switch-connector
`configure` skill, which only the Claude Code connector ships.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…(CHOO-1436)

`parseToolResponse` assumed the hook's `tool_response` was the tool's own
payload with `room_id` at the top level. That holds for Claude Code, which
unwraps the MCP result first. Codex now emits the same event and is
envelope-aware — its hook output schema carries `updatedMCPToolOutput` and its
binary knows `CallToolResult` — so it may pass the envelope through, and its
schema types `tool_response` as any value, so the shape cannot be settled
statically.

Probe the payload out of whichever shape arrives: the value itself, a JSON
string, `structuredContent` (where FastMCP puts a dict return),
`structuredContent.result` (where it puts a non-dict), or the first text
content block. The candidate must carry a string `room_id`, which is what
distinguishes the payload from an envelope wrapping it — both are plain
objects, so a first-object-wins probe would stop at the envelope.

A shape we cannot read now warns instead of returning `{ kind: 'ignore' }` in
silence. That silence was the failure mode this event exists to fix: the poller
stays pinned to the spawn-time room and nothing is raised. The warning carries
the provider, pty, and the response's type and top-level keys — enough to
identify a shape change without logging room content.

The logger is injected rather than imported: this module is bundled into the
remote sidecar, which must stay free of the Electron-bound main logger. Same
shape as the existing `CredentialsLogger` seam.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…x dialect (CHOO-1436)

`advertisedVersion` fell back to a second `plugin list --json` exec whenever
the marketplace listing yielded nothing. It could never return anything: the
codex dialect's `parseAdvertisedVersions` is hardcoded empty, and the
claude-code one looks for the marketplace name among entries whose `name` is a
plugin name. So it cost one SSH round-trip per status read, always for null.
The parser's contract now says it takes marketplace-list output only, and the
codex dialect test feeds it marketplace-list output to match.

`isNewerVersion` was duplicated byte-for-byte across the two drivers; it now
crosses the same seam `marketplaceMatchesSource` already does.

The `NONE_AGENT` fixture was `id: 'codex'` with `switchSetup: kind 'none'`,
asserting that Switch setup is unsupported for codex — the opposite of what
this branch makes true. Renamed to a neutral id.

Nothing exercised `dialect: 'codex'` through either driver, only the dialect
table in isolation. Both drivers now cover it, including `update()`'s
remove-then-add fallback and — the branch that can leave a host with no
connector at all — the case where the remove succeeds and the re-add fails.
The remote driver had no test file; the new one also pins the exec count, so
reinstating the deleted probe fails a test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…1436)

The Codex skill was adapted from the Claude one, and in adapting it two
defects were quietly fixed on the Codex side only — so the drift runs
backwards and the Claude skill is now the stale one:

- Its frontmatter description, the string that decides whether the skill
  loads, omitted `list_roles`, `get_role_detail`, `assume_role` and
  `release_role`, all four of which the body documents.
- It advertised a `role` filter on `list_agents`. The MCP tool takes
  `name_contains`, `owner_name` and `known_agent_type` — there is no such
  filter.

Both are backported, leaving the two tool lists identical apart from Claude's
tool-name prefix, and the plugin version is bumped so installs pick it up.

The convention section in CLAUDE.md is why this happened: it named one
connector and told a future agent to update one skill and bump one version.
It now covers both, says what each ships and why the Codex one has no MCP
config, and asks for the two skills to be diffed after any edit so intentional
divergence stays visible.

The agent-type picker's comment claimed the single-option case needs no extra
click. With a second onboardable connector that is no longer the common case;
the behaviour is deliberately unchanged, since auto-selecting one of several
would be worse than asking.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…call (CHOO-1436)

Both connector skills tell agents to `cancel_task(task_id, reason)` — in the
frontmatter trigger list and four times in the body each — and the in-room
instructions in `protocol/instructions.py` repeat it. It was never registered
as an `@mcp.tool`. It existed only on the HTTP surface, so an agent following
the documentation called a tool that was not there, and the delegator's abort
path was the one stage of the task lifecycle it could not drive.

`ProtocolService.cancel_task` already enforces requester-only and posts the
cancellation to the room, so the tool is the same thin wrapper the other five
task tools are.

Nothing tied the documented tool names to the registered ones, which is how
this survived. A new test asserts the task protocol is exposed end to end and
that every tool the skills advertise is registered, so the next rename fails
here rather than at an agent's tool call.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two claims behind this branch were read out of the Codex binary rather than
observed: that hook payloads arrive on stdin with no positional operands (the
basis for dropping the `${1:-$(cat)}` fallback), and that `tool_response` for
an MCP tool may carry a `CallToolResult` envelope rather than the payload.

The probe answers both against a live Codex turn in an isolated `CODEX_HOME`.
Its MCP server declares `connect_to_room` exactly as the real one does — an
async FastMCP tool returning `dict[str, Any]` — so the serialisation under test
is the serialisation Switch actually produces: the payload lands in
`structuredContent` and is repeated as JSON in `content[0].text`, the two
shapes the enricher unwraps.

Its hook commands are deliberately not the ones switchdash generates. They
record the operand count and stdin verbatim, so they measure Codex's delivery
mechanism rather than our shell.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The probe needed two fixes before it measured anything. It did not pass
`--dangerously-bypass-hook-trust`, which Codex requires to run a hook it has
no persisted `trusted_hash` for, and its hooks.json was assembled by heredoc
so the quotes inside the probe commands produced invalid JSON. Codex responds
to both by running no hooks and carrying on, which reads as "hooks don't
fire"; it now builds the config with `json.dumps`, validates it, and fails
loudly on a parse warning in the transcript rather than reporting a blank.

Both questions are now answered against Codex CLI 0.146.0:

Payload delivery — `argc=0`, `arg1=<unset>`, and the event JSON on stdin for
both SessionStart (445 bytes, carrying `session_id`) and PostToolUse (874).
The `${1:-$(cat)}` fallback was dead code, as the binary's `$SHELL -lc`
invocation implied.

Tool result shape — Codex forwards the `CallToolResult` intact:
`{content: [{type: 'text', text: '<json>'}], structuredContent: {…},
isError: false}`. It does not unwrap the way Claude Code does, so reading
`tool_response.room_id` finds nothing. The enricher's unwrapping is therefore
load-bearing, not defensive: without it the room-tracking hook would fire,
find no ids, and silently do nothing.

That capture is now pinned verbatim in the enricher test. Reverting the parser
turns it, and four sibling cases, into `{ kind: 'ignore' }`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
christian-mcdermott and others added 21 commits July 30, 2026 14:54
Codex sets `sessionIdOnResumeOnly`, so `buildStandardCommand` emits no session
id for a fresh session — and `generateAgentLaunchSpec` builds with
`isResuming: false`. Its launch spec therefore never contained
`__SWITCHDASH_SESSION_ID__`, and `materializeAgentCommand` required it:

  Error: agent launch spec is missing the __SWITCHDASH_SESSION_ID__ argv token

Every remote auto-session spawn threw, retried three times, and posted "I
tried to start a session but couldn't" into the room. The requirement predates
this branch, but Codex is the only `sessionIdOnResumeOnly` provider that is
onboardable, and this branch is what makes it onboardable — so it also made
the argv-registration work for auto-sessions unreachable.

The session id is not needed on argv: switchdash correlates a spawn through
the pty id in the hook env and learns the provider's own id from the
SessionStart hook. Only the prompt is load-bearing, since it carries the room
to connect to. Require that; substitute the session id and endpoint when
present.

`generate-agent-launch-spec.test.ts` mocks `buildCommand`, which is exactly
why it did not catch this — a stub cannot show that the real provider spec
omits a token. It now also drives the real `buildStandardCommand` for a
Codex-shaped and a Claude-shaped provider and pipes both through
`materializeAgentCommand`.

Also corrects four claims both connector skills made about the MCP surface,
each verified against `bridges/agent/mcp/server.py`:

- `list_participants` returns `{id, name, type, status, alias}`; `agent_type`,
  task capabilities and room role come from `connect_to_room` /
  `get_room_detail`.
- `create_reference` takes `read_visibility` / `write_visibility`, not
  `visibility`.
- `bridge_display_name` is returned by `get_room_detail` only, not
  `connect_to_room`.
- `update_agent_detail` options are per known-agent type: Codex has
  `auto_session` and does not have `channels_enabled` / `subagent_name`, and
  an undefined key is ignored rather than rejected.

Both plugin versions bumped per the convention.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…updates (CHOO-1436)

Four findings from an adversarial pass over the branch.

`launchArgsForServer`'s comment had the override semantics backwards. Verified
against Codex 0.146.0: `-c mcp_servers.<name>.<key>` MERGES into that server's
table rather than replacing it. Overriding `url` on a name the user's
`config.toml` already defines as a stdio server produces a table with both
`command` and `url`, and Codex then refuses to load its config at all — the
session does not start. Registering a name the config does not define works
(`streamable_http servers 1`). Comment corrected and the collision recorded,
since the failure is a dead session rather than a missing tool.

Codex lists a disabled plugin among the installed ones but does not load its
skill, so the parser reported the connector as present while its tooling was
inert — and offered the agent type for onboarding on that basis. Disabled
entries are now dropped.

The codex service fixture invented an install-cache directory distinct from
the marketplace root, which is what made its `updateAvailable: true` assertion
possible. Codex reports `source.path` as the marketplace SOURCE directory —
per the verbatim capture in the sibling dialect test — so for a local-path
marketplace the installed and advertised manifests are the same file and an
update can never be detected. The fixture now matches, and the test says so.
Claude's manifest sits alongside reporting a wrong version, so a reader that
went to the wrong dir still fails.

The probe left a copy of the user's `~/.codex/auth.json` in its temp directory
on every non-clean run, which is most of them. The copy is now removed on every
exit path including SIGINT, and the kept-directory notice says so.

Both connector skills documented an attachment download without `--fail`, so
an HTTP error body would be written to the output file with curl exiting 0 —
the agent would then read a JSON error as an image.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…pproving ones (CHOO-1436)

Codex keys hook trust per entry in `~/.codex/config.toml` and runs no hook it
has no entry for. `--dangerously-bypass-hook-trust` was bundled into
`buildCodexAutoApproveFlag`, so it reached only auto-approving sessions — and a
default local Codex agent ran none of switchdash's hooks at all.

Measured against 0.146.0 with the hook config switchdash actually installs:

  with the flag     SessionStart fires, `hook: SessionStart Completed` logged
  without the flag  no hook runs, and the transcript never mentions hooks

So the room tracking this branch adds was dead for a default agent, and so was
the SessionStart rollout-id capture that resume depends on. Worse for existing
users: trust is keyed on the hook's content, and this branch rewrites the
SessionStart command, which invalidates the entry they had already granted.

The flag is now a default arg. Codex accepts it ahead of a subcommand, so it
leads argv on the fresh, auto-approving and resume paths alike (verified).

Writing per-entry trust would be narrower and was the first choice, but the
`trusted_hash` input is undocumented — around 25 encodings of the command text,
handler JSON, enclosing group and TOML all fail to reproduce a known hash, and
there is no `codex hooks trust` command. Guessing it would produce something
that breaks silently on a Codex change, re-disabling the hooks it exists to
protect. Recorded on the constant, with a note that the flag is per-invocation
and also un-gates a hook the user added themselves.

`command.test.ts` asserted the flag was absent when `autoApprove: false`,
pinning the broken behaviour; it now asserts the opposite.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…(CHOO-1436)

Writing `.switch/agents/<name>.json` became unconditional for every provider,
but the only thing that deleted it was Claude's `removeLocal`. So deleting an
agent of a provider without repo-agent definitions left a live SWITCH_API_TOKEN
in the working directory, with the row gone and no UI path left to revoke it.

Teardown now mirrors the write: the caller removes the provider-neutral
credentials for every provider, and `removeLocal` is left owning only the
provider-specific files (the definition and the legacy per-agent settings).
Same split as the write side, where one neutral writer replaced the
capability hook.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`renameAgent` updated the row and relocated the remote sidecar, but left every
file switchdash writes for the agent under the old name. Both are keyed by
`name`: the Switch credentials at `.switch/agents/<name>.json` and, for a
provider with repo-agent definitions, the definition the CLI launches against
(`--agent <name>`).

The credentials loss is unrecoverable — the token is minted once and lives
nowhere else — and it is silent: the launch path falls through to the shared
`.claude/settings.local.json`, so the session authenticates as whatever
identity happens to be there, possibly a different agent's.

Pre-existing, but the id-keyed neutral file used to make a rename survivable for
providers without repo-agents; they are name-keyed now and the migration deletes
the id-keyed copy, so nothing catches it any more.

New files are written before the old ones are removed, so an interruption leaves
a recoverable duplicate rather than nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Found while running the E2E procedure against Codex 0.146.0.

Codex copies a plugin into a versioned cache —
`<CODEX_HOME>/plugins/cache/<marketplace>/<plugin>/<version>/` — but reports
`source.path` in `plugin list --json` as the marketplace SOURCE directory. The
dialect handed that back as `manifestPath`, and `installedVersion` prefers a
manifest read over the CLI's own `version`, so switchdash read the version the
marketplace currently advertises and reported it as the installed one.

Measured: install at 0.1.1, bump the checkout to 0.2.0 without reinstalling.
The CLI still reports `version: 0.1.1`; switchdash reported installed 0.2.0,
equal to advertised, and rendered "up to date" while the session was running
0.1.1. The dialect's own comment claimed `version` was "authoritative either
way" — it was not, because nothing consulted it.

Codex entries now carry no manifest path, so `installedVersion` falls back to
the CLI's `version`. That is the account of what Codex actually installed.

This also means local Codex update detection works, where the previous
comment asserted it never could. Only the remote driver is still blind, and
for a different reason: `codex plugin marketplace list --json` carries no
plugin versions, so it has nothing to compare against.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…O-1436)

The commit that added `-fsS` to the attachment curls claimed both skills, but
only the Codex one changed. Without `-f` a rejected upload exits 0 and prints
the error body, so an agent following the skill reports a file as sent when it
was not. CLAUDE.md now requires the two skills to stay in step, so this is the
first thing that rule catches.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…cy fixes (CHOO-1436)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…n (CHOO-1436)

main released 0.10.0; the stack's earlier lock-sync commit re-pinned 0.9.0 when
replayed on top. Match the lock to pyproject.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…y (CHOO-1436)

CODEX_SANDBOX_MODE / CODEX_APPROVAL_POLICY let a Codex session launch under a
sandbox that blocks loopback, which silently disables every switchdash hook —
they are `curl`s to 127.0.0.1 ending in `|| true`. Nothing that ships depends on
varying them, and resolving them read the desktop's `process.env` even for SSH
sessions, so a remote VM's sandbox was configured from the operator's laptop.
The flags are now fixed at the values automation requires.

That removes the reason the provider registry carried no `autoApproveFlag` for
Codex: the string is no longer configurable, so a literal is accurate again.
Codex was the only one of 31 entries without its argv mirror. Hook trust stays
in `defaultArgs` — it is orthogonal to the sandbox and every session needs it.

The mirror is descriptive; nothing reads it at spawn time. `provider-argv-parity`
builds the real command through the plugin and fails if the two disagree, which
also catches the hand-written Codex spec in `generate-agent-launch-spec.test.ts`
having lost `sandbox_mode`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…-1436)

Everything switchdash provisions per agent is keyed by the agent's name, not its
id: Switch credentials at `.switch/agents/<name>.json` and the provider
definition at `.claude/agents/<name>.md`. Two agents sharing a name in one
location therefore share one credentials file.

`renameAgent` writes the credentials to the destination unconditionally, so
renaming onto a sibling replaced that sibling's token with this agent's and then
deleted the original — leaving the sibling authenticating to Switch as somebody
else. Reproduced in the added test.

`addAgent` is exposed the same way. It delegates uniqueness entirely to the
gateway's HTTP 409, which is scoped to the Switch server and cannot see a name
that is free there and taken in this directory.

`agentNameTaken` now guards both, before the rename writes anything and before
`addAgent` mints an identity it would have to discard. `renameAgent` returns a
`Result` rather than throwing: a name collision is user-fixable, and this
codebase reserves throws for what should not happen.

There is still no unique index on (location_id, name); adding one needs a
dedupe migration against installs that may already hold duplicates.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e (CHOO-1436)

`delete` is how an agent's Switch token is revoked from disk, and all three
implementations reported success no matter what happened.

The local one wrapped `unlink` in a bare `try {}` `catch {}`, swallowing
permission errors alongside ENOENT — and swallowing the path-escape error from
`resolveSafe`, which was inside the try. It now ignores only ENOENT and ENOTDIR,
the same reasoning already written into `read`.

Both SSH adapters — there are two, and the second is easy to miss — discarded
`SshFileSystem.remove`'s result entirely. That call never throws; it reports
through `{success, error}`, so permission denied, a failed `rm -rf` and a dropped
connection all read as success. `assertRemoved` translates the result into the
`delete` contract for both.

`deleteAgent` gets its own catch around the credentials delete, matching the one
`removeLocal` already has, so a failure there cannot skip the teardown step
after it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…date (CHOO-1436)

`ensureMarketplace` re-points a same-named marketplace registered against a
stale source. `install` and `checkForUpdates` both call it; `update` did not.

That matters for Codex specifically, because Codex has no per-plugin update verb
and `update` is therefore remove-then-add. Against a stale source the remove
succeeds and the re-add fails, leaving the host with no connector at all — the
one outcome the method's own error message is written to describe. It is
reachable without a prior "Check for updates": the Update affordance is gated on
`updateAvailable`, which `getStatus` computes from on-disk manifests.

Both drivers now repair the marketplace before the uninstall, returning the same
failure shape `install` uses so a marketplace problem never reaches the
destructive step. Tests cover the stale-source path and the codex dialect's
refresh failure, which previously only ran under claude-code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…d (CHOO-1436)

Codex's marketplace listing carries no plugin versions, so on a remote host
`latestVersion` is always null and `updateAvailable` always false. Four comments
across the driver and the dialect already say an empty version map means
"unknown, not up to date" — and nothing on the wire carried that distinction, so
every caller collapsed the two.

The sharpest consequence: "Check for updates" toasted "Switch connector is up to
date" on no evidence at all. It now says the currency could not be determined.

`updateCheckUnavailable` gives the two callers one predicate to branch on. The
settings card and the remote-host panel disclose the limitation in place, and
both offer a reinstall — on a remote host `updateAvailable` can never go true, so
otherwise the plugin could only be refreshed by uninstalling it first.

Local Codex is unaffected: it reads versions from on-disk manifests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…n (CHOO-1436)

`-c mcp_servers.<name>.<key>` merges into the config's table for that name
rather than replacing it. Overriding `url` on a name the user's own
`~/.codex/config.toml` 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 on a parse error switchdash never sees.

`codexMcpAdapter`'s docblock states that precondition and nothing enforced it.
`launchArgsForServer` is synchronous and takes no filesystem, so the check lives
in the runtimes, beside `ensureHooksInstalled` — but it throws, where that helper
swallows everything.

Local only. A remote agent's VM home is not mounted here, so probing it would
answer "no collision" for every host; the SSH path passes null and logs the gap
rather than letting an unreadable config read as clear.

The test asserting url and token are emitted together explained itself with the
opposite model — that `-c` replaces a table wholesale, which would make this
collision harmless. Corrected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ult (CHOO-1436)

`asRoomResult` accepted any object carrying a string `room_id`, while the
docstring two lines below defines the result as carrying `room_id`, `agent_id`
and `name`.

`parseToolResponse` tries `structuredContent` before the text block and returns
on the first match, so an envelope whose `structuredContent` held only `room_id`
would win over a text block carrying the whole result — and the event would then
be dropped for a missing `agent_id` that was there all along. Requiring both
fields lets the chain fall through.

Not reachable on Codex 0.146.0, which populates both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A relative path handed to a `PluginFs` may be resolved against a remote POSIX
host over SFTP, so it must be a forward-slash literal — `path.join` emits
backslashes when switchdash runs on Windows. `switch-settings-paths.ts` documents
the rule; `claude/subagents.ts` was the one file under `packages/` that broke it,
and it broke it at the two module-level constants, which poisoned all four
derived helpers.

`launchArgs` had the same defect on an absolute path: it is called with
`remoteRepoDir` and with the SSH session path, so a Windows host emitted
backslash separators into a `--settings` flag that a Linux shell then parsed.

The test built its expected keys with `path.join` too, so it agreed with the bug
— which is why nothing caught it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… (CHOO-1436)

An `@mcp.tool` docstring ships to agents as the tool's schema description,
whether or not a connector skill is loaded. An audit of all 46 tools found 13
disagreeing with the code beside them.

The four that matter most are authorization claims. `delegate_task` said
"Requires can_delegate capability"; the three performer-side task tools said
"Requires can_accept". Neither flag is read by any handler — they are populated
from config and surfaced in `list_participants`, `/commands` and the in-room
instructions, and that is all. The handlers check requester/performer identity
and room membership. The docstrings now say what is actually enforced; whether
the flags should become authoritative is a separate decision, and enforcing
`can_delegate` would break every agent that has not opted in, since it defaults
to false.

The rest are contract drift, each verified against the model that produces it:
`list_participants` omitted `status` and `alias`; `load_internal_documents`
omitted `name`; `list_references` omitted `linked_rooms`; `get_room_detail`
omitted `join_event_listeners` and `archived`; `get_agent_detail` omitted
`addressing_policy` and pointed at two tools that return no agent ids;
`connect_to_room` described `held_by` as names when it carries holder objects;
`send_targeted_message` omitted the `dormant` status; `assume_role` said
re-assuming fails when re-assuming the role you hold refreshes its lease;
`update_agent_detail` listed only claude-code's options and omitted
`auto_session` even there.

`cancel_task` no longer re-reads the task to report its status. The service
raises on every failure, so the read could only turn a committed cancel into an
error — and the docstring already promised the literal value.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…rift (CHOO-1436)

`define_role`, `edit_role` and `delete_role` are registered MCP tools that
neither skill mentioned: the roles section covered only consuming a role, so an
agent asked to set a room up had no documented way to create one. Both skills
now describe them, including that an edit reaches a holder only on its next
`assume_role`.

`list_linked_rooms` and `update_room` are named in both skill bodies and were
absent from the tool-surface test, so a rename would not have been caught.

The test's set is maintained by hand, which is the same exposure it exists to
close, and parsing the skills does not fix it — the trigger list omits the
body-only names, and matching identifiers in the prose collides with field names
and parameter forms until the assertion is vacuous. Instead the set is extended
and a separate check asserts the two connectors' trigger lists are equal to each
other and registered. They are byte-identical today with nothing enforcing it.

The marketplace description still described itself as Claude-Code-only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ts (CHOO-1436)

Comments-only. CLAUDE.md asks that a comment explain intent that stands on its
own rather than document the edit that introduced it, and the codebase carries
no first-person plural in comments anywhere else.

`generate-agent-launch-spec.test.ts` justified its second suite by what a past
defect got through; it now states the standing limitation of mocking
`buildCommand`. Two "we" phrasings in the switch-setup driver are reworded.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…anonymous

Add docs/TELEMETRY.md: the complete field-by-field catalogue of every
Console telemetry event with example values, the three code-level
mechanisms that keep free text out of a payload, the install identifier,
the send path, the client-IP requirements on the relay, and the argument
that the data cannot be traced to a person.

Add a Telemetry section to the README summarising what is collected, what
never is, and how to opt out, linking to the full document.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@amaudruz

Copy link
Copy Markdown
Collaborator Author

Superseded — branched off a feature branch by mistake, so this carried 108 unrelated files. Reopened clean off main.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants