From a0f8bfc9ddc3cdda87e3639214881ac97fdeb2a4 Mon Sep 17 00:00:00 2001 From: Christian McDermott Date: Mon, 27 Jul 2026 13:37:19 -0400 Subject: [PATCH 01/51] feat(codex): honor CODEX_SANDBOX_MODE/CODEX_APPROVAL_POLICY + add tests (CHOO-1436) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- dash/AGENTS.md | 6 + .../core/providers/agent-provider-registry.ts | 2 +- .../plugins/helpers/standard-command.ts | 2 +- .../agents/impl/codex/auto-approve.test.ts | 60 +++++++ .../src/agents/impl/codex/auto-approve.ts | 57 +++++++ .../src/agents/impl/codex/command.test.ts | 63 +++++++ .../src/agents/impl/codex/hooks.test.ts | 155 ++++++++++++++++++ .../plugins/src/agents/impl/codex/index.ts | 4 +- 8 files changed, 345 insertions(+), 4 deletions(-) create mode 100644 dash/packages/plugins/src/agents/impl/codex/auto-approve.test.ts create mode 100644 dash/packages/plugins/src/agents/impl/codex/auto-approve.ts create mode 100644 dash/packages/plugins/src/agents/impl/codex/command.test.ts create mode 100644 dash/packages/plugins/src/agents/impl/codex/hooks.test.ts diff --git a/dash/AGENTS.md b/dash/AGENTS.md index 234c08bb6..7c0f4d263 100644 --- a/dash/AGENTS.md +++ b/dash/AGENTS.md @@ -387,6 +387,12 @@ pnpm run test `SWITCHDASH_DB_FILE`, `SWITCHDASH_DISABLE_NATIVE_DB`, `SWITCHDASH_DISABLE_PTY`, `SWITCHDASH_REGISTER_DEEPLINK`, `CODEX_SANDBOX_MODE`, and `CODEX_APPROVAL_POLICY`. + - `CODEX_SANDBOX_MODE` (`read-only` | `workspace-write` | `danger-full-access`) + and `CODEX_APPROVAL_POLICY` (`untrusted` | `on-request` | `never`) override the + `-c sandbox_mode=…` / `-c approval_policy=…` flags switchdash passes to Codex, + defaulting to `danger-full-access` / `never` for headless auto-sessions. An + unrecognized value is a hard error (it will not silently fall back to full + access). See `packages/plugins/src/agents/impl/codex/auto-approve.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/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..472eacf9b 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 @@ -99,7 +99,7 @@ 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'], 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/plugins/src/agents/impl/codex/auto-approve.test.ts b/dash/packages/plugins/src/agents/impl/codex/auto-approve.test.ts new file mode 100644 index 000000000..9901032af --- /dev/null +++ b/dash/packages/plugins/src/agents/impl/codex/auto-approve.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest'; +import { buildCodexAutoApproveFlag } from './auto-approve'; + +describe('buildCodexAutoApproveFlag', () => { + it('defaults to full access + no approvals when unset', () => { + expect(buildCodexAutoApproveFlag({})).toBe( + '-c approval_policy="never" -c sandbox_mode="danger-full-access" --dangerously-bypass-hook-trust' + ); + }); + + it('honors a CODEX_SANDBOX_MODE override', () => { + expect(buildCodexAutoApproveFlag({ CODEX_SANDBOX_MODE: 'workspace-write' })).toBe( + '-c approval_policy="never" -c sandbox_mode="workspace-write" --dangerously-bypass-hook-trust' + ); + }); + + it('honors a CODEX_APPROVAL_POLICY override', () => { + expect(buildCodexAutoApproveFlag({ CODEX_APPROVAL_POLICY: 'on-request' })).toBe( + '-c approval_policy="on-request" -c sandbox_mode="danger-full-access" --dangerously-bypass-hook-trust' + ); + }); + + it('honors both overrides together', () => { + expect( + buildCodexAutoApproveFlag({ + CODEX_SANDBOX_MODE: 'read-only', + CODEX_APPROVAL_POLICY: 'untrusted', + }) + ).toBe( + '-c approval_policy="untrusted" -c sandbox_mode="read-only" --dangerously-bypass-hook-trust' + ); + }); + + it('trims whitespace and treats a blank value as unset', () => { + expect(buildCodexAutoApproveFlag({ CODEX_SANDBOX_MODE: ' workspace-write ' })).toContain( + 'sandbox_mode="workspace-write"' + ); + expect(buildCodexAutoApproveFlag({ CODEX_SANDBOX_MODE: ' ' })).toContain( + 'sandbox_mode="danger-full-access"' + ); + }); + + it('always keeps --dangerously-bypass-hook-trust (needed for the SessionStart hook)', () => { + expect(buildCodexAutoApproveFlag({ CODEX_SANDBOX_MODE: 'read-only' })).toContain( + '--dangerously-bypass-hook-trust' + ); + }); + + it('throws on an unknown sandbox mode rather than silently widening access', () => { + expect(() => buildCodexAutoApproveFlag({ CODEX_SANDBOX_MODE: 'full' })).toThrow( + /Invalid CODEX_SANDBOX_MODE="full"/ + ); + }); + + it('throws on an unknown approval policy', () => { + expect(() => buildCodexAutoApproveFlag({ CODEX_APPROVAL_POLICY: 'yolo' })).toThrow( + /Invalid CODEX_APPROVAL_POLICY="yolo"/ + ); + }); +}); diff --git a/dash/packages/plugins/src/agents/impl/codex/auto-approve.ts b/dash/packages/plugins/src/agents/impl/codex/auto-approve.ts new file mode 100644 index 000000000..5646e41c0 --- /dev/null +++ b/dash/packages/plugins/src/agents/impl/codex/auto-approve.ts @@ -0,0 +1,57 @@ +/** + * Codex's sandbox and approval behavior is configurable through two switchdash + * environment variables, documented in AGENTS.md: + * - CODEX_SANDBOX_MODE → Codex `-c sandbox_mode=...` + * - CODEX_APPROVAL_POLICY → Codex `-c approval_policy=...` + * + * When unset, both fall back to the automation defaults that headless + * auto-sessions require (full access, no approval prompts). An explicit but + * unrecognized value is a hard error rather than a silent fallback: a typo in + * CODEX_SANDBOX_MODE must never quietly widen the sandbox back to full access. + */ + +export const CODEX_SANDBOX_MODES = ['read-only', 'workspace-write', 'danger-full-access'] as const; +export const CODEX_APPROVAL_POLICIES = ['untrusted', 'on-request', 'never'] as const; + +export type CodexSandboxMode = (typeof CODEX_SANDBOX_MODES)[number]; +export type CodexApprovalPolicy = (typeof CODEX_APPROVAL_POLICIES)[number]; + +const DEFAULT_SANDBOX_MODE: CodexSandboxMode = 'danger-full-access'; +const DEFAULT_APPROVAL_POLICY: CodexApprovalPolicy = 'never'; + +function resolveEnum( + raw: string | undefined, + allowed: readonly T[], + fallback: T, + envVar: string +): T { + const value = raw?.trim(); + if (!value) return fallback; + if ((allowed as readonly string[]).includes(value)) return value as T; + throw new Error(`Invalid ${envVar}="${value}". Expected one of: ${allowed.join(', ')}.`); +} + +/** + * Build Codex's auto-approve argument string, honoring the CODEX_SANDBOX_MODE + * and CODEX_APPROVAL_POLICY overrides. + * + * `--dangerously-bypass-hook-trust` is always included: it is orthogonal to the + * sandbox and lets Codex run switchdash's own SessionStart hook (which captures + * the rollout session id used for resume) without the interactive trust prompt + * that automated sessions cannot answer. + */ +export function buildCodexAutoApproveFlag(env: Record = {}): string { + const sandboxMode = resolveEnum( + env.CODEX_SANDBOX_MODE, + CODEX_SANDBOX_MODES, + DEFAULT_SANDBOX_MODE, + 'CODEX_SANDBOX_MODE' + ); + const approvalPolicy = resolveEnum( + env.CODEX_APPROVAL_POLICY, + CODEX_APPROVAL_POLICIES, + DEFAULT_APPROVAL_POLICY, + 'CODEX_APPROVAL_POLICY' + ); + return `-c approval_policy="${approvalPolicy}" -c sandbox_mode="${sandboxMode}" --dangerously-bypass-hook-trust`; +} 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..97b1d3d4e --- /dev/null +++ b/dash/packages/plugins/src/agents/impl/codex/command.test.ts @@ -0,0 +1,63 @@ +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: '', +}; + +describe('codex buildCommand', () => { + it('starts a fresh session with the prompt positional and no session-id flag', () => { + 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. + expect(cmd.args).not.toContain('switchdash-session'); + // auto-approve applied and hook-trust bypass always present. + expect(cmd.args).toContain('--dangerously-bypass-hook-trust'); + // The prompt is the final positional argument. + expect(cmd.args.at(-1)).toBe('Fix the bug'); + }); + + it('omits auto-approve args when autoApprove is false', () => { + const cmd = build({ ...base, initialPrompt: 'hello' }); + expect(cmd.args).not.toContain('--dangerously-bypass-hook-trust'); + expect(cmd.args).toEqual(['hello']); + }); + + it('resumes with the captured rollout session id', () => { + const cmd = build({ ...base, isResuming: true, providerSessionId: 'rollout-9' }); + expect(cmd.args[0]).toBe('resume'); + expect(cmd.args[1]).toBe('rollout-9'); + // No positional prompt is added on resume. + expect(cmd.args).not.toContain('Fix the bug'); + }); + + 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(0, 2)).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..5db87ff93 --- /dev/null +++ b/dash/packages/plugins/src/agents/impl/codex/hooks.test.ts @@ -0,0 +1,155 @@ +import type { PluginFs } from '@switchdash/core/agents/plugins'; +import { describe, expect, it } from 'vitest'; +import { CODEX_HOOKS_PATH, buildCodexHookConfig } from './hooks'; + +const CODEX_CONFIG_PATH = '.codex/config.toml'; + +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' }); + }); +}); + +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']) { + expect(config.hooks[key]).toHaveLength(1); + expect(JSON.stringify(config.hooks[key][0])).toContain('SWITCHDASH_HOOK_PORT'); + } + }); + + 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'); + }); +}); diff --git a/dash/packages/plugins/src/agents/impl/codex/index.ts b/dash/packages/plugins/src/agents/impl/codex/index.ts index fdcafa59d..dad47216a 100644 --- a/dash/packages/plugins/src/agents/impl/codex/index.ts +++ b/dash/packages/plugins/src/agents/impl/codex/index.ts @@ -5,6 +5,7 @@ import { homebrewOption, npmDependency, } from '@switchdash/core/agents/plugins/helpers'; +import { buildCodexAutoApproveFlag } from './auto-approve'; import { buildCodexHookConfig } from './hooks'; import { icon } from './icon'; @@ -73,8 +74,7 @@ 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', + autoApproveFlag: buildCodexAutoApproveFlag(process.env), initialPromptFlag: '', resumeFlag: 'resume', sessionIdFlag: ' ', From b1b5c60f9021c59817467963f9ec9d05e9f285ff Mon Sep 17 00:00:00 2001 From: Christian McDermott Date: Tue, 28 Jul 2026 12:31:06 -0400 Subject: [PATCH 02/51] fix(codex): derive default agent-name prefix from the chosen provider (CHOO-1436) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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... 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.. and Claude is unchanged. Description uses the provider name too. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../main/core/agents/agent-defaults.test.ts | 10 +++++++- .../src/main/core/agents/agent-defaults.ts | 24 ++++++++++++------- .../main/core/switch-servers/controller.ts | 7 ++++-- .../add-agent-modal/add-agent-modal.tsx | 8 +++++-- .../components/add-agent-modal/modes.ts | 15 ++++++++---- 5 files changed, 46 insertions(+), 18 deletions(-) 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/switch-servers/controller.ts b/dash/apps/switchdash-desktop/src/main/core/switch-servers/controller.ts index b5e4d0f88..2da0ca276 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 @@ -14,6 +14,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,8 +229,10 @@ 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 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..3d74d4de3 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(), 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; From c483547cb64cde4b6400e113a3e315524235a678 Mon Sep 17 00:00:00 2001 From: Christian McDermott Date: Tue, 28 Jul 2026 12:12:13 -0400 Subject: [PATCH 03/51] fix(codex): persist Switch creds on disk for providers without repo-agents (CHOO-1436) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/.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) --- .../src/main/core/agents/add-agent.ts | 20 ++++++- .../core/agents/write-switch-settings.test.ts | 59 ++++++++++++++++++- .../main/core/agents/write-switch-settings.ts | 22 +++++++ 3 files changed, 99 insertions(+), 2 deletions(-) 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..a94086a62 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 @@ -14,6 +14,7 @@ import { resolveWorkspaceFsFor } from './agent-workspace-fs'; import { createAgent } from './createAgent'; import { registerAgentIdentity } from './register-agent-identity'; import { reconcileAgentAutoSessionFromGateway } from './setAgentAutoSession'; +import { writeNeutralAgentSettingsFs } from './write-switch-settings'; export type AddAgentParams = { id?: string; @@ -74,6 +75,10 @@ export async function addAgent(params: AddAgentParams): Promise }); if (registered.kind !== 'created') return registered; + // Generated up front so the per-agent credentials file can be keyed by it + // below — the launch path reads `agentSettingsPath(sessionPath, session.agentId)`. + const localAgentId = params.id ?? randomUUID(); + const behavior = getPlugin(params.providerId).behavior.repoAgents; const workspace = await resolveWorkspaceFsFor(params.sshHost, params.dir); try { @@ -89,6 +94,19 @@ export async function addAgent(params: AddAgentParams): Promise apiToken: registered.apiKey, agentId: registered.id, }); + } else { + // Providers without repo-agent definitions (e.g. Codex) have no + // `writeCredentials` hook, so their Switch credentials would never land on + // disk — leaving the session with no `SWITCH_*` to inject and the + // MCP-based Switch setup a no-op. Write the provider-neutral per-agent + // file directly from the freshly-minted token, keyed by the agent id the + // launch path reads (CHOO-1436). + await writeNeutralAgentSettingsFs(workspace.fs, { + slug: localAgentId, + apiEndpoint: server.apiUrl, + apiToken: registered.apiKey, + agentId: registered.id, + }); } } finally { workspace.close(); @@ -101,7 +119,7 @@ export async function addAgent(params: AddAgentParams): Promise }); const agent = await createAgent({ - id: params.id ?? randomUUID(), + id: localAgentId, locationId: location.id, name: params.name, providerId: params.providerId, 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..b36bff7b5 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,18 @@ 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, + writeNeutralAgentSettingsFs, writeSwitchSettings, } from './write-switch-settings'; @@ -92,6 +98,57 @@ 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', 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({ 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', + }); + }); +}); + 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..1509677f1 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,7 @@ 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 { agentSettingsRelativePath, SWITCH_AGENTS_GITIGNORE_RELATIVE, @@ -284,3 +285,24 @@ export async function writeAgentNeutralSettings(params: { 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 id) — the authoritative identity switchdash injects at launch + * (`agentSettingsPath`). Mirrors {@link writeAgentNeutralSettings} but through the + * transport-agnostic fs so it works at create time for both local and remote + * agents, and for providers with no repo-agent `writeCredentials` hook (e.g. + * Codex), which would otherwise get no credentials on disk at all (CHOO-1436). + */ +export async function writeNeutralAgentSettingsFs( + workspaceFs: PluginFs, + params: { slug: string } & SwitchSettingsCredentials +): Promise { + const relPath = agentSettingsRelativePath(params.slug); + const merged = mergeSwitchSettings(await workspaceFs.read(relPath), params); + await workspaceFs.write(relPath, merged); + if (!(await workspaceFs.exists(SWITCH_AGENTS_GITIGNORE_RELATIVE))) { + await workspaceFs.write(SWITCH_AGENTS_GITIGNORE_RELATIVE, '*\n'); + } +} From d9f8f04efaa789dda9fa734e4facbcbfca370919 Mon Sep 17 00:00:00 2001 From: Christian McDermott Date: Tue, 28 Jul 2026 15:59:07 -0400 Subject: [PATCH 04/51] feat(codex): register Codex as its own gateway known-agent (CHOO-1436) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 && 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) --- core/switch_core/gateway/known_agents.py | 114 ++++++++++++++++++ .../switch_core/gateway/test_known_agents.py | 75 ++++++++++++ .../src/main/core/agents/add-agent.ts | 2 + .../main/core/agents/known-agent-type.test.ts | 14 +++ .../src/main/core/agents/known-agent-type.ts | 11 ++ .../core/agents/register-agent-identity.ts | 4 + .../core/switch-servers/gateway-client.ts | 13 +- 7 files changed, 231 insertions(+), 2 deletions(-) create mode 100644 dash/apps/switchdash-desktop/src/main/core/agents/known-agent-type.test.ts create mode 100644 dash/apps/switchdash-desktop/src/main/core/agents/known-agent-type.ts diff --git a/core/switch_core/gateway/known_agents.py b/core/switch_core/gateway/known_agents.py index 72782b7fb..b86e341f8 100644 --- a/core/switch_core/gateway/known_agents.py +++ b/core/switch_core/gateway/known_agents.py @@ -297,8 +297,122 @@ 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.""" + + # switchdash sends `channels_enabled` for every provider; Codex has no + # connector channel, so it does not affect the profile. Accepted (and + # ignored) for request-shape compatibility with the claude-code options. + channels_enabled: bool = True + + @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" + 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), + ) + + @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(Codex sessions are normally " + "managed by switchdash — it auto-starts one when I'm addressed.)" + ) + + KNOWN_AGENTS: dict[str, type[KnownAgent]] = { "claude-code": ClaudeCodeKnownAgent, + "codex": CodexKnownAgent, } diff --git a/core/tests/switch_core/gateway/test_known_agents.py b/core/tests/switch_core/gateway/test_known_agents.py index 63b400967..f41489500 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,78 @@ 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" + + 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_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 + 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 + + 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_accepted_but_ignored(self) -> None: + # switchdash sends channels_enabled for every provider; Codex accepts it + # without it affecting the profile. + opts = CodexOptions.model_validate({"channels_enabled": False}) + 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/dash/apps/switchdash-desktop/src/main/core/agents/add-agent.ts b/dash/apps/switchdash-desktop/src/main/core/agents/add-agent.ts index a94086a62..4d975e4dd 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 @@ -12,6 +12,7 @@ import { basenameFromAnyPath } from '@shared/path-name'; import { agentEvents } from './agent-events'; 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'; @@ -72,6 +73,7 @@ export async function addAgent(params: AddAgentParams): Promise description: params.description, repoDir: params.dir, autoSession: params.autoSession, + agentType: knownAgentTypeForProvider(params.providerId), }); if (registered.kind !== 'created') return registered; 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..cbc4dc173 --- /dev/null +++ b/dash/apps/switchdash-desktop/src/main/core/agents/known-agent-type.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest'; +import { knownAgentTypeForProvider } from './known-agent-type'; + +describe('knownAgentTypeForProvider', () => { + it('maps codex to its own gateway known-agent type', () => { + expect(knownAgentTypeForProvider('codex')).toBe('codex'); + }); + + it('maps every other provider to claude-code (the generic managed shape)', () => { + for (const id of ['claude', 'grok', 'gemini', 'cursor', 'droid'] as const) { + expect(knownAgentTypeForProvider(id)).toBe('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..2e7624ffe --- /dev/null +++ b/dash/apps/switchdash-desktop/src/main/core/agents/known-agent-type.ts @@ -0,0 +1,11 @@ +import type { AgentProviderId } from '@shared/core/providers/agent-provider-registry'; + +/** + * Map a switchdash provider to the gateway known-agent type it registers as. + * Codex has its own gateway known-agent (correct connector type + `codex …` + * onboarding command instead of Claude's); every other provider still registers + * as `claude-code`, the generic switchdash-managed shape (CHOO-1436). + */ +export function knownAgentTypeForProvider(providerId: AgentProviderId): string { + return providerId === 'codex' ? 'codex' : 'claude-code'; +} 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..9e9f2b8f8 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,9 @@ export type RegisterAgentInput = { description: string; repoDir: string; autoSession?: boolean; + /** Gateway known-agent type; derive from the provider via + * {@link knownAgentTypeForProvider}. Defaults to 'claude-code'. */ + agentType?: string; }; /** @@ -34,6 +37,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/switch-servers/gateway-client.ts b/dash/apps/switchdash-desktop/src/main/core/switch-servers/gateway-client.ts index 9e77c9315..a9e08bbbe 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,22 @@ 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. Defaults to 'claude-code' for back-compat; + * pass 'codex' for a Codex agent so it registers as a Codex known-agent + * (correct connector type + `codex …` onboarding command) rather than + * inheriting Claude Code's (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 ?? 'claude-code', name: params.name, description: params.description, options: params.options, From 96e8eae376eb70bc2f89843dd1f5200fa5df79af Mon Sep 17 00:00:00 2001 From: Christian McDermott Date: Wed, 29 Jul 2026 14:02:22 -0400 Subject: [PATCH 05/51] refactor(codex): address PR self-review findings (CHOO-1436) 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) --- core/switch_core/gateway/known_agents.py | 13 +++-- .../switch_core/gateway/test_known_agents.py | 58 ++++++++++++++++++- dash/AGENTS.md | 4 +- .../src/main/core/agents/add-agent.ts | 4 ++ .../main/core/switch-servers/controller.ts | 11 ++-- .../src/agents/impl/codex/auto-approve.ts | 2 +- .../src/agents/impl/codex/command.test.ts | 43 +++++++++++--- .../src/agents/impl/codex/hooks.test.ts | 13 ++++- .../plugins/src/agents/impl/codex/hooks.ts | 2 +- 9 files changed, 123 insertions(+), 27 deletions(-) diff --git a/core/switch_core/gateway/known_agents.py b/core/switch_core/gateway/known_agents.py index b86e341f8..756b77e8b 100644 --- a/core/switch_core/gateway/known_agents.py +++ b/core/switch_core/gateway/known_agents.py @@ -317,10 +317,11 @@ class CodexOptions(KnownAgentOptions): unavailable-session message so the operator gets a notification. Bare name, no leading `@`. None → post without a mention.""" - # switchdash sends `channels_enabled` for every provider; Codex has no - # connector channel, so it does not affect the profile. Accepted (and - # ignored) for request-shape compatibility with the claude-code options. channels_enabled: bool = True + """switchdash sends `channels_enabled` for every provider; Codex has no + connector channel of its own, so it does not affect the registered profile. + Accepted (and ignored) for request-shape compatibility with the claude-code + options so the shared registration path needs no special-casing.""" @field_validator("repo_dir", "notify_user", mode="before") @classmethod @@ -382,7 +383,7 @@ def start_session_instructions( 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}"' + cmd = f'cd "{dir_token}" && codex "{prompt}"' prefix = f"@{options.notify_user}\n\n" if options.notify_user else "" if connected_not_live: @@ -405,8 +406,8 @@ def start_session_instructions( "session connected to this room, my operator should run:" ) return ( - f"{prefix}{opening}\n\n```\n{cmd}\n```\n\n(Codex sessions are normally " - "managed by switchdash — it auto-starts one when I'm addressed.)" + f"{prefix}{opening}\n\n```\n{cmd}\n```\n\n(or start Codex manually and " + "ask me to connect to the room.)" ) diff --git a/core/tests/switch_core/gateway/test_known_agents.py b/core/tests/switch_core/gateway/test_known_agents.py index f41489500..c0317a742 100644 --- a/core/tests/switch_core/gateway/test_known_agents.py +++ b/core/tests/switch_core/gateway/test_known_agents.py @@ -264,17 +264,71 @@ 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 - assert 'cd /Users/x/repo && codex "connect to switch room hub"' in msg + # 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 + assert 'cd ""' in msg def test_notify_user_prepended_as_at_mention(self) -> None: opts = CodexOptions(repo_dir="/x", notify_user="cmcd") diff --git a/dash/AGENTS.md b/dash/AGENTS.md index 7c0f4d263..bb6ce36d1 100644 --- a/dash/AGENTS.md +++ b/dash/AGENTS.md @@ -392,7 +392,9 @@ pnpm run test `-c sandbox_mode=…` / `-c approval_policy=…` flags switchdash passes to Codex, defaulting to `danger-full-access` / `never` for headless auto-sessions. An unrecognized value is a hard error (it will not silently fall back to full - access). See `packages/plugins/src/agents/impl/codex/auto-approve.ts`. + access); the value is validated when a Codex **session launches**, not at app + startup, so a bad value surfaces as a session-start failure. See + `packages/plugins/src/agents/impl/codex/auto-approve.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/apps/switchdash-desktop/src/main/core/agents/add-agent.ts b/dash/apps/switchdash-desktop/src/main/core/agents/add-agent.ts index 4d975e4dd..292899690 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 @@ -97,6 +97,10 @@ export async function addAgent(params: AddAgentParams): Promise agentId: registered.id, }); } else { + // Two credential-keying conventions exist and the readers (auto-session + // watcher + notification poller) try both via a fallback chain: + // - behavior providers (Claude) key by agent NAME (writeCredentials above) + // - non-behavior providers (Codex) key by agent ID (this branch) // Providers without repo-agent definitions (e.g. Codex) have no // `writeCredentials` hook, so their Switch credentials would never land on // disk — leaving the session with no `SWITCH_*` to inject and the 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 2da0ca276..3c9fa76a7 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 @@ -235,12 +235,11 @@ export const switchServersController = createRPCController({ }): 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); diff --git a/dash/packages/plugins/src/agents/impl/codex/auto-approve.ts b/dash/packages/plugins/src/agents/impl/codex/auto-approve.ts index 5646e41c0..3095d3098 100644 --- a/dash/packages/plugins/src/agents/impl/codex/auto-approve.ts +++ b/dash/packages/plugins/src/agents/impl/codex/auto-approve.ts @@ -40,7 +40,7 @@ function resolveEnum( * the rollout session id used for resume) without the interactive trust prompt * that automated sessions cannot answer. */ -export function buildCodexAutoApproveFlag(env: Record = {}): string { +export function buildCodexAutoApproveFlag(env: Record): string { const sandboxMode = resolveEnum( env.CODEX_SANDBOX_MODE, CODEX_SANDBOX_MODES, diff --git a/dash/packages/plugins/src/agents/impl/codex/command.test.ts b/dash/packages/plugins/src/agents/impl/codex/command.test.ts index 97b1d3d4e..38ca3dfab 100644 --- a/dash/packages/plugins/src/agents/impl/codex/command.test.ts +++ b/dash/packages/plugins/src/agents/impl/codex/command.test.ts @@ -1,5 +1,5 @@ import type { CommandContext } from '@switchdash/core/agents/plugins'; -import { describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { provider } from './index'; function build(ctx: CommandContext) { @@ -14,17 +14,34 @@ const base: CommandContext = { model: '', }; +// The default (unset) sandbox/approval flag, split the way buildStandardCommand +// splits it on whitespace. +const AUTO_FLAGS = [ + '-c', + 'approval_policy="never"', + '-c', + 'sandbox_mode="danger-full-access"', + '--dangerously-bypass-hook-trust', +]; + describe('codex buildCommand', () => { - it('starts a fresh session with the prompt positional and no session-id flag', () => { + // Neutralize any ambient CODEX_SANDBOX_MODE / CODEX_APPROVAL_POLICY on the dev + // machine so the auto-approve flag is deterministic (blank → defaults). + beforeEach(() => { + vi.stubEnv('CODEX_SANDBOX_MODE', ''); + vi.stubEnv('CODEX_APPROVAL_POLICY', ''); + }); + afterEach(() => { + vi.unstubAllEnvs(); + }); + + 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. - expect(cmd.args).not.toContain('switchdash-session'); - // auto-approve applied and hook-trust bypass always present. - expect(cmd.args).toContain('--dangerously-bypass-hook-trust'); - // The prompt is the final positional argument. - expect(cmd.args.at(-1)).toBe('Fix the bug'); + // Full structural check: auto-approve flags in order, prompt last. + expect(cmd.args).toEqual([...AUTO_FLAGS, 'Fix the bug']); }); it('omits auto-approve args when autoApprove is false', () => { @@ -41,6 +58,18 @@ describe('codex buildCommand', () => { 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(['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, diff --git a/dash/packages/plugins/src/agents/impl/codex/hooks.test.ts b/dash/packages/plugins/src/agents/impl/codex/hooks.test.ts index 5db87ff93..66b84703f 100644 --- a/dash/packages/plugins/src/agents/impl/codex/hooks.test.ts +++ b/dash/packages/plugins/src/agents/impl/codex/hooks.test.ts @@ -1,8 +1,6 @@ import type { PluginFs } from '@switchdash/core/agents/plugins'; import { describe, expect, it } from 'vitest'; -import { CODEX_HOOKS_PATH, buildCodexHookConfig } from './hooks'; - -const CODEX_CONFIG_PATH = '.codex/config.toml'; +import { CODEX_CONFIG_PATH, CODEX_HOOKS_PATH, buildCodexHookConfig } from './hooks'; function createMemoryFs(initial: Record = {}): PluginFs { const files = new Map(Object.entries(initial)); @@ -85,6 +83,15 @@ describe('buildCodexHookConfig.parseHookEvent', () => { 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', () => { diff --git a/dash/packages/plugins/src/agents/impl/codex/hooks.ts b/dash/packages/plugins/src/agents/impl/codex/hooks.ts index c405012a6..94b1b8787 100644 --- a/dash/packages/plugins/src/agents/impl/codex/hooks.ts +++ b/dash/packages/plugins/src/agents/impl/codex/hooks.ts @@ -13,7 +13,7 @@ import { 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'; const LEGACY_CODEX_NOTIFY_COMMAND = [ 'bash', From 116f288d0b48298e3f6631876b5ccadd7f35bde8 Mon Sep 17 00:00:00 2001 From: Christian McDermott Date: Wed, 29 Jul 2026 14:05:58 -0400 Subject: [PATCH 06/51] fix(agents): write per-agent Switch credentials for every provider (CHOO-1436) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `.switch/agents/.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) --- .../agent-runtime/impl/local-agent-runtime.ts | 12 +- .../src/main/core/agents/add-agent.ts | 36 ++---- .../core/agents/migrate-agent-storage.test.ts | 113 +++++++++++------- .../main/core/agents/migrate-agent-storage.ts | 24 ++-- .../src/main/core/agents/onboard-agent.ts | 13 +- .../main/core/agents/write-switch-settings.ts | 6 +- 6 files changed, 117 insertions(+), 87 deletions(-) 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..cafdcfd8e 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 @@ -201,14 +201,18 @@ export class LocalAgentRuntime implements AgentRuntimeProvider { // 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` + // 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). + // which Claude reads natively). Keyed by `name` — the one key-space every + // writer uses (CHOO-1440); id is only a fallback for a nameless legacy row. const subagentVars = session.agentName && repoAgents ? await repoAgents.readLaunchEnv(createPluginFs(this.sessionPath), session.agentName) - : await readAgentSwitchEnv(agentSettingsPath(this.sessionPath, session.agentId), log); + : await readAgentSwitchEnv( + agentSettingsPath(this.sessionPath, session.agentName ?? session.agentId), + log + ); + const pty = spawnLocalPty({ id: ptySessionId, command: resolved.command, 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 292899690..e803652bd 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 @@ -77,42 +77,28 @@ export async function addAgent(params: AddAgentParams): Promise }); if (registered.kind !== 'created') return registered; - // Generated up front so the per-agent credentials file can be keyed by it - // below — the launch path reads `agentSettingsPath(sessionPath, session.agentId)`. const localAgentId = params.id ?? randomUUID(); 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, - }); - } else { - // Two credential-keying conventions exist and the readers (auto-session - // watcher + notification poller) try both via a fallback chain: - // - behavior providers (Claude) key by agent NAME (writeCredentials above) - // - non-behavior providers (Codex) key by agent ID (this branch) - // Providers without repo-agent definitions (e.g. Codex) have no - // `writeCredentials` hook, so their Switch credentials would never land on - // disk — leaving the session with no `SWITCH_*` to inject and the - // MCP-based Switch setup a no-op. Write the provider-neutral per-agent - // file directly from the freshly-minted token, keyed by the agent id the - // launch path reads (CHOO-1436). - await writeNeutralAgentSettingsFs(workspace.fs, { - slug: localAgentId, - apiEndpoint: server.apiUrl, - apiToken: registered.apiKey, - agentId: registered.id, - }); } } finally { workspace.close(); 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..a393fc406 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,34 +41,54 @@ 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 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' }, + }) + ) + ); + 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 = { + writeCredentials, + 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, + writeCredentials, + readLaunchEnv, + readDefinition, + writeDefinition, + discoverLocal, updateAgent: vi.fn(async () => undefined), isComplete: vi.fn(async () => false), markComplete: vi.fn(async () => undefined), @@ -76,17 +96,7 @@ const h = vi.hoisted(() => { }); 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' })), @@ -124,6 +134,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 +146,34 @@ 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.writeCredentials).not.toHaveBeenCalled(); + expect(h.writeDefinition).not.toHaveBeenCalled(); }); it('falls back to .claude/settings.local.json when no neutral file exists', async () => { @@ -152,10 +185,8 @@ 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 () => { 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..97c9c4f63 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 @@ -10,6 +10,7 @@ import { 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 @@ -65,8 +66,12 @@ interface MigrateResult { /** Migrate one agent (local or remote). */ async function migrateOne(agent: Agent): Promise { + // A provider may have no repo-agent behavior (e.g. Codex): it has no on-disk + // definition and no `writeCredentials`/`readLaunchEnv` hooks, 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 }; const location = await getLocationById(agent.locationId); if (!location) return { changed: false, complete: true }; @@ -84,9 +89,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. @@ -96,14 +103,14 @@ async function migrateOne(agent: Agent): Promise { if (neutral === null) { const creds = parseSwitchAgentCredentials((await workspace.fs.read(idKeyedRelPath)) ?? '', log) ?? - toCreds(await behavior.readLaunchEnv(workspace.fs, name)) ?? + (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,8 +130,9 @@ 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; } 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..87c6103eb 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 @@ -141,17 +141,18 @@ 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/write-switch-settings.ts b/dash/apps/switchdash-desktop/src/main/core/agents/write-switch-settings.ts index 1509677f1..583baf7df 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 @@ -289,11 +289,11 @@ export async function writeAgentNeutralSettings(params: { /** * 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 id) — the authoritative identity switchdash injects at launch + * (the agent name) — the authoritative identity switchdash injects at launch * (`agentSettingsPath`). Mirrors {@link writeAgentNeutralSettings} but through the * transport-agnostic fs so it works at create time for both local and remote - * agents, and for providers with no repo-agent `writeCredentials` hook (e.g. - * Codex), which would otherwise get no credentials on disk at all (CHOO-1436). + * agents. This is the unconditional per-agent credential write for every provider; + * providers with repo-agent definitions (Claude) layer their definition on top. */ export async function writeNeutralAgentSettingsFs( workspaceFs: PluginFs, From cbf798ee403e285213176c4e612682501fbb527c Mon Sep 17 00:00:00 2001 From: Christian McDermott Date: Wed, 29 Jul 2026 14:12:03 -0400 Subject: [PATCH 07/51] fix(agents): require an explicit gateway known-agent type (CHOO-1436) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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) --- core/switch_core/gateway/known_agents.py | 2 +- .../switch_core/gateway/test_known_agents.py | 2 +- .../main/core/agents/known-agent-type.test.ts | 28 +++++++++++++++-- .../src/main/core/agents/known-agent-type.ts | 31 ++++++++++++++++--- .../core/agents/onboard-location-agents.ts | 4 +++ .../core/agents/register-agent-identity.ts | 5 +-- .../main/core/switch-servers/controller.ts | 6 ++++ .../core/switch-servers/gateway-client.ts | 11 +++---- 8 files changed, 72 insertions(+), 17 deletions(-) diff --git a/core/switch_core/gateway/known_agents.py b/core/switch_core/gateway/known_agents.py index 756b77e8b..1189d73e4 100644 --- a/core/switch_core/gateway/known_agents.py +++ b/core/switch_core/gateway/known_agents.py @@ -332,7 +332,7 @@ def _blank_string_to_none(cls, value: object) -> object: class CodexKnownAgent(KnownAgent): - connector_type = "Codex" + connector_type = "Codex CLI" options_schema = CodexOptions tools = [ ToolSpec(name="Shell", description="Executes shell commands"), diff --git a/core/tests/switch_core/gateway/test_known_agents.py b/core/tests/switch_core/gateway/test_known_agents.py index c0317a742..60f880013 100644 --- a/core/tests/switch_core/gateway/test_known_agents.py +++ b/core/tests/switch_core/gateway/test_known_agents.py @@ -238,7 +238,7 @@ def test_message_does_not_interpolate_room_name(self) -> None: class TestCodexKnownAgent: def test_registered_under_codex_key(self) -> None: assert KNOWN_AGENTS.get("codex") is CodexKnownAgent - assert CodexKnownAgent.connector_type == "Codex" + assert CodexKnownAgent.connector_type == "Codex CLI" def test_default_profile_is_session_addressable(self) -> None: profile = CodexKnownAgent.build_profile(CodexOptions()) 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 index cbc4dc173..42327a65d 100644 --- 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 @@ -1,14 +1,36 @@ -import { describe, expect, it } from 'vitest'; +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('maps every other provider to claude-code (the generic managed shape)', () => { - for (const id of ['claude', 'grok', 'gemini', 'cursor', 'droid'] as const) { + 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 index 2e7624ffe..71c972316 100644 --- 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 @@ -1,11 +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. - * Codex has its own gateway known-agent (correct connector type + `codex …` - * onboarding command instead of Claude's); every other provider still registers - * as `claude-code`, the generic switchdash-managed shape (CHOO-1436). + * + * 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 { - return providerId === 'codex' ? 'codex' : 'claude-code'; + 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/onboard-location-agents.ts b/dash/apps/switchdash-desktop/src/main/core/agents/onboard-location-agents.ts index 7e97cdc62..9d08ff056 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'; @@ -114,6 +115,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 : ''; 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 9e9f2b8f8..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 @@ -8,8 +8,9 @@ export type RegisterAgentInput = { repoDir: string; autoSession?: boolean; /** Gateway known-agent type; derive from the provider via - * {@link knownAgentTypeForProvider}. Defaults to 'claude-code'. */ - agentType?: string; + * `knownAgentTypeForProvider`. Required — an omitted type would silently + * register the agent as Claude Code whatever it actually runs (CHOO-1436). */ + agentType: string; }; /** 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 3c9fa76a7..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'; @@ -249,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; @@ -280,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.ts b/dash/apps/switchdash-desktop/src/main/core/switch-servers/gateway-client.ts index a9e08bbbe..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 @@ -222,18 +222,17 @@ export async function registerKnownAgent( name: string; description: string; options: RegisterKnownAgentOptions; - /** Gateway known-agent type. Defaults to 'claude-code' for back-compat; - * pass 'codex' for a Codex agent so it registers as a Codex known-agent - * (correct connector type + `codex …` onboarding command) rather than - * inheriting Claude Code's (CHOO-1436). */ - agentType?: string; + /** 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: params.agentType ?? 'claude-code', + agent_type: params.agentType, name: params.name, description: params.description, options: params.options, From a049f89216dac68707d4ef1cbf1fb38ba4575a1d Mon Sep 17 00:00:00 2001 From: Christian McDermott Date: Wed, 29 Jul 2026 16:47:21 -0400 Subject: [PATCH 08/51] fix(agents): keep relative Switch settings paths POSIX (CHOO-1436) `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) --- .../core/agents/propagate-server-api-url.ts | 9 ++--- .../core/agents/remove-switch-settings.ts | 13 +++---- .../core/agents/switch-settings-paths.test.ts | 35 +++++++++++++++++++ .../main/core/agents/switch-settings-paths.ts | 16 ++++++--- .../agents/write-remote-switch-settings.ts | 9 ++--- 5 files changed, 55 insertions(+), 27 deletions(-) create mode 100644 dash/apps/switchdash-desktop/src/main/core/agents/switch-settings-paths.test.ts 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/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/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'}`); } From 6ab047e14dc1ffbec19557e0b46042952c3e1005 Mon Sep 17 00:00:00 2001 From: Christian McDermott Date: Wed, 29 Jul 2026 16:47:32 -0400 Subject: [PATCH 09/51] fix(agents): stop warning when a credentials file is simply absent (CHOO-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) --- .../main/core/agents/migrate-agent-storage.ts | 7 +- .../switch-rooms/switch-credentials.test.ts | 78 +++++++++++++++++++ .../core/switch-rooms/switch-credentials.ts | 54 +++++++++---- 3 files changed, 120 insertions(+), 19 deletions(-) create mode 100644 dash/apps/switchdash-desktop/src/main/core/switch-rooms/switch-credentials.test.ts 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 97c9c4f63..da94da0c1 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 @@ -102,12 +102,9 @@ 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) ?? + 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 - ); + parseSwitchAgentCredentials(await workspace.fs.read(SWITCH_SETTINGS_RELATIVE_PATH), log); if (creds) { await writeNeutralAgentSettingsFs(workspace.fs, { slug: name, 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; From 83f46988ec913f817799ea968d00998cdcaed112 Mon Sep 17 00:00:00 2001 From: Christian McDermott Date: Wed, 29 Jul 2026 16:47:49 -0400 Subject: [PATCH 10/51] fix(agents): inject the Switch identity on the remote launch path (CHOO-1436) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `SshAgentRuntime` gated `identityVars` on the Claude-only `repoAgents` behavior and fell through to `{}`. `LocalAgentRuntime` was fixed to read the provider-neutral `.switch/agents/.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) --- .../impl/local-agent-runtime.test.ts | 33 ++++++++++- .../agent-runtime/impl/local-agent-runtime.ts | 17 +++--- .../impl/ssh-agent-runtime.test.ts | 56 ++++++++++++++++++- .../agent-runtime/impl/ssh-agent-runtime.ts | 13 +++-- .../src/main/core/agents/agent-creds-slug.ts | 24 ++++++++ .../src/main/core/sessions/session-builder.ts | 17 +++--- .../src/shared/core/sessions/sessions.ts | 3 +- 7 files changed, 136 insertions(+), 27 deletions(-) create mode 100644 dash/apps/switchdash-desktop/src/main/core/agents/agent-creds-slug.ts 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..314fecd5f 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 @@ -55,8 +55,14 @@ vi.mock('@main/core/switch-rooms/switch-notification-poller', () => ({ }, })); +// Stubbed so importing the runtime doesn't pull in the DB client (no Electron +// `app` in tests). The agent row is what resolves the credentials-file slug. +vi.mock('@main/core/agents/getAgentById', () => ({ + getAgentById: vi.fn(async () => ({ name: 'codex-hoot' })), +})); + 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 +160,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'); @@ -249,6 +256,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 cafdcfd8e..cd223914c 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 @@ -5,7 +5,7 @@ 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 type { AgentRuntimeProvider } from '@main/core/agent-runtime/types'; -import { agentSettingsPath } from '@main/core/agents/switch-settings-paths'; +import { resolveAgentCredsSlug } 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 +20,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'; @@ -201,17 +201,14 @@ export class LocalAgentRuntime implements AgentRuntimeProvider { // 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` + // 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). Keyed by `name` — the one key-space every - // writer uses (CHOO-1440); id is only a fallback for a nameless legacy row. + // which Claude reads natively). + const workspaceFs = createPluginFs(this.sessionPath); const subagentVars = session.agentName && repoAgents - ? await repoAgents.readLaunchEnv(createPluginFs(this.sessionPath), session.agentName) - : await readAgentSwitchEnv( - agentSettingsPath(this.sessionPath, session.agentName ?? session.agentId), - log - ); + ? await repoAgents.readLaunchEnv(workspaceFs, session.agentName) + : await readAgentSwitchEnvFromFs(workspaceFs, await resolveAgentCredsSlug(session), log); const pty = spawnLocalPty({ id: ptySessionId, 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..675111e86 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'; @@ -117,6 +118,7 @@ function emitReconnected(connectionId: string): void { } const { events } = await import('@main/lib/events'); +const { getAgentById } = await import('@main/core/agents/getAgentById'); type ProviderState = { known: boolean; @@ -139,14 +141,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 +173,7 @@ function sshProvider({ sessionPath: '/repo', tmux, ctx, - fs: { copyLocalFile: vi.fn(async () => {}) } as never, + fs, proxy, connectionId: 'ssh-1', }); @@ -230,6 +249,41 @@ 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. + vi.mocked(getAgentById).mockResolvedValueOnce({ + autoApprove: false, + name: 'codex-hoot', + } as never); + 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('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..aadeb20cc 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 @@ -3,6 +3,7 @@ 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 type { AgentRuntimeProvider } from '@main/core/agent-runtime/types'; +import { agentCredsSlug, resolveAgentCredsSlug } 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 +21,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 +215,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(agent, session), agentName: agent?.name ?? session.agentName ?? null, ctx: this.ctx, connectionId: this.connectionId, @@ -303,7 +305,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(agent, session), agentName: agent?.name ?? session.agentName ?? null, ctx: this.ctx, connectionId: this.connectionId, @@ -451,13 +453,14 @@ export class SshAgentRuntime implements AgentRuntimeProvider { 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` + // 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 remoteFs = createRemotePluginFs(this.fs); const identityVars = session.agentName && repoAgents - ? await repoAgents.readLaunchEnv(createRemotePluginFs(this.fs), session.agentName) - : {}; + ? await repoAgents.readLaunchEnv(remoteFs, session.agentName) + : await readAgentSwitchEnvFromFs(remoteFs, await resolveAgentCredsSlug(session), log); const tmuxSessionName = this.tmux ? makeAgentTmuxSessionName(this.sessionId) : undefined; 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..a43217400 --- /dev/null +++ b/dash/apps/switchdash-desktop/src/main/core/agents/agent-creds-slug.ts @@ -0,0 +1,24 @@ +import type { Agent } from '@shared/core/agents/agents'; +import type { Session } from '@shared/core/sessions/sessions'; +import { getAgentById } from './getAgentById'; + +/** + * 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. + * + * The agent row is preferred over the session's denormalised `agentName` because + * it is the source of truth, and the local agent id is a last-resort fallback for + * a row that predates named agents. Kept in one place so the launch paths, the + * sidecar, and the remote preflight can never drift onto different key-spaces. + */ +export function agentCredsSlug( + agent: Pick | null | undefined, + session: Session +): string { + return agent?.name ?? session.agentName ?? session.agentId; +} + +/** {@link agentCredsSlug} for a caller that has not already loaded the agent row. */ +export async function resolveAgentCredsSlug(session: Session): Promise { + return agentCredsSlug(await getAgentById(session.agentId), session); +} 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..772fee445 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 { resolveAgentCredsSlug } 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(await resolveAgentCredsSlug(session)), + agentSettingsRelativePath(session.agentId), + ]), SWITCH_SETTINGS_RELATIVE_PATH, ]; 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; From 29c2c3c0f687b1a849661f00b56f84d363f87d86 Mon Sep 17 00:00:00 2001 From: Christian McDermott Date: Wed, 29 Jul 2026 16:47:58 -0400 Subject: [PATCH 11/51] fix(agents): version the storage-migration marker (CHOO-1436) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../agent-storage-migration-marker.db.test.ts | 67 +++++++++++++++++++ .../agents/agent-storage-migration-marker.ts | 21 +++++- 2 files changed, 85 insertions(+), 3 deletions(-) create mode 100644 dash/apps/switchdash-desktop/src/main/core/agents/agent-storage-migration-marker.db.test.ts 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..4402b14f5 --- /dev/null +++ b/dash/apps/switchdash-desktop/src/main/core/agents/agent-storage-migration-marker.db.test.ts @@ -0,0 +1,67 @@ +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 { + isAgentStorageMigrationComplete, + 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 isAgentStorageMigrationComplete()).toBe(false); + }); + + it('reports complete after a clean pass latches it', async () => { + await markAgentStorageMigrationComplete(); + expect(await isAgentStorageMigrationComplete()).toBe(true); + }); + + 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 isAgentStorageMigrationComplete()).toBe(false); + }); + + 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 isAgentStorageMigrationComplete()).toBe(true); + 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..2c05cbd4e 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 @@ -10,14 +10,29 @@ import { kv } from '@main/db/schema'; */ const MARKER_KEY = 'agentStorageMigrationComplete'; +/** + * The migration generation this build knows how to satisfy. Bump it whenever + * {@link migrateAgentStorage} 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. + * + * - `1` — the original pass: Claude agents only (providers without a + * `repoAgents` behavior returned "complete" without being looked at). + * - `2` — every provider's credentials collapsed onto the name-keyed key-space. + */ +const MARKER_VALUE = '2'; + export async function isAgentStorageMigrationComplete(): Promise { const [row] = await db.select().from(kv).where(eq(kv.key, MARKER_KEY)).limit(1); - return row?.value === '1'; + return row?.value === MARKER_VALUE; } export async function markAgentStorageMigrationComplete(): Promise { 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: MARKER_VALUE, updatedAt: sql`CURRENT_TIMESTAMP` }) + .onConflictDoUpdate({ + target: kv.key, + set: { value: MARKER_VALUE, updatedAt: sql`CURRENT_TIMESTAMP` }, + }); } From 3ed33444fd12e8783f7b62fc50506cf171e69ee2 Mon Sep 17 00:00:00 2001 From: Christian McDermott Date: Wed, 29 Jul 2026 16:48:10 -0400 Subject: [PATCH 12/51] refactor(agents): collapse to one per-agent credential writer (CHOO-1436) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three paths wrote `.switch/agents/.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) --- .../src/main/core/agents/add-agent.test.ts | 159 ++++++++++++++++++ .../src/main/core/agents/add-agent.ts | 4 +- .../core/agents/onboard-location-agents.ts | 5 +- .../core/agents/write-switch-settings.test.ts | 59 ++++++- .../main/core/agents/write-switch-settings.ts | 47 ++---- .../plugins/capabilities/repo-agents.ts | 10 -- .../packages/core/src/agents/plugins/index.ts | 1 - .../src/agents/impl/claude/subagents.test.ts | 49 ------ .../src/agents/impl/claude/subagents.ts | 37 ---- 9 files changed, 234 insertions(+), 137 deletions(-) create mode 100644 dash/apps/switchdash-desktop/src/main/core/agents/add-agent.test.ts 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..4608b1225 --- /dev/null +++ b/dash/apps/switchdash-desktop/src/main/core/agents/add-agent.test.ts @@ -0,0 +1,159 @@ +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 } = { + workspace: null, + repoAgents: { writeDefinition }, + }; + return { + state, + writeDefinition, + 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' })), +})); +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.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(); + }); +}); 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 e803652bd..1d033e24a 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 @@ -77,8 +77,6 @@ export async function addAgent(params: AddAgentParams): Promise }); if (registered.kind !== 'created') return registered; - const localAgentId = params.id ?? randomUUID(); - const behavior = getPlugin(params.providerId).behavior.repoAgents; const workspace = await resolveWorkspaceFsFor(params.sshHost, params.dir); try { @@ -111,7 +109,7 @@ export async function addAgent(params: AddAgentParams): Promise }); const agent = await createAgent({ - id: localAgentId, + id: params.id ?? randomUUID(), locationId: location.id, name: params.name, providerId: params.providerId, 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 9d08ff056..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 @@ -20,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; @@ -127,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/write-switch-settings.test.ts b/dash/apps/switchdash-desktop/src/main/core/agents/write-switch-settings.test.ts index b36bff7b5..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 @@ -13,6 +13,7 @@ import { mergeSwitchApiEndpoint, mergeSwitchSettings, removeSwitchSettings, + writeAgentNeutralSettings, writeNeutralAgentSettingsFs, writeSwitchSettings, } from './write-switch-settings'; @@ -120,12 +121,15 @@ describe('writeNeutralAgentSettingsFs', () => { expect(ignore).toBe('*\n'); }); - it('merges into an existing per-agent file, preserving unrelated env keys', async () => { + 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({ env: { EXISTING: 'keep', SWITCH_API_TOKEN: 'old' } }), + JSON.stringify({ + permissions: { allow: ['Bash'] }, + env: { EXISTING: 'keep', SWITCH_API_TOKEN: 'old' }, + }), 'utf8' ); @@ -146,6 +150,57 @@ describe('writeNeutralAgentSettingsFs', () => { 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'); }); }); 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 583baf7df..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 @@ -2,6 +2,7 @@ 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, @@ -258,51 +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; - } - - 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'); - } +export async function writeAgentNeutralSettings( + params: { dir: string; slug: string } & SwitchSettingsCredentials +): Promise { + await writeNeutralAgentSettingsFs(createPluginFs(params.dir), params); } /** * 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`). Mirrors {@link writeAgentNeutralSettings} but through the - * transport-agnostic fs so it works at create time for both local and remote - * agents. This is the unconditional per-agent credential write for every provider; - * providers with repo-agent definitions (Claude) layer their definition on top. + * (`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 { - const relPath = agentSettingsRelativePath(params.slug); - const merged = mergeSwitchSettings(await workspaceFs.read(relPath), params); - await workspaceFs.write(relPath, merged); 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/packages/core/src/agents/plugins/capabilities/repo-agents.ts b/dash/packages/core/src/agents/plugins/capabilities/repo-agents.ts index 7e027798e..a6f8bee37 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[]; diff --git a/dash/packages/core/src/agents/plugins/index.ts b/dash/packages/core/src/agents/plugins/index.ts index bd4108ee8..2a185ac22 100644 --- a/dash/packages/core/src/agents/plugins/index.ts +++ b/dash/packages/core/src/agents/plugins/index.ts @@ -77,7 +77,6 @@ export type { LocalRepoAgent, RepoAgentAttributes, RepoAgentAttributeValue, - RepoAgentCredentials, RepoAgentDefinition, RepoAgentField, RepoAgentFieldOption, 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..689ecc252 100644 --- a/dash/packages/plugins/src/agents/impl/claude/subagents.test.ts +++ b/dash/packages/plugins/src/agents/impl/claude/subagents.test.ts @@ -31,7 +31,6 @@ function fakeFs(files: Record): PluginFs { const settingsRel = (name: string) => path.join(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`); describe('claudeRepoAgentsBehavior.launchArgs', () => { @@ -220,51 +219,3 @@ describe('claudeRepoAgentsBehavior.removeLocal', () => { 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 () => { - 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', - }); - - 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'); - }); -}); diff --git a/dash/packages/plugins/src/agents/impl/claude/subagents.ts b/dash/packages/plugins/src/agents/impl/claude/subagents.ts index e37e2b8c0..62bafa867 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, @@ -460,42 +459,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; }, From 8e5e047c70657ae9ab46f7735ef7ccfb8f895215 Mon Sep 17 00:00:00 2001 From: Christian McDermott Date: Wed, 29 Jul 2026 16:48:22 -0400 Subject: [PATCH 13/51] fix(codex): fail loud on a corrupt hooks file, drop the stale flag copy (CHOO-1436) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- dash/AGENTS.md | 7 ++- .../core/providers/agent-provider-registry.ts | 11 ++-- .../src/agents/impl/codex/command.test.ts | 14 +++++ .../src/agents/impl/codex/hooks.test.ts | 25 ++++++++ .../plugins/src/agents/impl/codex/hooks.ts | 61 ++++--------------- .../plugins/src/agents/impl/codex/index.ts | 5 +- 6 files changed, 63 insertions(+), 60 deletions(-) diff --git a/dash/AGENTS.md b/dash/AGENTS.md index bb6ce36d1..7330a0e3a 100644 --- a/dash/AGENTS.md +++ b/dash/AGENTS.md @@ -392,8 +392,11 @@ pnpm run test `-c sandbox_mode=…` / `-c approval_policy=…` flags switchdash passes to Codex, defaulting to `danger-full-access` / `never` for headless auto-sessions. An unrecognized value is a hard error (it will not silently fall back to full - access); the value is validated when a Codex **session launches**, not at app - startup, so a bad value surfaces as a session-start failure. See + access); the value is resolved when an **auto-approving Codex session + launches**, not at app startup, so a bad value surfaces as a session-start + failure — and only for the sessions that would actually use the flag. Note + that on the resume/restore paths a spawn failure is logged rather than shown, + so a typo there reads as "the session did not come back". See `packages/plugins/src/agents/impl/codex/auto-approve.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 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 472eacf9b..4449cf6dc 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 @@ -105,13 +105,10 @@ export const AGENT_PROVIDERS: AgentProviderDefinition[] = [ 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', + // No `autoApproveFlag` here: Codex's sandbox/approval args are configurable + // (CODEX_SANDBOX_MODE / CODEX_APPROVAL_POLICY) and are built by + // `buildCodexAutoApproveFlag` in the plugin. A literal copy in this metadata + // registry could only ever go stale. initialPromptFlag: '', resumeFlag: 'resume', sessionIdFlag: ' ', diff --git a/dash/packages/plugins/src/agents/impl/codex/command.test.ts b/dash/packages/plugins/src/agents/impl/codex/command.test.ts index 38ca3dfab..1fd1b535a 100644 --- a/dash/packages/plugins/src/agents/impl/codex/command.test.ts +++ b/dash/packages/plugins/src/agents/impl/codex/command.test.ts @@ -77,6 +77,20 @@ describe('codex buildCommand', () => { expect(cmd.args.slice(0, 2)).toEqual(['resume', '--last']); }); + it('rejects an invalid sandbox mode when the session actually auto-approves', () => { + vi.stubEnv('CODEX_SANDBOX_MODE', 'full'); + expect(() => build({ ...base, autoApprove: true, initialPrompt: 'hi' })).toThrow( + /Invalid CODEX_SANDBOX_MODE="full"/ + ); + }); + + it('does not resolve the sandbox env for a session that never auto-approves', () => { + // The flag is unused on this path, so a typo in the env must not stop the + // session from launching at all. + vi.stubEnv('CODEX_SANDBOX_MODE', 'full'); + expect(build({ ...base, initialPrompt: 'hi' }).args).toEqual(['hi']); + }); + it('deduplicates the bypass-approvals-and-sandbox singleton flag', () => { const cmd = build({ ...base, diff --git a/dash/packages/plugins/src/agents/impl/codex/hooks.test.ts b/dash/packages/plugins/src/agents/impl/codex/hooks.test.ts index 66b84703f..d103c0ef0 100644 --- a/dash/packages/plugins/src/agents/impl/codex/hooks.test.ts +++ b/dash/packages/plugins/src/agents/impl/codex/hooks.test.ts @@ -159,4 +159,29 @@ describe('buildCodexHookConfig install/read/delete', () => { // 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'); + }); }); diff --git a/dash/packages/plugins/src/agents/impl/codex/hooks.ts b/dash/packages/plugins/src/agents/impl/codex/hooks.ts index 94b1b8787..5db78456e 100644 --- a/dash/packages/plugins/src/agents/impl/codex/hooks.ts +++ b/dash/packages/plugins/src/agents/impl/codex/hooks.ts @@ -1,14 +1,10 @@ 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, } from '@switchdash/core/agents/plugins/helpers'; import * as toml from 'smol-toml'; @@ -94,53 +90,18 @@ 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: makeCodexSessionStartCommand() }, + ]); 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 dad47216a..357e7b208 100644 --- a/dash/packages/plugins/src/agents/impl/codex/index.ts +++ b/dash/packages/plugins/src/agents/impl/codex/index.ts @@ -74,7 +74,10 @@ export const provider = registerPluginBehavior(plugin, { prompt: { buildCommand: (ctx) => buildStandardCommand(ctx, { - autoApproveFlag: buildCodexAutoApproveFlag(process.env), + // Resolved only when it will actually be used: an unrecognised + // CODEX_SANDBOX_MODE is a hard error, and a session that never + // auto-approves has no business failing to launch over it. + autoApproveFlag: ctx.autoApprove ? buildCodexAutoApproveFlag(process.env) : '', initialPromptFlag: '', resumeFlag: 'resume', sessionIdFlag: ' ', From 01c800b33fd3a630c46c56487b7f492959a541b1 Mon Sep 17 00:00:00 2001 From: Christian McDermott Date: Wed, 29 Jul 2026 16:48:34 -0400 Subject: [PATCH 14/51] feat(codex): session-control parity for reset/compact/interrupt (CHOO-1436) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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) --- core/switch_core/gateway/known_agents.py | 19 +++-- .../switch_core/gateway/test_known_agents.py | 22 ++++- .../core/switch-rooms/session-control.test.ts | 85 +++++++++++++++++++ .../main/core/switch-rooms/session-control.ts | 32 +++++++ 4 files changed, 150 insertions(+), 8 deletions(-) create mode 100644 dash/apps/switchdash-desktop/src/main/core/switch-rooms/session-control.test.ts diff --git a/core/switch_core/gateway/known_agents.py b/core/switch_core/gateway/known_agents.py index 1189d73e4..44f42a11f 100644 --- a/core/switch_core/gateway/known_agents.py +++ b/core/switch_core/gateway/known_agents.py @@ -317,11 +317,11 @@ class CodexOptions(KnownAgentOptions): unavailable-session message so the operator gets a notification. Bare name, no leading `@`. None → post without a mention.""" - channels_enabled: bool = True - """switchdash sends `channels_enabled` for every provider; Codex has no - connector channel of its own, so it does not affect the registered profile. - Accepted (and ignored) for request-shape compatibility with the claude-code - options so the shared registration path needs no special-casing.""" + # 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 @@ -359,6 +359,15 @@ def build_profile(cls, options: KnownAgentOptions) -> IntegrationProfile: 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 diff --git a/core/tests/switch_core/gateway/test_known_agents.py b/core/tests/switch_core/gateway/test_known_agents.py index 60f880013..6896d9eb8 100644 --- a/core/tests/switch_core/gateway/test_known_agents.py +++ b/core/tests/switch_core/gateway/test_known_agents.py @@ -260,6 +260,17 @@ def test_can_delegate_and_accept_tasks(self) -> None: 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") @@ -336,10 +347,15 @@ def test_notify_user_prepended_as_at_mention(self) -> None: assert msg is not None assert msg.startswith("@cmcd\n\n") - def test_channels_enabled_is_accepted_but_ignored(self) -> None: - # switchdash sends channels_enabled for every provider; Codex accepts it - # without it affecting the profile. + 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" 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..ad9a498e9 --- /dev/null +++ b/dash/apps/switchdash-desktop/src/main/core/switch-rooms/session-control.test.ts @@ -0,0 +1,85 @@ +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'); + } + ); + + it('interrupts before clearing on codex, which refuses /clear mid-turn', () => { + expect(resolveSessionControl('codex').plan('reset', ctx)?.slice(0, 2)).toEqual([ + { kind: 'raw', data: ESC }, + { kind: 'prompt', text: '/clear' }, + ]); + }); + + it.each(['claude', 'codex'])('compacts %s with /compact then a reconnect', (providerId) => { + const steps = resolveSessionControl(providerId).plan('compact', ctx); + expect(steps).not.toBeNull(); + expect(steps![0]).toEqual({ kind: 'prompt', text: '/compact' }); + expect((steps![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..de78d5c36 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,37 @@ const CLAUDE_CONTROL: SessionControl = { }, }; +/** + * Codex: also a TUI, and its recipes happen to match Claude's — ESC interrupts + * the current turn (Ctrl+C would exit the session instead), `/clear` starts a + * fresh chat, `/compact` summarises the transcript. 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 to Codex. + */ +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: 'prompt', text: '/compact' }, + { kind: 'prompt', text: reconnectAndAnnounce(ctx, 'context has been compacted') }, + ]; + case 'reset': + // Codex refuses /clear while a turn is running, so interrupt first. + 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 +134,7 @@ const NO_CONTROL: SessionControl = { const BY_PROVIDER: Record = { claude: CLAUDE_CONTROL, + codex: CODEX_CONTROL, }; /** Resolve the session-control support + recipes for a provider. */ From a813da473cab52eae70e2443b4248e78430ecea6 Mon Sep 17 00:00:00 2001 From: Christian McDermott Date: Wed, 29 Jul 2026 16:48:42 -0400 Subject: [PATCH 15/51] test(agents): cover the agent_type registration contract (CHOO-1436) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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) --- .../switch-servers/gateway-client.test.ts | 46 ++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) 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, + }); + }); +}); From d0d5e03e17324d78a2f42f5ea6b603f2898b2c11 Mon Sep 17 00:00:00 2001 From: Christian McDermott Date: Thu, 30 Jul 2026 10:55:05 -0400 Subject: [PATCH 16/51] fix(codex): interrupt before /compact, which Codex also drops mid-turn (CHOO-1436) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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) --- .../core/switch-rooms/session-control.test.ts | 25 ++++++++++++++----- .../main/core/switch-rooms/session-control.ts | 17 ++++++++----- 2 files changed, 30 insertions(+), 12 deletions(-) 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 index ad9a498e9..69f98eab4 100644 --- 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 @@ -56,18 +56,31 @@ describe('resolveSessionControl', () => { } ); - it('interrupts before clearing on codex, which refuses /clear mid-turn', () => { - expect(resolveSessionControl('codex').plan('reset', ctx)?.slice(0, 2)).toEqual([ + // 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: '/clear' }, + { kind: 'prompt', text: slash }, ]); }); - it.each(['claude', 'codex'])('compacts %s with /compact then a reconnect', (providerId) => { + // 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![0]).toEqual({ kind: 'prompt', text: '/compact' }); - expect((steps![1] as { text: string }).text).toContain('context has been compacted'); + expect((steps!.at(-1) as { text: string }).text).toContain('context has been compacted'); }); it('omits the role and thread clauses when there is neither', () => { 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 de78d5c36..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 @@ -97,11 +97,16 @@ const CLAUDE_CONTROL: SessionControl = { }; /** - * Codex: also a TUI, and its recipes happen to match Claude's — ESC interrupts - * the current turn (Ctrl+C would exit the session instead), `/clear` starts a - * fresh chat, `/compact` summarises the transcript. 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 to Codex. + * 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 }, @@ -111,11 +116,11 @@ const CODEX_CONTROL: SessionControl = { 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': - // Codex refuses /clear while a turn is running, so interrupt first. return [ { kind: 'raw', data: ESC }, { kind: 'prompt', text: '/clear' }, From 54cb3ca2ab276e3690bf7af1c65ca71aa68641cf Mon Sep 17 00:00:00 2001 From: Christian McDermott Date: Thu, 30 Jul 2026 10:55:14 -0400 Subject: [PATCH 17/51] test(agents): observe the files, not a spy on a deleted hook (CHOO-1436) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../core/agents/migrate-agent-storage.test.ts | 27 +++++++------------ 1 file changed, 10 insertions(+), 17 deletions(-) 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 a393fc406..b24b54d43 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 @@ -44,14 +44,6 @@ function credsJson(agentId: string): string { // `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 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' }, - }) - ) - ); const readLaunchEnv = vi.fn(async (fs: PluginFs, name: string) => { const raw = (await fs.read(`.switch/agents/${name}.json`)) ?? @@ -66,7 +58,6 @@ const h = vi.hoisted(() => { ); const discoverLocal = vi.fn(async () => []); const defaultRepoAgents = { - writeCredentials, readLaunchEnv, readDefinition, writeDefinition, @@ -84,7 +75,6 @@ const h = vi.hoisted(() => { return { state, defaultRepoAgents, - writeCredentials, readLaunchEnv, readDefinition, writeDefinition, @@ -172,7 +162,6 @@ describe('migrateAgentStorage', () => { 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.writeCredentials).not.toHaveBeenCalled(); expect(h.writeDefinition).not.toHaveBeenCalled(); }); @@ -190,16 +179,22 @@ describe('migrateAgentStorage', () => { }); 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 () => { @@ -208,7 +203,6 @@ describe('migrateAgentStorage', () => { await migrateAgentStorage(); - expect(h.writeCredentials).not.toHaveBeenCalled(); expect(await ws.exists('.switch/agents/cc-hoot-main.json')).toBe(false); }); @@ -219,7 +213,6 @@ describe('migrateAgentStorage', () => { await migrateAgentStorage(); expect(resolveWorkspaceFsFor).not.toHaveBeenCalled(); - expect(h.writeCredentials).not.toHaveBeenCalled(); expect(h.markComplete).not.toHaveBeenCalled(); }); From 9b126398f2e7497a008e0833220f3152bb996254 Mon Sep 17 00:00:00 2001 From: Christian McDermott Date: Thu, 30 Jul 2026 10:55:26 -0400 Subject: [PATCH 18/51] refactor(agents): make the creds slug a pure read of the session (CHOO-1436) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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) --- .../impl/local-agent-runtime.test.ts | 7 +----- .../agent-runtime/impl/local-agent-runtime.ts | 4 ++-- .../impl/ssh-agent-runtime.test.ts | 6 +---- .../agent-runtime/impl/ssh-agent-runtime.ts | 8 +++---- .../src/main/core/agents/agent-creds-slug.ts | 24 +++++++------------ .../src/main/core/sessions/session-builder.ts | 4 ++-- 6 files changed, 18 insertions(+), 35 deletions(-) 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 314fecd5f..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 @@ -55,12 +55,6 @@ vi.mock('@main/core/switch-rooms/switch-notification-poller', () => ({ }, })); -// Stubbed so importing the runtime doesn't pull in the DB client (no Electron -// `app` in tests). The agent row is what resolves the credentials-file slug. -vi.mock('@main/core/agents/getAgentById', () => ({ - getAgentById: vi.fn(async () => ({ name: 'codex-hoot' })), -})); - vi.mock('@main/core/switch-rooms/switch-credentials', () => ({ readAgentSwitchEnvFromFs: vi.fn(async () => ({})), })); @@ -202,6 +196,7 @@ function session(): Session { return { id: 'session-1', agentId: 'agent-1', + agentName: 'codex-hoot', providerId: 'codex', title: 'Session 1', shellId: 'system', 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 cd223914c..90b83c77a 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 @@ -5,7 +5,7 @@ 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 type { AgentRuntimeProvider } from '@main/core/agent-runtime/types'; -import { resolveAgentCredsSlug } from '@main/core/agents/agent-creds-slug'; +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'; @@ -208,7 +208,7 @@ export class LocalAgentRuntime implements AgentRuntimeProvider { const subagentVars = session.agentName && repoAgents ? await repoAgents.readLaunchEnv(workspaceFs, session.agentName) - : await readAgentSwitchEnvFromFs(workspaceFs, await resolveAgentCredsSlug(session), log); + : await readAgentSwitchEnvFromFs(workspaceFs, agentCredsSlug(session), log); const pty = spawnLocalPty({ id: ptySessionId, 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 675111e86..0a4c1ce01 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 @@ -118,7 +118,6 @@ function emitReconnected(connectionId: string): void { } const { events } = await import('@main/lib/events'); -const { getAgentById } = await import('@main/core/agents/getAgentById'); type ProviderState = { known: boolean; @@ -184,6 +183,7 @@ function session(): Session { return { id: 'session-1', agentId: 'agent-1', + agentName: 'codex-hoot', providerId: 'codex', title: 'Session 1', shellId: 'system', @@ -253,10 +253,6 @@ describe('SshAgentRuntime', () => { // 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. - vi.mocked(getAgentById).mockResolvedValueOnce({ - autoApprove: false, - name: 'codex-hoot', - } as never); const exitHandlers: Array void>> = []; mockSpawn(exitHandlers); 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 aadeb20cc..b4722e8f6 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 @@ -3,7 +3,7 @@ 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 type { AgentRuntimeProvider } from '@main/core/agent-runtime/types'; -import { agentCredsSlug, resolveAgentCredsSlug } from '@main/core/agents/agent-creds-slug'; +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'; @@ -215,7 +215,7 @@ export class SshAgentRuntime implements AgentRuntimeProvider { repoDir: this.sessionPath, deeplinkScheme: DEEPLINK_SCHEME, autoApprove: agent?.autoApprove ?? false, - credsSlug: agentCredsSlug(agent, session), + credsSlug: agentCredsSlug(session), agentName: agent?.name ?? session.agentName ?? null, ctx: this.ctx, connectionId: this.connectionId, @@ -305,7 +305,7 @@ export class SshAgentRuntime implements AgentRuntimeProvider { repoDir: this.sessionPath, deeplinkScheme: DEEPLINK_SCHEME, autoApprove: agent?.autoApprove ?? false, - credsSlug: agentCredsSlug(agent, session), + credsSlug: agentCredsSlug(session), agentName: agent?.name ?? session.agentName ?? null, ctx: this.ctx, connectionId: this.connectionId, @@ -460,7 +460,7 @@ export class SshAgentRuntime implements AgentRuntimeProvider { const identityVars = session.agentName && repoAgents ? await repoAgents.readLaunchEnv(remoteFs, session.agentName) - : await readAgentSwitchEnvFromFs(remoteFs, await resolveAgentCredsSlug(session), log); + : await readAgentSwitchEnvFromFs(remoteFs, agentCredsSlug(session), log); const tmuxSessionName = this.tmux ? makeAgentTmuxSessionName(this.sessionId) : undefined; 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 index a43217400..be2d15fd0 100644 --- 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 @@ -1,24 +1,16 @@ -import type { Agent } from '@shared/core/agents/agents'; import type { Session } from '@shared/core/sessions/sessions'; -import { getAgentById } from './getAgentById'; /** * 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. * - * The agent row is preferred over the session's denormalised `agentName` because - * it is the source of truth, and the local agent id is a last-resort fallback for - * a row that predates named agents. Kept in one place so the launch paths, the - * sidecar, and the remote preflight can never drift onto different key-spaces. + * `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( - agent: Pick | null | undefined, - session: Session -): string { - return agent?.name ?? session.agentName ?? session.agentId; -} - -/** {@link agentCredsSlug} for a caller that has not already loaded the agent row. */ -export async function resolveAgentCredsSlug(session: Session): Promise { - return agentCredsSlug(await getAgentById(session.agentId), session); +export function agentCredsSlug(session: Session): string { + return session.agentName ?? session.agentId; } 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 772fee445..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 { resolveAgentCredsSlug } from '@main/core/agents/agent-creds-slug'; +import { agentCredsSlug } from '@main/core/agents/agent-creds-slug'; import { agentSettingsRelativePath, SWITCH_SETTINGS_RELATIVE_PATH, @@ -115,7 +115,7 @@ export async function buildSessionFromRuntime( // yet migrated (CHOO-1440). const credsRelPaths = [ ...new Set([ - agentSettingsRelativePath(await resolveAgentCredsSlug(session)), + agentSettingsRelativePath(agentCredsSlug(session)), agentSettingsRelativePath(session.agentId), ]), SWITCH_SETTINGS_RELATIVE_PATH, From 953b0bf6e7320b258b6b7a3b867513e0cb36a030 Mon Sep 17 00:00:00 2001 From: Christian McDermott Date: Thu, 30 Jul 2026 11:04:41 -0400 Subject: [PATCH 19/51] perf(agents): scope the migration re-run to what its generation can fix (CHOO-1436) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../agent-storage-migration-marker.db.test.ts | 19 +++++--- .../agents/agent-storage-migration-marker.ts | 39 +++++++++------- .../core/agents/migrate-agent-storage.test.ts | 34 ++++++++++++-- .../main/core/agents/migrate-agent-storage.ts | 45 ++++++++++--------- 4 files changed, 92 insertions(+), 45 deletions(-) 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 index 4402b14f5..2d5899bbb 100644 --- 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 @@ -4,7 +4,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { AppDb } from '@main/db/client'; import { kv } from '@main/db/schema'; import { - isAgentStorageMigrationComplete, + AGENT_STORAGE_MIGRATION_GENERATION, + completedAgentStorageMigrationGeneration, markAgentStorageMigrationComplete, } from './agent-storage-migration-marker'; @@ -35,12 +36,16 @@ describe('agent storage migration marker', () => { }); it('reports incomplete when no marker has been written', async () => { - expect(await isAgentStorageMigrationComplete()).toBe(false); + expect(await completedAgentStorageMigrationGeneration()).toBeLessThan( + AGENT_STORAGE_MIGRATION_GENERATION + ); }); it('reports complete after a clean pass latches it', async () => { await markAgentStorageMigrationComplete(); - expect(await isAgentStorageMigrationComplete()).toBe(true); + expect(await completedAgentStorageMigrationGeneration()).toBe( + AGENT_STORAGE_MIGRATION_GENERATION + ); }); it('reports incomplete for a marker latched by an earlier migration generation', async () => { @@ -51,7 +56,9 @@ describe('agent storage migration marker', () => { .insert(kv) .values({ key: MARKER_KEY, value: '1', updatedAt: sql`CURRENT_TIMESTAMP` }); - expect(await isAgentStorageMigrationComplete()).toBe(false); + expect(await completedAgentStorageMigrationGeneration()).toBeLessThan( + AGENT_STORAGE_MIGRATION_GENERATION + ); }); it('upgrades a stale marker in place rather than inserting a second row', async () => { @@ -61,7 +68,9 @@ describe('agent storage migration marker', () => { await markAgentStorageMigrationComplete(); - expect(await isAgentStorageMigrationComplete()).toBe(true); + 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 2c05cbd4e..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,36 +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'; /** - * The migration generation this build knows how to satisfy. Bump it whenever - * {@link migrateAgentStorage} 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. + * Generations of the migration: * - * - `1` — the original pass: Claude agents only (providers without a - * `repoAgents` behavior returned "complete" without being looked at). - * - `2` — every provider's credentials collapsed onto the name-keyed key-space. + * - `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. */ -const MARKER_VALUE = '2'; +export const AGENT_STORAGE_MIGRATION_GENERATION = 2; -export async function isAgentStorageMigrationComplete(): Promise { +/** 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 === MARKER_VALUE; + 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: MARKER_VALUE, updatedAt: sql`CURRENT_TIMESTAMP` }) + .values({ key: MARKER_KEY, value, updatedAt: sql`CURRENT_TIMESTAMP` }) .onConflictDoUpdate({ target: kv.key, - set: { value: MARKER_VALUE, updatedAt: sql`CURRENT_TIMESTAMP` }, + set: { value, updatedAt: sql`CURRENT_TIMESTAMP` }, }); } 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 b24b54d43..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 @@ -80,7 +80,7 @@ const h = vi.hoisted(() => { writeDefinition, discoverLocal, updateAgent: vi.fn(async () => undefined), - isComplete: vi.fn(async () => false), + completedGeneration: vi.fn(async () => 0), markComplete: vi.fn(async () => undefined), }; }); @@ -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, })); @@ -206,8 +207,8 @@ describe('migrateAgentStorage', () => { 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(); @@ -216,6 +217,31 @@ describe('migrateAgentStorage', () => { 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 da94da0c1..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,7 +4,8 @@ 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'; @@ -30,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', { @@ -57,24 +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 `writeCredentials`/`readLaunchEnv` hooks, 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. + // 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; + // 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 { @@ -134,7 +139,7 @@ async function migrateOne(agent: Agent): Promise { changed = true; } - return { changed, complete: true }; + return changed; } finally { workspace.close(); } From 1de30de92894cd082fd5376d14999bc4034aa4f5 Mon Sep 17 00:00:00 2001 From: Christian McDermott Date: Wed, 29 Jul 2026 14:59:58 -0400 Subject: [PATCH 20/51] feat(codex): connect Codex to Switch through its own connector plugin (CHOO-1436) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .claude-plugin/marketplace.json | 6 + .../codex-plugin/.codex-plugin/plugin.json | 6 + connectors/codex-plugin/README.md | 35 + .../codex-plugin/skills/switch/SKILL.md | 609 ++++++++++++++++++ .../agent-runtime/impl/local-agent-runtime.ts | 40 +- .../switch-mcp-launch-args.test.ts | 57 ++ .../agent-runtime/switch-mcp-launch-args.ts | 40 ++ .../core/switch-setup/remote-switch-setup.ts | 118 ++-- .../switch-setup-cli-dialect.test.ts | 125 ++++ .../switch-setup/switch-setup-cli-dialect.ts | 174 +++++ .../switch-setup/switch-setup-service.test.ts | 1 + .../core/switch-setup/switch-setup-service.ts | 196 +++--- .../src/agents/plugins/capabilities/mcp.ts | 14 + .../plugins/capabilities/switch-setup.ts | 34 +- .../core/src/agents/plugins/helpers/mcp.ts | 25 + .../packages/core/src/agents/plugins/index.ts | 7 +- .../plugins/src/agents/impl/claude/index.ts | 1 + .../plugins/src/agents/impl/codex/index.ts | 11 +- 18 files changed, 1344 insertions(+), 155 deletions(-) create mode 100644 connectors/codex-plugin/.codex-plugin/plugin.json create mode 100644 connectors/codex-plugin/README.md create mode 100644 connectors/codex-plugin/skills/switch/SKILL.md create mode 100644 dash/apps/switchdash-desktop/src/main/core/agent-runtime/switch-mcp-launch-args.test.ts create mode 100644 dash/apps/switchdash-desktop/src/main/core/agent-runtime/switch-mcp-launch-args.ts create mode 100644 dash/apps/switchdash-desktop/src/main/core/switch-setup/switch-setup-cli-dialect.test.ts create mode 100644 dash/apps/switchdash-desktop/src/main/core/switch-setup/switch-setup-cli-dialect.ts diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index ae5569bb2..1a1b31e8b 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -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/connectors/codex-plugin/.codex-plugin/plugin.json b/connectors/codex-plugin/.codex-plugin/plugin.json new file mode 100644 index 000000000..606b82246 --- /dev/null +++ b/connectors/codex-plugin/.codex-plugin/plugin.json @@ -0,0 +1,6 @@ +{ + "name": "switch-connector-codex", + "version": "0.1.0", + "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..073f19db1 --- /dev/null +++ b/connectors/codex-plugin/README.md @@ -0,0 +1,35 @@ +# 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 per-agent credentials as +`-c mcp_servers.switch.*` overrides. 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..240a2ed1e --- /dev/null +++ b/connectors/codex-plugin/skills/switch/SKILL.md @@ -0,0 +1,609 @@ +--- +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, 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` to see who else is in + the room, the room role each currently holds (if any), their `agent_type`, + and their task capabilities. +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 -sS -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 -sS -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 (for a local coding agent such as `codex` or `claude-code`: + `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 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: `visibility` (defaults to `"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 `connect_to_room` / `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. + +**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. +- `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/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 90b83c77a..dd22e4ce7 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 @@ -23,6 +23,7 @@ import { providerOverrideSettings } from '@main/core/settings/provider-settings- 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 { switchMcpLaunchArgs } from '@main/core/agent-runtime/switch-mcp-launch-args'; import type { ResolvedShellProfile } from '@main/core/terminal-shell/types'; import { events } from '@main/lib/events'; import { runWithLogContext } from '@main/lib/log-context'; @@ -155,14 +156,33 @@ 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); + 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 +217,6 @@ 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). - const workspaceFs = createPluginFs(this.sessionPath); - const subagentVars = - session.agentName && repoAgents - ? await repoAgents.readLaunchEnv(workspaceFs, session.agentName) - : await readAgentSwitchEnvFromFs(workspaceFs, agentCredsSlug(session), log); const pty = spawnLocalPty({ id: ptySessionId, 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..07efe8bdf --- /dev/null +++ b/dash/apps/switchdash-desktop/src/main/core/agent-runtime/switch-mcp-launch-args.test.ts @@ -0,0 +1,57 @@ +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, because -c replaces a server table wholesale', () => { + // Overriding one key of mcp_servers. discards the rest, so a partial + // set would leave the server unauthenticated rather than merged. + 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..c7fdcdbd1 --- /dev/null +++ b/dash/apps/switchdash-desktop/src/main/core/agent-runtime/switch-mcp-launch-args.ts @@ -0,0 +1,40 @@ +import type { getPlugin } from '@main/core/providers/plugin-registry'; + +/** 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'; + +/** Path appended to the agent-bridge endpoint to reach its MCP surface. */ +const SWITCH_MCP_PATH_SUFFIX = '/mcp/'; + +/** + * 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. + */ +export function switchMcpLaunchArgs( + plugin: ReturnType, + apiEndpoint: string | undefined +): string[] { + const buildArgs = plugin.behavior.mcp?.launchArgsForServer; + if (!buildArgs) return []; + + const endpoint = apiEndpoint?.trim().replace(/\/+$/, ''); + if (!endpoint) return []; + + return buildArgs({ + name: SWITCH_MCP_SERVER_NAME, + transport: 'http', + url: `${endpoint}${SWITCH_MCP_PATH_SUFFIX}`, + bearer_token_env_var: SWITCH_MCP_TOKEN_ENV_VAR, + }); +} 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..d5c437f0b 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 @@ -5,11 +5,8 @@ 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 { cliRulesFor, type SwitchSetupCliRules } from './switch-setup-cli-dialect'; +import type { SwitchSetupResult, SwitchSetupStatus } from './switch-setup-service'; import { marketplaceMatchesSource } from './switch-setup-service'; const EXEC_TIMEOUT_MS = 120_000; @@ -83,7 +80,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 +103,33 @@ 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; + const fromMarketplace = rules + .parseAdvertisedVersions(parseJsonLoose(stdout), marketplaceName) + .get(pluginName); + if (fromMarketplace) return fromMarketplace; + const { stdout: pluginStdout } = await this.run(bin, ['plugin', 'list', '--json']); + return ( + rules.parseAdvertisedVersions(parseJsonLoose(pluginStdout), marketplaceName).get(pluginName) ?? + null + ); } /** @@ -141,18 +141,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 +171,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 +206,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,13 +232,18 @@ 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.' }; @@ -247,11 +253,33 @@ 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 res = await this.run(bin, ['plugin', 'update', ref, '-s', descriptor.scope]); - return res.code === 0 + const { descriptor, bin, ref, rules } = resolved; + 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..6500036bd --- /dev/null +++ b/dash/apps/switchdash-desktop/src/main/core/switch-setup/switch-setup-cli-dialect.test.ts @@ -0,0 +1,125 @@ +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. + expect(rules.parsePluginList(JSON.parse(CODEX_PLUGIN_LIST))).toEqual([ + { + ref: 'switch-connector-codex@switch-plugins', + version: '0.1.0', + manifestPath: '/repo/connectors/codex-plugin', + }, + ]); + expect(cliRulesFor('claude-code').parsePluginList(JSON.parse(CODEX_PLUGIN_LIST))).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 only versions installed plugins', () => { + expect(rules.parseAdvertisedVersions(JSON.parse(CODEX_PLUGIN_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..792a7fa0f --- /dev/null +++ b/dash/apps/switchdash-desktop/src/main/core/switch-setup/switch-setup-cli-dialect.ts @@ -0,0 +1,174 @@ +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, read from the CLI's own listing rather + * than from on-disk manifests (the remote driver has no cheap filesystem + * access). Keyed by plugin name. 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; source?: { path?: string } }; + if (typeof e.pluginId !== 'string') return []; + // `source.path` is the marketplace source directory, which holds the + // manifest; the entry's own `version` is authoritative either way. + return [{ ref: e.pluginId, version: e.version ?? null, manifestPath: e.source?.path ?? 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 reports a version only for plugins that are actually installed — the + * `available` list carries none — 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..e49972114 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,6 +42,7 @@ const CLI_AGENT = { marketplaceName: 'switch-plugins', marketplaceSource: 'sandbox-quantum/switch', scope: 'user', + dialect: 'claude-code', }, hostDependency: { binaryNames: ['claude'] }, }, 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..c0ccd3a89 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, @@ -81,7 +87,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 +109,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 +138,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 +166,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, so we fall through and attempt the + // add — which is idempotent — rather than treating it 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 +199,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 +249,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 +275,67 @@ 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. + */ 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; + + 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/packages/core/src/agents/plugins/capabilities/mcp.ts b/dash/packages/core/src/agents/plugins/capabilities/mcp.ts index 8484414fd..9dc7ab748 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 = { 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; 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,25 @@ 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. Every key is emitted on every call: an + * override of `mcp_servers..url` replaces that server's whole table + * rather than merging into it, so a partial set would silently drop the + * fields left out. + */ + launchArgsForServer(server: McpServerRegistration): string[] { + const entries: Array<[string, string]> = []; + if (typeof server.url === 'string') entries.push(['url', server.url]); + if (typeof server.bearer_token_env_var === 'string') { + entries.push(['bearer_token_env_var', server.bearer_token_env_var]); + } + if (typeof server.command === 'string') entries.push(['command', server.command]); + 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/index.ts b/dash/packages/core/src/agents/plugins/index.ts index 2a185ac22..354387e4b 100644 --- a/dash/packages/core/src/agents/plugins/index.ts +++ b/dash/packages/core/src/agents/plugins/index.ts @@ -83,7 +83,12 @@ export type { 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/codex/index.ts b/dash/packages/plugins/src/agents/impl/codex/index.ts index 357e7b208..0f6e2de2f 100644 --- a/dash/packages/plugins/src/agents/impl/codex/index.ts +++ b/dash/packages/plugins/src/agents/impl/codex/index.ts @@ -5,6 +5,7 @@ import { homebrewOption, npmDependency, } from '@switchdash/core/agents/plugins/helpers'; +import { SWITCH_MARKETPLACE_SOURCE } from '../../../distribution'; import { buildCodexAutoApproveFlag } from './auto-approve'; import { buildCodexHookConfig } from './hooks'; import { icon } from './icon'; @@ -65,7 +66,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 } ); From c8e77a6749b247178efeb40cf891897203d41f71 Mon Sep 17 00:00:00 2001 From: Christian McDermott Date: Wed, 29 Jul 2026 15:14:08 -0400 Subject: [PATCH 21/51] fix(codex): track the agent's room from connect_to_room (CHOO-1436) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../agent-runtime/impl/local-agent-runtime.ts | 2 +- .../core/switch-setup/remote-switch-setup.ts | 5 +-- .../switch-setup-cli-dialect.test.ts | 6 ++-- .../switch-setup/switch-setup-cli-dialect.ts | 7 ++-- .../src/agents/impl/codex/hooks.test.ts | 33 +++++++++++++++-- .../plugins/src/agents/impl/codex/hooks.ts | 36 +++++++++++++++++-- 6 files changed, 76 insertions(+), 13 deletions(-) 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 dd22e4ce7..d86da530d 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,6 +4,7 @@ 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 type { AgentRuntimeProvider } from '@main/core/agent-runtime/types'; import { agentCredsSlug } from '@main/core/agents/agent-creds-slug'; import { localDependencyManager } from '@main/core/dependencies/dependency-managers'; @@ -23,7 +24,6 @@ import { providerOverrideSettings } from '@main/core/settings/provider-settings- 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 { switchMcpLaunchArgs } from '@main/core/agent-runtime/switch-mcp-launch-args'; import type { ResolvedShellProfile } from '@main/core/terminal-shell/types'; import { events } from '@main/lib/events'; import { runWithLogContext } from '@main/lib/log-context'; 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 d5c437f0b..f08bdbc17 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 @@ -127,8 +127,9 @@ export class RemoteSwitchSetupService { if (fromMarketplace) return fromMarketplace; const { stdout: pluginStdout } = await this.run(bin, ['plugin', 'list', '--json']); return ( - rules.parseAdvertisedVersions(parseJsonLoose(pluginStdout), marketplaceName).get(pluginName) ?? - null + rules + .parseAdvertisedVersions(parseJsonLoose(pluginStdout), marketplaceName) + .get(pluginName) ?? null ); } 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 index 6500036bd..e497de7f5 100644 --- 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 @@ -64,9 +64,9 @@ describe('codex dialect', () => { }); it('reports no advertised versions, since Codex only versions installed plugins', () => { - expect(rules.parseAdvertisedVersions(JSON.parse(CODEX_PLUGIN_LIST), 'switch-plugins').size).toBe( - 0 - ); + expect( + rules.parseAdvertisedVersions(JSON.parse(CODEX_PLUGIN_LIST), 'switch-plugins').size + ).toBe(0); }); it('looks for plugin manifests under .codex-plugin but marketplaces under .claude-plugin', () => { 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 index 792a7fa0f..7c453a8c9 100644 --- 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 @@ -97,7 +97,8 @@ const claudeCode: SwitchSetupCliRules = { ).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); + if (typeof p.name === 'string' && typeof p.version === 'string') + versions.set(p.name, p.version); } return versions; }, @@ -127,7 +128,9 @@ const codex: SwitchSetupCliRules = { if (typeof e.pluginId !== 'string') return []; // `source.path` is the marketplace source directory, which holds the // manifest; the entry's own `version` is authoritative either way. - return [{ ref: e.pluginId, version: e.version ?? null, manifestPath: e.source?.path ?? null }]; + return [ + { ref: e.pluginId, version: e.version ?? null, manifestPath: e.source?.path ?? null }, + ]; }); }, diff --git a/dash/packages/plugins/src/agents/impl/codex/hooks.test.ts b/dash/packages/plugins/src/agents/impl/codex/hooks.test.ts index d103c0ef0..c8d79e6b8 100644 --- a/dash/packages/plugins/src/agents/impl/codex/hooks.test.ts +++ b/dash/packages/plugins/src/agents/impl/codex/hooks.test.ts @@ -1,6 +1,11 @@ import type { PluginFs } from '@switchdash/core/agents/plugins'; import { describe, expect, it } from 'vitest'; -import { CODEX_CONFIG_PATH, CODEX_HOOKS_PATH, buildCodexHookConfig } from './hooks'; +import { + CODEX_CONFIG_PATH, + CODEX_HOOKS_PATH, + CODEX_ROOM_CONNECT_MATCHER, + buildCodexHookConfig, +} from './hooks'; function createMemoryFs(initial: Record = {}): PluginFs { const files = new Map(Object.entries(initial)); @@ -103,12 +108,36 @@ describe('buildCodexHookConfig install/read/delete', () => { const config = JSON.parse((await fs.read(CODEX_HOOKS_PATH))!) as { hooks: Record; }; - for (const key of ['Stop', 'PermissionRequest', 'SessionStart']) { + 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 Claude's connector 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(); diff --git a/dash/packages/plugins/src/agents/impl/codex/hooks.ts b/dash/packages/plugins/src/agents/impl/codex/hooks.ts index 5db78456e..b39486069 100644 --- a/dash/packages/plugins/src/agents/impl/codex/hooks.ts +++ b/dash/packages/plugins/src/agents/impl/codex/hooks.ts @@ -54,12 +54,35 @@ async function removeLegacyCodexNotify(fs: PluginFs): Promise { await fs.write(CODEX_CONFIG_PATH, toml.stringify(config)); } -function makeCodexSessionStartCommand(): string { - const post = makeHookPostCommand('session-start', 'stdin', {}); +/** + * A hook command that forwards Codex's event payload to switchdash. + * + * Codex documents stdin delivery, but its notify-style hooks have historically + * passed the payload as `$1`, so accept either rather than depending on which + * one a given event uses. + */ +function makeCodexStdinCommand(eventType: string): string { + const post = makeHookPostCommand(eventType, '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. Shared with Claude's connector, which emits the + * same event from the equivalent PostToolUse hook. + */ +const SWITCH_ROOM_CONNECT_EVENT = 'switch_room_connect'; + /** * Codex sends `{ type: 'agent-turn-complete' }` as its stop signal instead * of a plain 'stop' event type, and uses fixed `notification_type` values @@ -93,7 +116,14 @@ export function buildCodexHookConfig() { const base = buildNestedJsonHookConfig(CODEX_HOOKS_PATH, [ { hookKey: 'Stop', command: makeNotificationHookCommand('idle_prompt') }, { hookKey: 'PermissionRequest', command: makeNotificationHookCommand('permission_prompt') }, - { hookKey: 'SessionStart', command: makeCodexSessionStartCommand() }, + { hookKey: 'SessionStart', command: makeCodexStdinCommand('session-start') }, + // Matcher-scoped to the Switch connect tool; the rest are lifecycle events + // that carry no matcher. + { + hookKey: 'PostToolUse', + command: makeCodexStdinCommand(SWITCH_ROOM_CONNECT_EVENT), + matcher: CODEX_ROOM_CONNECT_MATCHER, + }, ]); return { From eb0eb0568a0047582a061a0ea3242aefbb0047ec Mon Sep 17 00:00:00 2001 From: Christian McDermott Date: Wed, 29 Jul 2026 17:24:42 -0400 Subject: [PATCH 22/51] fix(codex): post the real hook payload from the generated command (CHOO-1436) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ""` 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) --- .../src/agents/plugins/helpers/hooks.test.ts | 94 ++++++++++--- .../src/agents/impl/codex/hooks.test.ts | 130 +++++++++++++++++- .../plugins/src/agents/impl/codex/hooks.ts | 23 +--- 3 files changed, 206 insertions(+), 41 deletions(-) diff --git a/dash/packages/core/src/agents/plugins/helpers/hooks.test.ts b/dash/packages/core/src/agents/plugins/helpers/hooks.test.ts index 852f964ca..2b10f958c 100644 --- a/dash/packages/core/src/agents/plugins/helpers/hooks.test.ts +++ b/dash/packages/core/src/agents/plugins/helpers/hooks.test.ts @@ -1,4 +1,4 @@ -import { execFile } from 'node:child_process'; +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'; @@ -9,38 +9,69 @@ import { filterUserHooks, makeStdinHookCommand, SWITCHDASH_MARKER } from './hook const execFileAsync = promisify(execFile); /** - * Run a generated POSIX hook command under a real `sh` with `curl` stubbed out, - * and report the URL and token it would have posted to. The endpoint resolution - * is shell code, so asserting on the string alone would not catch a quoting or - * `sed` mistake — only executing it does. + * Run `sh -c command` with `stdin` delivered on fd 0 and then closed, mirroring + * how an agent host feeds a hook its event payload. `execFile` cannot do this — + * it has no stdin input option, so a command that reads fd 0 would hang. */ -async function resolveEndpoint( - env: Record -): 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/plugins/src/agents/impl/codex/hooks.test.ts b/dash/packages/plugins/src/agents/impl/codex/hooks.test.ts index c8d79e6b8..b09c1f94b 100644 --- a/dash/packages/plugins/src/agents/impl/codex/hooks.test.ts +++ b/dash/packages/plugins/src/agents/impl/codex/hooks.test.ts @@ -1,3 +1,8 @@ +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 { @@ -7,6 +12,67 @@ import { 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 { @@ -133,8 +199,8 @@ describe('buildCodexHookConfig install/read/delete', () => { 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 Claude's connector emits, so both providers - // land on one enricher path. + // 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'); }); @@ -214,3 +280,63 @@ describe('buildCodexHookConfig install/read/delete', () => { 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 b39486069..4afe16b3c 100644 --- a/dash/packages/plugins/src/agents/impl/codex/hooks.ts +++ b/dash/packages/plugins/src/agents/impl/codex/hooks.ts @@ -3,8 +3,8 @@ import type { CanonicalHookEvent, HookRegistration } from '@switchdash/core/agen import { buildNestedJsonHookConfig, defaultHookEventParser, - makeHookPostCommand, makeNotificationHookCommand, + makeStdinHookCommand, } from '@switchdash/core/agents/plugins/helpers'; import * as toml from 'smol-toml'; @@ -54,19 +54,6 @@ async function removeLegacyCodexNotify(fs: PluginFs): Promise { await fs.write(CODEX_CONFIG_PATH, toml.stringify(config)); } -/** - * A hook command that forwards Codex's event payload to switchdash. - * - * Codex documents stdin delivery, but its notify-style hooks have historically - * passed the payload as `$1`, so accept either rather than depending on which - * one a given event uses. - */ -function makeCodexStdinCommand(eventType: string): string { - const post = makeHookPostCommand(eventType, '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`, @@ -78,8 +65,8 @@ 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. Shared with Claude's connector, which emits the - * same event from the equivalent PostToolUse hook. + * 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'; @@ -116,12 +103,12 @@ export function buildCodexHookConfig() { const base = buildNestedJsonHookConfig(CODEX_HOOKS_PATH, [ { hookKey: 'Stop', command: makeNotificationHookCommand('idle_prompt') }, { hookKey: 'PermissionRequest', command: makeNotificationHookCommand('permission_prompt') }, - { hookKey: 'SessionStart', command: makeCodexStdinCommand('session-start') }, + { 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: makeCodexStdinCommand(SWITCH_ROOM_CONNECT_EVENT), + command: makeStdinHookCommand(SWITCH_ROOM_CONNECT_EVENT), matcher: CODEX_ROOM_CONNECT_MATCHER, }, ]); From 5b49ef0f11bf3364de789ca4990f926a221f14d4 Mon Sep 17 00:00:00 2001 From: Christian McDermott Date: Wed, 29 Jul 2026 17:31:57 -0400 Subject: [PATCH 23/51] fix(codex): give remote sessions the Switch MCP server (CHOO-1436) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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="/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) --- .../impl/ssh-agent-runtime.test.ts | 60 ++++++++++++++-- .../agent-runtime/impl/ssh-agent-runtime.ts | 34 +++++---- .../agent-runtime/switch-mcp-launch-args.ts | 14 ++-- .../agents/generate-agent-launch-spec.test.ts | 34 ++++++++- .../core/agents/generate-agent-launch-spec.ts | 11 ++- .../core/switch-rooms/switch-mcp-endpoint.ts | 39 ++++++++++ .../src/sidecar/agent-launch-spec.test.ts | 72 +++++++++++++++++++ .../src/sidecar/agent-launch-spec.ts | 45 ++++++++++-- .../src/sidecar/session-spawner.ts | 1 + 9 files changed, 275 insertions(+), 35 deletions(-) create mode 100644 dash/apps/switchdash-desktop/src/shared/core/switch-rooms/switch-mcp-endpoint.ts 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 0a4c1ce01..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 @@ -16,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 () => {})); @@ -67,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', () => ({ @@ -118,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; @@ -222,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(); @@ -280,6 +285,51 @@ describe('SshAgentRuntime', () => { ); }); + 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 b4722e8f6..574060139 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,6 +2,7 @@ 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 type { AgentRuntimeProvider } from '@main/core/agent-runtime/types'; import { agentCredsSlug } from '@main/core/agents/agent-creds-slug'; import { getAgentById } from '@main/core/agents/getAgentById'; @@ -430,17 +431,32 @@ 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); + 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, @@ -452,16 +468,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 remoteFs = createRemotePluginFs(this.fs); - const identityVars = - session.agentName && repoAgents - ? await repoAgents.readLaunchEnv(remoteFs, session.agentName) - : await readAgentSwitchEnvFromFs(remoteFs, agentCredsSlug(session), log); - const tmuxSessionName = this.tmux ? makeAgentTmuxSessionName(this.sessionId) : undefined; const cfg: AgentSessionConfig = { 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 index c7fdcdbd1..0f184734e 100644 --- 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 @@ -1,4 +1,5 @@ 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'; @@ -6,9 +7,6 @@ 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'; -/** Path appended to the agent-bridge endpoint to reach its MCP surface. */ -const SWITCH_MCP_PATH_SUFFIX = '/mcp/'; - /** * Launch arguments registering the Switch MCP server for this session, for * agents that must receive it on argv. @@ -20,6 +18,10 @@ const SWITCH_MCP_PATH_SUFFIX = '/mcp/'; * 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, @@ -28,13 +30,13 @@ export function switchMcpLaunchArgs( const buildArgs = plugin.behavior.mcp?.launchArgsForServer; if (!buildArgs) return []; - const endpoint = apiEndpoint?.trim().replace(/\/+$/, ''); - if (!endpoint) return []; + const url = switchMcpUrl(apiEndpoint); + if (url === null) return []; return buildArgs({ name: SWITCH_MCP_SERVER_NAME, transport: 'http', - url: `${endpoint}${SWITCH_MCP_PATH_SUFFIX}`, + url, bearer_token_env_var: SWITCH_MCP_TOKEN_ENV_VAR, }); } 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..70a442195 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,7 @@ vi.mock('@main/core/settings/provider-settings-service', () => ({ })); vi.mock('@main/core/dependencies/host-dependency-store', () => ({ hostDependencyStore: {} })); +import { SWITCH_API_ENDPOINT_PLACEHOLDER } from '@shared/core/switch-rooms/switch-mcp-endpoint'; import { generateAgentLaunchSpec } from './generate-agent-launch-spec'; const baseParams = { @@ -37,6 +45,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 +81,26 @@ 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: [] })); + }); }); 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/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/sidecar/agent-launch-spec.test.ts b/dash/apps/switchdash-desktop/src/sidecar/agent-launch-spec.test.ts index c26ab5ff8..944b7eb98 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,6 +46,7 @@ describe('materializeAgentCommand', () => { sessionId: 'c', initialPrompt: 'p', extraEnv: { SHARED: 'override', HOOK: 'x' }, + switchApiEndpoint: undefined, }); expect(cmd.env).toEqual({ BASE: '1', SHARED: 'override', HOOK: 'x' }); }); @@ -54,6 +57,7 @@ describe('materializeAgentCommand', () => { sessionId: 'c', initialPrompt: 'p', extraEnv: {}, + switchApiEndpoint: undefined, }) ).toThrow(SESSION_ID_PLACEHOLDER); }); @@ -64,7 +68,75 @@ describe('materializeAgentCommand', () => { sessionId: 'c', initialPrompt: 'p', extraEnv: {}, + switchApiEndpoint: undefined, }) ).toThrow(INITIAL_PROMPT_PLACEHOLDER); }); }); + +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(codexSpec(), { + sessionId: 's1', + initialPrompt: 'p', + extraEnv: {}, + switchApiEndpoint: undefined, + }) + ).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..32c7dfb84 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. * @@ -33,6 +38,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,16 +49,26 @@ 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 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 the session id or prompt placeholder is missing from the spec's + * argv — a spec that cannot carry them would silently spawn a session that + * never connects to the room, so we fail loud instead. The endpoint token is + * optional: only providers that receive their MCP server on argv emit one. + * + * 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, @@ -63,6 +81,19 @@ export function materializeAgentCommand( } } - const args = spec.args.map((arg) => substitutions[arg] ?? arg); + 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}`); + } + 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); From 3936ecd447ef7f75e8485bdcd36a95b22c4b4dfb Mon Sep 17 00:00:00 2001 From: Christian McDermott Date: Wed, 29 Jul 2026 17:33:58 -0400 Subject: [PATCH 24/51] refactor(codex): declare what argv MCP registration actually supports (CHOO-1436) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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..` 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) --- .../src/main/core/agents/onboard-agent.ts | 2 +- .../src/agents/plugins/capabilities/mcp.ts | 7 +++ .../src/agents/plugins/helpers/mcp.test.ts | 53 +++++++++++++++++++ .../core/src/agents/plugins/helpers/mcp.ts | 29 +++++++--- .../plugins/src/agents/impl/codex/index.ts | 2 +- 5 files changed, 83 insertions(+), 10 deletions(-) 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 87c6103eb..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; 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/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 db7c8642e..b5a775fa1 100644 --- a/dash/packages/core/src/agents/plugins/helpers/mcp.ts +++ b/dash/packages/core/src/agents/plugins/helpers/mcp.ts @@ -197,18 +197,31 @@ export function codexMcpAdapter(configPath = '.codex/config.toml') { serversKey: 'mcp_servers', /** * Codex takes config overrides as `-c =`, at higher - * precedence than any config file. Every key is emitted on every call: an - * override of `mcp_servers..url` replaces that server's whole table - * rather than merging into it, so a partial set would silently drop the - * fields left out. + * precedence than any config file. + * + * Only HTTP servers can be expressed this way. An override of + * `mcp_servers..url` replaces that server's whole table rather than + * merging into it, so every key a server needs has to be emitted together — + * which rules out a stdio server, whose `args` and `env` are arrays and + * tables rather than scalars. Rather than emit a partial table that would + * launch a subtly broken server, reject anything but an HTTP server. */ launchArgsForServer(server: McpServerRegistration): string[] { - const entries: Array<[string, string]> = []; - if (typeof server.url === 'string') entries.push(['url', server.url]); - if (typeof server.bearer_token_env_var === '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]); } - if (typeof server.command === 'string') entries.push(['command', server.command]); return entries.flatMap(([key, value]) => [ '-c', `mcp_servers.${server.name}.${key}=${JSON.stringify(value)}`, diff --git a/dash/packages/plugins/src/agents/impl/codex/index.ts b/dash/packages/plugins/src/agents/impl/codex/index.ts index 0f6e2de2f..409247d8e 100644 --- a/dash/packages/plugins/src/agents/impl/codex/index.ts +++ b/dash/packages/plugins/src/agents/impl/codex/index.ts @@ -28,7 +28,7 @@ export const plugin = definePlugin( hooks: { kind: 'config', scope: 'global', - supportedEvents: ['notification', 'stop', 'session'], + supportedEvents: ['notification', 'stop', 'session', 'tool-done'], }, hostDependency: npmDependency({ id: 'codex', From f5dc09459f3fec0d541103f78977a13ff9377885 Mon Sep 17 00:00:00 2001 From: Christian McDermott Date: Wed, 29 Jul 2026 17:36:43 -0400 Subject: [PATCH 25/51] fix(agent-hooks): read room ids from an MCP tool result of any shape (CHOO-1436) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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) --- .../core/agent-hooks/agent-hook-service.ts | 2 +- .../core/agent-hooks/event-enricher.test.ts | 142 ++++++++++++++++-- .../main/core/agent-hooks/event-enricher.ts | 94 ++++++++++-- .../src/sidecar/sidecar-runtime.ts | 2 +- 4 files changed, 210 insertions(+), 30 deletions(-) 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..735402ae0 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,162 @@ 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 a full MCP CallToolResult carrying both content and structuredContent', async () => { const parsed = await parseHookEvent( raw('switch_room_connect', { - tool_response: { room_id: 'room-1', agent_id: 'agent-1', name: 'Room One' }, + tool_response: { + content: [{ type: 'text', text: JSON.stringify(roomResult) }], + structuredContent: roomResult, + 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); + 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('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..dc6c56171 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,78 @@ 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 a + * string `room_id` — or null. The `room_id` probe is what tells the payload + * apart from an MCP envelope wrapping it, since both are plain objects. + */ +function asRoomResult(value: unknown): Record | null { + const record = asRecord(value); + return record && typeof record.room_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 +136,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 +151,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/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, From d821613fae672c0672019ba3375ea54921daef78 Mon Sep 17 00:00:00 2001 From: Christian McDermott Date: Wed, 29 Jul 2026 17:37:16 -0400 Subject: [PATCH 26/51] fix(switch-setup): drop the dead remote version probe, cover the codex dialect (CHOO-1436) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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) --- .../switch-setup/remote-switch-setup.test.ts | 267 ++++++++++++++++++ .../core/switch-setup/remote-switch-setup.ts | 19 +- .../switch-setup-cli-dialect.test.ts | 4 +- .../switch-setup/switch-setup-cli-dialect.ts | 16 +- .../switch-setup/switch-setup-service.test.ts | 201 ++++++++++++- .../core/switch-setup/switch-setup-service.ts | 3 +- 6 files changed, 479 insertions(+), 31 deletions(-) create mode 100644 dash/apps/switchdash-desktop/src/main/core/switch-setup/remote-switch-setup.test.ts 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..65c12e8f2 --- /dev/null +++ b/dash/apps/switchdash-desktop/src/main/core/switch-setup/remote-switch-setup.test.ts @@ -0,0 +1,267 @@ +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('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); + expect(calls()).toEqual([`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()).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/napoleon')); + + 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 f08bdbc17..b968d9fa9 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,5 +1,4 @@ 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'; @@ -7,7 +6,7 @@ import { log } from '@main/lib/logger'; import { getPlugin, listPlugins } from '../providers/plugin-registry'; import { cliRulesFor, type SwitchSetupCliRules } from './switch-setup-cli-dialect'; import type { SwitchSetupResult, SwitchSetupStatus } from './switch-setup-service'; -import { marketplaceMatchesSource } from './switch-setup-service'; +import { isNewerVersion, marketplaceMatchesSource } from './switch-setup-service'; const EXEC_TIMEOUT_MS = 120_000; @@ -56,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 @@ -121,15 +113,8 @@ export class RemoteSwitchSetupService { rules: SwitchSetupCliRules ): Promise { const { stdout } = await this.run(bin, ['plugin', 'marketplace', 'list', '--json']); - const fromMarketplace = rules - .parseAdvertisedVersions(parseJsonLoose(stdout), marketplaceName) - .get(pluginName); - if (fromMarketplace) return fromMarketplace; - const { stdout: pluginStdout } = await this.run(bin, ['plugin', 'list', '--json']); return ( - rules - .parseAdvertisedVersions(parseJsonLoose(pluginStdout), marketplaceName) - .get(pluginName) ?? null + rules.parseAdvertisedVersions(parseJsonLoose(stdout), marketplaceName).get(pluginName) ?? null ); } 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 index e497de7f5..d80ef6345 100644 --- 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 @@ -63,9 +63,9 @@ describe('codex dialect', () => { expect(rules.marketplaceRefreshArgs('m')).toEqual(['plugin', 'marketplace', 'upgrade', 'm']); }); - it('reports no advertised versions, since Codex only versions installed plugins', () => { + it("reports no advertised versions, since Codex's marketplace listing carries none", () => { expect( - rules.parseAdvertisedVersions(JSON.parse(CODEX_PLUGIN_LIST), 'switch-plugins').size + rules.parseAdvertisedVersions(JSON.parse(CODEX_MARKETPLACE_LIST), 'switch-plugins').size ).toBe(0); }); 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 index 7c453a8c9..be1f0a0ca 100644 --- 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 @@ -53,10 +53,11 @@ export type SwitchSetupCliRules = { parsePluginList(parsed: unknown): InstalledPlugin[]; parseMarketplaceList(parsed: unknown): RegisteredMarketplace[]; /** - * Versions the marketplace advertises, read from the CLI's own listing rather - * than from on-disk manifests (the remote driver has no cheap filesystem - * access). Keyed by plugin name. An empty map means "unknown", which callers - * must treat as "no update detected" rather than "up to date". + * 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; }; @@ -145,10 +146,9 @@ const codex: SwitchSetupCliRules = { }, /** - * Codex reports a version only for plugins that are actually installed — the - * `available` list carries none — 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". + * 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. 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 e49972114..ac9c4a45a 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 @@ -48,17 +48,36 @@ const CLI_AGENT = { }, }; -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'; +const CODEX_INSTALL_PATH = '/cache/codex/plugins/switch-connector-codex'; +const CODEX_MARKET_ROOT = '/cache/codex/marketplaces/switch-plugins'; + /** Default happy-path exec: installed 0.1.0, marketplace present. */ function execImpl(installedVersion: string | null) { return (_bin: string, args: string[] = []) => { @@ -94,6 +113,73 @@ 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(installedManifestVersion: string, advertisedVersion: string) { + return (path: string) => { + if (path === `${CODEX_INSTALL_PATH}/.codex-plugin/plugin.json`) { + return Promise.resolve(JSON.stringify({ version: installedManifestVersion })); + } + if (path === `${CODEX_MARKET_ROOT}/.claude-plugin/marketplace.json`) { + return Promise.resolve( + JSON.stringify({ + plugins: [{ name: 'switch-connector-codex', source: './connectors/codex-plugin' }], + }) + ); + } + if (path === `${CODEX_MARKET_ROOT}/connectors/codex-plugin/.codex-plugin/plugin.json`) { + return Promise.resolve(JSON.stringify({ version: advertisedVersion })); + } + return Promise.reject(new Error('ENOENT')); + }; +} + function readFileImpl(installedManifestVersion: string, advertisedVersion: string) { return (path: string) => { if (path === `${INSTALL_PATH}/.claude-plugin/plugin.json`) { @@ -122,7 +208,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(); @@ -326,3 +412,112 @@ 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('reads the object-wrapped listings and the .codex-plugin manifest', async () => { + // The CLI listing reports a stale 0.1.0; only a read of the install dir's + // `.codex-plugin/plugin.json` (Claude's lives under `.claude-plugin/`) finds + // the real 0.2.0, and every other path in the fixture is ENOENT. + mocks.exec.mockImplementation(codexExecImpl('0.1.0')); + mocks.readFile.mockImplementation(codexReadFileImpl('0.2.0', '0.3.0')); + + const status = await switchSetupService.getStatus('codex'); + + expect(status).toMatchObject({ + supported: true, + installed: true, + installedVersion: '0.2.0', + latestVersion: '0.3.0', + updateAvailable: true, + }); + }); + + it('reports not-installed when the object-wrapped list is empty', async () => { + mocks.exec.mockImplementation(codexExecImpl(null)); + mocks.readFile.mockImplementation(codexReadFileImpl('0.2.0', '0.3.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', '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('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); + expect(calls()).toEqual([`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()).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 c0ccd3a89..04cb3af90 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 @@ -62,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; From 1512648107957e083044b8912c6beee0b1a750cf Mon Sep 17 00:00:00 2001 From: Christian McDermott Date: Wed, 29 Jul 2026 17:37:46 -0400 Subject: [PATCH 27/51] docs: cover both agent connectors and backport the skill fixes (CHOO-1436) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CLAUDE.md | 42 +++++++++++++------ README.md | 18 +++++--- .../claude-code-plugin/skills/switch/SKILL.md | 4 +- .../add-agent-modal/agent-type-picker.tsx | 6 ++- 4 files changed, 48 insertions(+), 22 deletions(-) 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..985790794 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) | diff --git a/connectors/claude-code-plugin/skills/switch/SKILL.md b/connectors/claude-code-plugin/skills/switch/SKILL.md index 461249f65..35aa772c0 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, 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 @@ -583,7 +583,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/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, From 36d569f5c69ede12de816e9565ebdc68d325c4d1 Mon Sep 17 00:00:00 2001 From: Christian McDermott Date: Wed, 29 Jul 2026 17:51:35 -0400 Subject: [PATCH 28/51] feat(mcp): expose cancel_task, the one task tool agents were told to call (CHOO-1436) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- core/switch_core/bridges/agent/mcp/server.py | 21 +++++ .../bridges/agent/test_mcp_tool_surface.py | 78 +++++++++++++++++++ 2 files changed, 99 insertions(+) create mode 100644 core/tests/switch_core/bridges/agent/test_mcp_tool_surface.py diff --git a/core/switch_core/bridges/agent/mcp/server.py b/core/switch_core/bridges/agent/mcp/server.py index 77ca37709..2015f1e1f 100644 --- a/core/switch_core/bridges/agent/mcp/server.py +++ b/core/switch_core/bridges/agent/mcp/server.py @@ -758,6 +758,27 @@ 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) + + protocol = _get_protocol() + await protocol.cancel_task(agent_id, task_id, reason) + task = await protocol.get_task(agent_id, task_id) + return {"status": task.status, "reason": reason} + + @mcp.tool async def list_tasks( role: str | None = None, status: str | None = None, ctx: Any = None 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..a29d26d44 --- /dev/null +++ b/core/tests/switch_core/bridges/agent/test_mcp_tool_surface.py @@ -0,0 +1,78 @@ +"""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. Nothing tied those names to the server's actual +registrations, so `cancel_task` was documented in both skills and in the +in-room instructions for a long while without ever being exposed as an +`@mcp.tool` — it lived only on the HTTP surface. An agent following the +documentation called a tool that was not there. +""" + +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; the other five were exposed + without it, which left a lifecycle agents were told to drive but could + only get four-sixths of the way through. + """ + 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 trigger list in `connectors/*/skills/switch/SKILL.md`. A tool + renamed or dropped here without updating both skills fails this test + rather than surfacing as an agent calling into nothing. + """ + 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", + "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)}" + ) From e75a62e1ec15ca562a9f99971894bddfb169ce4d Mon Sep 17 00:00:00 2001 From: Christian McDermott Date: Wed, 29 Jul 2026 18:00:10 -0400 Subject: [PATCH 29/51] test(codex): a probe for the two hook behaviours we inferred (CHOO-1436) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- scripts/codex-hook-probe/mcp_probe.py | 32 ++++++ scripts/codex-hook-probe/run.sh | 141 ++++++++++++++++++++++++++ 2 files changed, 173 insertions(+) create mode 100644 scripts/codex-hook-probe/mcp_probe.py create mode 100755 scripts/codex-hook-probe/run.sh diff --git a/scripts/codex-hook-probe/mcp_probe.py b/scripts/codex-hook-probe/mcp_probe.py new file mode 100644 index 000000000..03323cfec --- /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`. What Codex then puts in the +PostToolUse hook's `tool_response` is the open question this probe answers. +""" + +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..5ac03cf59 --- /dev/null +++ b/scripts/codex-hook-probe/run.sh @@ -0,0 +1,141 @@ +#!/usr/bin/env bash +# +# Answer the two questions PR #79 could not settle from the Codex binary alone: +# +# 1. Does Codex deliver a hook's event payload on stdin, with no positional +# operands? Commit "post the real hook payload from the generated command" +# drops a `${1:-$(cat)}` fallback on the strength of `$SHELL -lc` and a +# `stdin_error` outcome found in the binary. If `$#` is 0 and stdin carries +# the JSON, that reasoning holds. +# +# 2. What shape does `tool_response` take for an MCP tool call? Claude Code +# unwraps the MCP result; if Codex forwards the `CallToolResult` envelope +# instead, the payload sits under `structuredContent` / `content[0].text`. +# The enricher handles either, but the answer belongs in the PR. +# +# 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:-}" + +cleanup() { + if [ "$KEEP" = "--keep" ]; then + echo + echo "Probe directory kept at: $PROBE_DIR" + else + rm -rf "$PROBE_DIR" + fi +} +trap cleanup EXIT + +mkdir -p "$CODEX_HOME" "$DUMPS" + +# Codex resolves credentials from CODEX_HOME, so the isolated home needs a copy. +if [ ! -r "$HOME/.codex/auth.json" ]; then + echo "error: no ~/.codex/auth.json — run 'codex login' first." >&2 + exit 1 +fi +cp "$HOME/.codex/auth.json" "$CODEX_HOME/auth.json" +chmod 600 "$CODEX_HOME/auth.json" + +# A hook command that records what Codex actually handed it: the operand count, +# the first operand, and stdin. Deliberately NOT the command switchdash +# generates — this measures the delivery mechanism, not our shell. +probe_cmd() { + printf 'printf "argc=%%s\\narg1=%%s\\n" "$#" "${1:-}" > %s/%s.meta; cat > %s/%s.stdin' \ + "$DUMPS" "$1" "$DUMPS" "$1" +} + +cat > "$CODEX_HOME/hooks.json" < "$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 + } + +echo "──────────────────────────────────────────────────────────────────────" +echo "Q1 Payload delivery — expect argc=0 and non-empty stdin" +echo "──────────────────────────────────────────────────────────────────────" +for event in session-start post-tool-use; do + echo + echo "[$event]" + if [ -r "$DUMPS/$event.meta" ]; then + 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 + +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 From ab87a3c6588ef4af4afb424834c0ac7ef01e9c67 Mon Sep 17 00:00:00 2001 From: Christian McDermott Date: Wed, 29 Jul 2026 18:20:53 -0400 Subject: [PATCH 30/51] test(codex): run the probe, pin what Codex actually sends (CHOO-1436) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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=`, 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: ''}], 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) --- .../core/agent-hooks/event-enricher.test.ts | 21 +++- scripts/codex-hook-probe/run.sh | 113 ++++++++++++++---- 2 files changed, 105 insertions(+), 29 deletions(-) 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 735402ae0..d674b0c4a 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 @@ -42,12 +42,25 @@ describe('parseHookEvent', () => { expect(parsed).toEqual(expectedRoom); }); - it('unwraps a full MCP CallToolResult carrying both content and structuredContent', async () => { + 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_name: 'mcp__switch__connect_to_room', tool_response: { - content: [{ type: 'text', text: JSON.stringify(roomResult) }], - structuredContent: roomResult, + content: [ + { + type: 'text', + text: '{"room_id":"room-1","agent_id":"agent-1","name":"Room One","participants":[]}', + }, + ], + structuredContent: { ...roomResult, participants: [] }, isError: false, }, }), @@ -55,7 +68,7 @@ describe('parseHookEvent', () => { log ); - expect(parsed).toEqual(expectedRoom); + expect(parsed).toEqual({ ...expectedRoom, ctx }); expect(log.warn).not.toHaveBeenCalled(); }); diff --git a/scripts/codex-hook-probe/run.sh b/scripts/codex-hook-probe/run.sh index 5ac03cf59..04143c6e2 100755 --- a/scripts/codex-hook-probe/run.sh +++ b/scripts/codex-hook-probe/run.sh @@ -27,10 +27,15 @@ 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() { - if [ "$KEEP" = "--keep" ]; then + if [ "$KEEP" = "--keep" ] || [ "$KEEP_ON_EXIT" = "1" ]; then echo - echo "Probe directory kept at: $PROBE_DIR" + echo "Probe directory: $PROBE_DIR" + echo " codex output: $PROBE_DIR/codex.log" + echo " hook dumps: $DUMPS" else rm -rf "$PROBE_DIR" fi @@ -47,34 +52,47 @@ fi cp "$HOME/.codex/auth.json" "$CODEX_HOME/auth.json" chmod 600 "$CODEX_HOME/auth.json" -# A hook command that records what Codex actually handed it: the operand count, -# the first operand, and stdin. Deliberately NOT the command switchdash +# 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. -probe_cmd() { - printf 'printf "argc=%%s\\narg1=%%s\\n" "$#" "${1:-}" > %s/%s.meta; cat > %s/%s.stdin' \ - "$DUMPS" "$1" "$DUMPS" "$1" -} +# +# 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 -cat > "$CODEX_HOME/hooks.json" < 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")}], + } + ], + } } -EOF -cat > "$CODEX_HOME/config.toml" <&2; exit 1; } +cat > "$CODEX_HOME/config.toml" <&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" @@ -110,6 +149,25 @@ for event in session-start post-tool-use; do 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?" @@ -139,3 +197,8 @@ 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 From 512db0614f84e1c34cced04d0a7c42db78625c62 Mon Sep 17 00:00:00 2001 From: Christian McDermott Date: Wed, 29 Jul 2026 20:40:46 -0400 Subject: [PATCH 31/51] fix(codex): remote auto-sessions could never launch (CHOO-1436) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../claude-code-plugin/skills/switch/SKILL.md | 23 +++-- .../codex-plugin/.codex-plugin/plugin.json | 2 +- connectors/codex-plugin/README.md | 12 ++- .../codex-plugin/skills/switch/SKILL.md | 26 +++--- .../agents/generate-agent-launch-spec.test.ts | 90 +++++++++++++++++++ .../src/sidecar/agent-launch-spec.test.ts | 21 +++-- .../src/sidecar/agent-launch-spec.ts | 28 +++--- 7 files changed, 159 insertions(+), 43 deletions(-) diff --git a/connectors/claude-code-plugin/skills/switch/SKILL.md b/connectors/claude-code-plugin/skills/switch/SKILL.md index 35aa772c0..f46ea5607 100644 --- a/connectors/claude-code-plugin/skills/switch/SKILL.md +++ b/connectors/claude-code-plugin/skills/switch/SKILL.md @@ -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 @@ -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**, diff --git a/connectors/codex-plugin/.codex-plugin/plugin.json b/connectors/codex-plugin/.codex-plugin/plugin.json index 606b82246..96708b3cb 100644 --- a/connectors/codex-plugin/.codex-plugin/plugin.json +++ b/connectors/codex-plugin/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "switch-connector-codex", - "version": "0.1.0", + "version": "0.1.1", "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 index 073f19db1..d62a28256 100644 --- a/connectors/codex-plugin/README.md +++ b/connectors/codex-plugin/README.md @@ -23,10 +23,14 @@ 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 per-agent credentials as -`-c mcp_servers.switch.*` overrides. The skill assumes those tools are -present under the `switch` server; if they are missing, the session was -launched without the Switch MCP config. +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 diff --git a/connectors/codex-plugin/skills/switch/SKILL.md b/connectors/codex-plugin/skills/switch/SKILL.md index 240a2ed1e..822aa9bad 100644 --- a/connectors/codex-plugin/skills/switch/SKILL.md +++ b/connectors/codex-plugin/skills/switch/SKILL.md @@ -37,9 +37,11 @@ was launched without the Switch MCP config; say so rather than guessing. (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` to see who else is in - the room, the room role each currently holds (if any), their `agent_type`, - and their task capabilities. +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 @@ -296,11 +298,13 @@ 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 local coding agent such as `codex` or `claude-code`: - `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 self-parenting and cycles); - `clear_parent=true` detaches it to top-level. + 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 @@ -309,7 +313,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. @@ -478,7 +484,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**, 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 70a442195..fd1edc44f 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 @@ -29,7 +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 = { @@ -104,3 +110,87 @@ describe('generateAgentLaunchSpec', () => { expect(buildCommand).toHaveBeenCalledWith(expect.objectContaining({ agentArgs: [] })); }); }); + +/** + * The suite above mocks `buildCommand`, which is what let a real defect through: + * Codex sets `sessionIdOnResumeOnly`, so its fresh-session argv carries no + * session-id token at all, and the watcher rejected every spec it produced. A + * mocked command builder cannot show that. These drive the real provider spec. + */ +describe('generateAgentLaunchSpec against real provider command builders', () => { + const CODEX_SPEC = { + autoApproveFlag: '-c approval_policy="never" --dangerously-bypass-hook-trust', + 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/sidecar/agent-launch-spec.test.ts b/dash/apps/switchdash-desktop/src/sidecar/agent-launch-spec.test.ts index 944b7eb98..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 @@ -51,15 +51,18 @@ describe('materializeAgentCommand', () => { expect(cmd.env).toEqual({ BASE: '1', SHARED: 'override', HOOK: 'x' }); }); - it('throws when the session-id token is missing', () => { - expect(() => - materializeAgentCommand(spec({ args: [INITIAL_PROMPT_PLACEHOLDER] }), { - sessionId: 'c', - initialPrompt: 'p', - extraEnv: {}, - switchApiEndpoint: undefined, - }) - ).toThrow(SESSION_ID_PLACEHOLDER); + 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', () => { 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 32c7dfb84..9c571c52d 100644 --- a/dash/apps/switchdash-desktop/src/sidecar/agent-launch-spec.ts +++ b/dash/apps/switchdash-desktop/src/sidecar/agent-launch-spec.ts @@ -19,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. */ @@ -52,10 +53,17 @@ export interface MaterializedAgentCommand { * 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 session id or prompt placeholder is missing from the spec's - * argv — a spec that cannot carry them would silently spawn a session that - * never connects to the room, so we fail loud instead. The endpoint token is - * optional: only providers that receive their MCP server on argv emit one. + * 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 any `__SWITCHDASH_` token survives substitution, so a provider that * grows a new placeholder cannot quietly launch an agent pointed at literal @@ -75,10 +83,8 @@ export function materializeAgentCommand( [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); From 3ab18411d8b3c994e591d74b040baff460e5774e Mon Sep 17 00:00:00 2001 From: Christian McDermott Date: Wed, 29 Jul 2026 20:46:21 -0400 Subject: [PATCH 32/51] fix(codex): correct the argv-override semantics and stop overstating updates (CHOO-1436) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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..` 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) --- .../codex-plugin/skills/switch/SKILL.md | 4 +- .../switch-setup-cli-dialect.test.ts | 9 ++++ .../switch-setup/switch-setup-cli-dialect.ts | 12 +++++- .../switch-setup/switch-setup-service.test.ts | 43 ++++++++++++------- .../core/src/agents/plugins/helpers/mcp.ts | 17 +++++--- scripts/codex-hook-probe/run.sh | 12 ++++-- 6 files changed, 70 insertions(+), 27 deletions(-) diff --git a/connectors/codex-plugin/skills/switch/SKILL.md b/connectors/codex-plugin/skills/switch/SKILL.md index 822aa9bad..68c099b22 100644 --- a/connectors/codex-plugin/skills/switch/SKILL.md +++ b/connectors/codex-plugin/skills/switch/SKILL.md @@ -105,7 +105,7 @@ variables (`SWITCH_API_ENDPOINT`, `SWITCH_AGENT_ID`, `SWITCH_API_TOKEN`). and an `mxc` URI. Download the bytes, then read the local file: ```bash - curl -sS -G "$SWITCH_API_ENDPOINT/agents/$SWITCH_AGENT_ID/rooms//media" \ + 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 @@ -117,7 +117,7 @@ variables (`SWITCH_API_ENDPOINT`, `SWITCH_AGENT_ID`, `SWITCH_API_TOKEN`). posted `event_id`: ```bash - curl -sS -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 "file=@/path/to/image.png" \ -F "caption=..." 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 index d80ef6345..77be89b78 100644 --- 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 @@ -49,6 +49,15 @@ describe('codex dialect', () => { 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' }, 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 index be1f0a0ca..0112d0830 100644 --- 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 @@ -125,8 +125,18 @@ const codex: SwitchSetupCliRules = { 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; source?: { path?: string } }; + 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 []; // `source.path` is the marketplace source directory, which holds the // manifest; the entry's own `version` is authoritative either way. return [ 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 ac9c4a45a..e08e52646 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 @@ -75,8 +75,13 @@ 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'; -const CODEX_INSTALL_PATH = '/cache/codex/plugins/switch-connector-codex'; -const CODEX_MARKET_ROOT = '/cache/codex/marketplaces/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) { @@ -161,10 +166,15 @@ function codexExecImpl(installedVersion: string | null) { }; } -function codexReadFileImpl(installedManifestVersion: string, advertisedVersion: string) { +function codexReadFileImpl(manifestVersion: string) { return (path: string) => { if (path === `${CODEX_INSTALL_PATH}/.codex-plugin/plugin.json`) { - return Promise.resolve(JSON.stringify({ version: installedManifestVersion })); + 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( @@ -173,9 +183,6 @@ function codexReadFileImpl(installedManifestVersion: string, advertisedVersion: }) ); } - if (path === `${CODEX_MARKET_ROOT}/connectors/codex-plugin/.codex-plugin/plugin.json`) { - return Promise.resolve(JSON.stringify({ version: advertisedVersion })); - } return Promise.reject(new Error('ENOENT')); }; } @@ -424,11 +431,12 @@ describe('switchSetupService with the codex dialect', () => { }); it('reads the object-wrapped listings and the .codex-plugin manifest', async () => { - // The CLI listing reports a stale 0.1.0; only a read of the install dir's - // `.codex-plugin/plugin.json` (Claude's lives under `.claude-plugin/`) finds - // the real 0.2.0, and every other path in the fixture is ENOENT. + // The CLI listing reports a stale 0.1.0; only a read of + // `.codex-plugin/plugin.json` finds the real 0.2.0. Claude's + // `.claude-plugin/plugin.json` sits alongside it 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', '0.3.0')); + mocks.readFile.mockImplementation(codexReadFileImpl('0.2.0')); const status = await switchSetupService.getStatus('codex'); @@ -436,14 +444,19 @@ describe('switchSetupService with the codex dialect', () => { supported: true, installed: true, installedVersion: '0.2.0', - latestVersion: '0.3.0', - updateAvailable: true, + // Codex points `source.path` at the marketplace source directory, so for + // a local-path marketplace the installed and advertised manifests are the + // same file and an update can never be detected. Remote is worse: it has + // no manifest to read at all and reports null. Codex connector updates + // are effectively install-time only until Codex advertises versions. + 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', '0.3.0')); + mocks.readFile.mockImplementation(codexReadFileImpl('0.2.0')); const status = await switchSetupService.getStatus('codex'); @@ -483,7 +496,7 @@ describe('switchSetupService with the codex dialect', () => { it('refreshes the marketplace with upgrade rather than update', async () => { mocks.exec.mockImplementation(codexExecImpl('0.1.0')); - mocks.readFile.mockImplementation(codexReadFileImpl('0.1.0', '0.1.0')); + mocks.readFile.mockImplementation(codexReadFileImpl('0.1.0')); const status = await switchSetupService.checkForUpdates('codex'); diff --git a/dash/packages/core/src/agents/plugins/helpers/mcp.ts b/dash/packages/core/src/agents/plugins/helpers/mcp.ts index b5a775fa1..14f8ff834 100644 --- a/dash/packages/core/src/agents/plugins/helpers/mcp.ts +++ b/dash/packages/core/src/agents/plugins/helpers/mcp.ts @@ -199,12 +199,17 @@ export function codexMcpAdapter(configPath = '.codex/config.toml') { * Codex takes config overrides as `-c =`, at higher * precedence than any config file. * - * Only HTTP servers can be expressed this way. An override of - * `mcp_servers..url` replaces that server's whole table rather than - * merging into it, so every key a server needs has to be emitted together — - * which rules out a stdio server, whose `args` and `env` are arrays and - * tables rather than scalars. Rather than emit a partial table that would - * launch a subtly broken server, reject anything but an HTTP server. + * 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) { diff --git a/scripts/codex-hook-probe/run.sh b/scripts/codex-hook-probe/run.sh index 04143c6e2..a7492f2c1 100755 --- a/scripts/codex-hook-probe/run.sh +++ b/scripts/codex-hook-probe/run.sh @@ -31,25 +31,31 @@ KEEP="${1:-}" # 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" + 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 +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 -cp "$HOME/.codex/auth.json" "$CODEX_HOME/auth.json" +( 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, From 9ac7397a098bc868bf99f387406b31dedf503c5f Mon Sep 17 00:00:00 2001 From: Christian McDermott Date: Thu, 30 Jul 2026 01:30:33 -0400 Subject: [PATCH 33/51] fix(codex): run switchdash's hooks for every session, not just auto-approving ones (CHOO-1436) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../resolve-agent-session-command.test.ts | 7 +++- .../agents/impl/codex/auto-approve.test.ts | 21 +++++----- .../src/agents/impl/codex/auto-approve.ts | 30 ++++++++++++--- .../src/agents/impl/codex/command.test.ts | 38 +++++++++++-------- .../plugins/src/agents/impl/codex/index.ts | 6 ++- 5 files changed, 69 insertions(+), 33 deletions(-) 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/packages/plugins/src/agents/impl/codex/auto-approve.test.ts b/dash/packages/plugins/src/agents/impl/codex/auto-approve.test.ts index 9901032af..fab815933 100644 --- a/dash/packages/plugins/src/agents/impl/codex/auto-approve.test.ts +++ b/dash/packages/plugins/src/agents/impl/codex/auto-approve.test.ts @@ -1,22 +1,22 @@ import { describe, expect, it } from 'vitest'; -import { buildCodexAutoApproveFlag } from './auto-approve'; +import { buildCodexAutoApproveFlag, CODEX_HOOK_TRUST_FLAG } from './auto-approve'; describe('buildCodexAutoApproveFlag', () => { it('defaults to full access + no approvals when unset', () => { expect(buildCodexAutoApproveFlag({})).toBe( - '-c approval_policy="never" -c sandbox_mode="danger-full-access" --dangerously-bypass-hook-trust' + '-c approval_policy="never" -c sandbox_mode="danger-full-access"' ); }); it('honors a CODEX_SANDBOX_MODE override', () => { expect(buildCodexAutoApproveFlag({ CODEX_SANDBOX_MODE: 'workspace-write' })).toBe( - '-c approval_policy="never" -c sandbox_mode="workspace-write" --dangerously-bypass-hook-trust' + '-c approval_policy="never" -c sandbox_mode="workspace-write"' ); }); it('honors a CODEX_APPROVAL_POLICY override', () => { expect(buildCodexAutoApproveFlag({ CODEX_APPROVAL_POLICY: 'on-request' })).toBe( - '-c approval_policy="on-request" -c sandbox_mode="danger-full-access" --dangerously-bypass-hook-trust' + '-c approval_policy="on-request" -c sandbox_mode="danger-full-access"' ); }); @@ -26,9 +26,7 @@ describe('buildCodexAutoApproveFlag', () => { CODEX_SANDBOX_MODE: 'read-only', CODEX_APPROVAL_POLICY: 'untrusted', }) - ).toBe( - '-c approval_policy="untrusted" -c sandbox_mode="read-only" --dangerously-bypass-hook-trust' - ); + ).toBe('-c approval_policy="untrusted" -c sandbox_mode="read-only"'); }); it('trims whitespace and treats a blank value as unset', () => { @@ -40,10 +38,13 @@ describe('buildCodexAutoApproveFlag', () => { ); }); - it('always keeps --dangerously-bypass-hook-trust (needed for the SessionStart hook)', () => { - expect(buildCodexAutoApproveFlag({ CODEX_SANDBOX_MODE: 'read-only' })).toContain( - '--dangerously-bypass-hook-trust' + it('no longer carries hook trust, which every session needs regardless', () => { + // Gating trust on auto-approve left a default agent running none of + // switchdash's hooks; it is a default arg now. See CODEX_HOOK_TRUST_FLAG. + expect(buildCodexAutoApproveFlag({ CODEX_SANDBOX_MODE: 'read-only' })).not.toContain( + CODEX_HOOK_TRUST_FLAG ); + expect(CODEX_HOOK_TRUST_FLAG).toBe('--dangerously-bypass-hook-trust'); }); it('throws on an unknown sandbox mode rather than silently widening access', () => { diff --git a/dash/packages/plugins/src/agents/impl/codex/auto-approve.ts b/dash/packages/plugins/src/agents/impl/codex/auto-approve.ts index 3095d3098..c45fda967 100644 --- a/dash/packages/plugins/src/agents/impl/codex/auto-approve.ts +++ b/dash/packages/plugins/src/agents/impl/codex/auto-approve.ts @@ -35,10 +35,9 @@ function resolveEnum( * Build Codex's auto-approve argument string, honoring the CODEX_SANDBOX_MODE * and CODEX_APPROVAL_POLICY overrides. * - * `--dangerously-bypass-hook-trust` is always included: it is orthogonal to the - * sandbox and lets Codex run switchdash's own SessionStart hook (which captures - * the rollout session id used for resume) without the interactive trust prompt - * that automated sessions cannot answer. + * Hook trust is not part of this: it is orthogonal to the sandbox and every + * session needs it, so it is a default arg rather than an auto-approve one. + * See {@link CODEX_HOOK_TRUST_FLAG}. */ export function buildCodexAutoApproveFlag(env: Record): string { const sandboxMode = resolveEnum( @@ -53,5 +52,26 @@ export function buildCodexAutoApproveFlag(env: Record:::"] 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'; diff --git a/dash/packages/plugins/src/agents/impl/codex/command.test.ts b/dash/packages/plugins/src/agents/impl/codex/command.test.ts index 1fd1b535a..3f0a67660 100644 --- a/dash/packages/plugins/src/agents/impl/codex/command.test.ts +++ b/dash/packages/plugins/src/agents/impl/codex/command.test.ts @@ -14,15 +14,14 @@ const base: CommandContext = { 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 default (unset) sandbox/approval flag, split the way buildStandardCommand // splits it on whitespace. -const AUTO_FLAGS = [ - '-c', - 'approval_policy="never"', - '-c', - 'sandbox_mode="danger-full-access"', - '--dangerously-bypass-hook-trust', -]; +const AUTO_FLAGS = ['-c', 'approval_policy="never"', '-c', 'sandbox_mode="danger-full-access"']; describe('codex buildCommand', () => { // Neutralize any ambient CODEX_SANDBOX_MODE / CODEX_APPROVAL_POLICY on the dev @@ -41,19 +40,26 @@ describe('codex buildCommand', () => { 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([...AUTO_FLAGS, 'Fix the bug']); + expect(cmd.args).toEqual([TRUST_FLAG, ...AUTO_FLAGS, 'Fix the bug']); }); - it('omits auto-approve args when autoApprove is false', () => { + it('omits auto-approve args when autoApprove is false, but keeps hook trust', () => { + // Gating hook trust on auto-approve left a default agent running none of + // switchdash's hooks, so room tracking and rollout-id capture were dead. const cmd = build({ ...base, initialPrompt: 'hello' }); - expect(cmd.args).not.toContain('--dangerously-bypass-hook-trust'); - expect(cmd.args).toEqual(['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[0]).toBe('resume'); - expect(cmd.args[1]).toBe('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'); }); @@ -67,14 +73,14 @@ describe('codex buildCommand', () => { }); // Regression guard on arg order: `resume ` must precede the -c flags, // and no positional prompt is appended on resume. - expect(cmd.args).toEqual(['resume', 'rollout-9', ...AUTO_FLAGS]); + 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(0, 2)).toEqual(['resume', '--last']); + expect(cmd.args.slice(1, 3)).toEqual(['resume', '--last']); }); it('rejects an invalid sandbox mode when the session actually auto-approves', () => { @@ -88,7 +94,7 @@ describe('codex buildCommand', () => { // The flag is unused on this path, so a typo in the env must not stop the // session from launching at all. vi.stubEnv('CODEX_SANDBOX_MODE', 'full'); - expect(build({ ...base, initialPrompt: 'hi' }).args).toEqual(['hi']); + expect(build({ ...base, initialPrompt: 'hi' }).args).toEqual([TRUST_FLAG, 'hi']); }); it('deduplicates the bypass-approvals-and-sandbox singleton flag', () => { diff --git a/dash/packages/plugins/src/agents/impl/codex/index.ts b/dash/packages/plugins/src/agents/impl/codex/index.ts index 409247d8e..a22317f67 100644 --- a/dash/packages/plugins/src/agents/impl/codex/index.ts +++ b/dash/packages/plugins/src/agents/impl/codex/index.ts @@ -6,7 +6,7 @@ import { npmDependency, } from '@switchdash/core/agents/plugins/helpers'; import { SWITCH_MARKETPLACE_SOURCE } from '../../../distribution'; -import { buildCodexAutoApproveFlag } from './auto-approve'; +import { buildCodexAutoApproveFlag, CODEX_HOOK_TRUST_FLAG } from './auto-approve'; import { buildCodexHookConfig } from './hooks'; import { icon } from './icon'; @@ -83,6 +83,10 @@ export const provider = registerPluginBehavior(plugin, { prompt: { buildCommand: (ctx) => buildStandardCommand(ctx, { + // Every session, not just auto-approving ones: Codex runs no hook it has + // no persisted trust entry for, and switchdash's room tracking and + // rollout-id capture are hooks. + defaultArgs: [CODEX_HOOK_TRUST_FLAG], // Resolved only when it will actually be used: an unrecognised // CODEX_SANDBOX_MODE is a hard error, and a session that never // auto-approves has no business failing to launch over it. From 875862d6a46a8f3ace61a6236ebe7ca15b5ef68d Mon Sep 17 00:00:00 2001 From: Christian McDermott Date: Thu, 30 Jul 2026 11:01:37 -0400 Subject: [PATCH 34/51] fix(agents): remove the per-agent token on delete for every provider (CHOO-1436) Writing `.switch/agents/.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) --- .../src/main/core/agents/deleteAgent.test.ts | 123 ++++++++++++++++++ .../src/main/core/agents/deleteAgent.ts | 5 + .../plugins/capabilities/repo-agents.ts | 5 +- .../src/agents/impl/claude/subagents.test.ts | 14 +- .../src/agents/impl/claude/subagents.ts | 1 - 5 files changed, 145 insertions(+), 3 deletions(-) create mode 100644 dash/apps/switchdash-desktop/src/main/core/agents/deleteAgent.test.ts 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..2340bad01 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,10 @@ 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. + await ctx.fs.delete(agentSettingsRelativePath(agent.name ?? agent.id)); await removeSwitchCredentials(agent.providerId, ctx.fs); } finally { ctx.close(); 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 a6f8bee37..5c7d5ae19 100644 --- a/dash/packages/core/src/agents/plugins/capabilities/repo-agents.ts +++ b/dash/packages/core/src/agents/plugins/capabilities/repo-agents.ts @@ -110,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/plugins/src/agents/impl/claude/subagents.test.ts b/dash/packages/plugins/src/agents/impl/claude/subagents.test.ts index 689ecc252..93463d95e 100644 --- a/dash/packages/plugins/src/agents/impl/claude/subagents.test.ts +++ b/dash/packages/plugins/src/agents/impl/claude/subagents.test.ts @@ -207,7 +207,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":{}}', @@ -218,4 +218,16 @@ describe('claudeRepoAgentsBehavior.removeLocal', () => { expect(await workspaceFs.exists(defRel('reviewer'))).toBe(false); expect(await workspaceFs.exists(settingsRel('reviewer'))).toBe(false); }); + + it('leaves the provider-neutral credentials to the caller, which removes them for every provider', async () => { + const neutralRel = path.join('.switch', 'agents', 'reviewer.json'); + const workspaceFs = fakeFs({ + [defRel('reviewer')]: '---\nname: reviewer\ndescription: x\n---\n', + [neutralRel]: '{"env":{}}', + }); + + 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 62bafa867..8dbbf76b6 100644 --- a/dash/packages/plugins/src/agents/impl/claude/subagents.ts +++ b/dash/packages/plugins/src/agents/impl/claude/subagents.ts @@ -497,7 +497,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)); }, }; From b379c031a57749cc20b800ae5cb8b57985219c32 Mon Sep 17 00:00:00 2001 From: Christian McDermott Date: Thu, 30 Jul 2026 11:01:51 -0400 Subject: [PATCH 35/51] fix(agents): move an agent's files when it is renamed (CHOO-1436) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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/.json` and, for a provider with repo-agent definitions, the definition the CLI launches against (`--agent `). 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) --- .../src/main/core/agents/renameAgent.test.ts | 159 ++++++++++++++++++ .../src/main/core/agents/renameAgent.ts | 57 ++++++- 2 files changed, 215 insertions(+), 1 deletion(-) create mode 100644 dash/apps/switchdash-desktop/src/main/core/agents/renameAgent.test.ts 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..b8f59daa5 --- /dev/null +++ b/dash/apps/switchdash-desktop/src/main/core/agents/renameAgent.test.ts @@ -0,0 +1,159 @@ +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; + } = { + row: undefined, + fs: fakeFs(), + repoAgents: { readDefinition, writeDefinition, removeLocal }, + }; + 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' })), +})); +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.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(); + }); +}); 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..348d4a55a 100644 --- a/dash/apps/switchdash-desktop/src/main/core/agents/renameAgent.ts +++ b/dash/apps/switchdash-desktop/src/main/core/agents/renameAgent.ts @@ -3,14 +3,17 @@ 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 { 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,6 +47,57 @@ async function moveSidecarToNewName(previous: Agent, renamed: Agent): Promise.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 async function renameAgent(params: RenameAgentParams): Promise { const previous = await getAgentById(params.agentId); const [row] = await db @@ -54,6 +108,7 @@ export async function renameAgent(params: RenameAgentParams): Promise Date: Thu, 30 Jul 2026 12:20:57 -0400 Subject: [PATCH 36/51] fix(codex): stop reporting a stale connector as up to date (CHOO-1436) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found while running the E2E procedure against Codex 0.146.0. Codex copies a plugin into a versioned cache — `/plugins/cache////` — 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) --- .../switch-setup-cli-dialect.test.ts | 6 +++- .../switch-setup/switch-setup-cli-dialect.ts | 13 +++++---- .../switch-setup/switch-setup-service.test.ts | 29 ++++++++++++------- 3 files changed, 32 insertions(+), 16 deletions(-) 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 index 77be89b78..ecb01bd1a 100644 --- 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 @@ -39,11 +39,15 @@ describe('codex dialect', () => { // 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: '/repo/connectors/codex-plugin', + manifestPath: null, }, ]); expect(cliRulesFor('claude-code').parsePluginList(JSON.parse(CODEX_PLUGIN_LIST))).toEqual([]); 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 index 0112d0830..501a274f3 100644 --- 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 @@ -137,11 +137,14 @@ const codex: SwitchSetupCliRules = { // 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 []; - // `source.path` is the marketplace source directory, which holds the - // manifest; the entry's own `version` is authoritative either way. - return [ - { ref: e.pluginId, version: e.version ?? null, manifestPath: e.source?.path ?? null }, - ]; + // 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 the thing we want. + return [{ ref: e.pluginId, version: e.version ?? null, manifestPath: null }]; }); }, 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 e08e52646..30215d371 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 @@ -430,11 +430,14 @@ describe('switchSetupService with the codex dialect', () => { mocks.resolveCommandPath.mockResolvedValue('/usr/bin/codex'); }); - it('reads the object-wrapped listings and the .codex-plugin manifest', async () => { - // The CLI listing reports a stale 0.1.0; only a read of - // `.codex-plugin/plugin.json` finds the real 0.2.0. Claude's - // `.claude-plugin/plugin.json` sits alongside it reporting 9.9.9, so a - // reader that went to the wrong manifest dir fails here. + 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')); @@ -443,12 +446,18 @@ describe('switchSetupService with the codex dialect', () => { 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', - // Codex points `source.path` at the marketplace source directory, so for - // a local-path marketplace the installed and advertised manifests are the - // same file and an update can never be detected. Remote is worse: it has - // no manifest to read at all and reports null. Codex connector updates - // are effectively install-time only until Codex advertises versions. latestVersion: '0.2.0', updateAvailable: false, }); From 1f73c485eb7a252784b615654a8b05b0031e9608 Mon Sep 17 00:00:00 2001 From: Christian McDermott Date: Thu, 30 Jul 2026 12:22:13 -0400 Subject: [PATCH 37/51] docs(connectors): fail the Claude skill's upload curl loudly too (CHOO-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) --- connectors/claude-code-plugin/skills/switch/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/connectors/claude-code-plugin/skills/switch/SKILL.md b/connectors/claude-code-plugin/skills/switch/SKILL.md index f46ea5607..b99c4eb6c 100644 --- a/connectors/claude-code-plugin/skills/switch/SKILL.md +++ b/connectors/claude-code-plugin/skills/switch/SKILL.md @@ -117,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`. From 47731784393d0b78ef068cf9ec44f7038c41ecfc Mon Sep 17 00:00:00 2001 From: Christian McDermott Date: Thu, 30 Jul 2026 14:55:45 -0400 Subject: [PATCH 38/51] docs(connectors): bump switch-connector to 0.3.1 for the skill accuracy fixes (CHOO-1436) Co-Authored-By: Claude Opus 5 (1M context) --- connectors/claude-code-plugin/.claude-plugin/plugin.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/connectors/claude-code-plugin/.claude-plugin/plugin.json b/connectors/claude-code-plugin/.claude-plugin/plugin.json index 75ef87bac..e94013ead 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.1", "description": "Connect Claude Code to a Switch platform instance as a participating agent" } From 48a027303d012e2750df3c7ba4b7726992e81f17 Mon Sep 17 00:00:00 2001 From: Christian McDermott Date: Thu, 30 Jul 2026 14:59:07 -0400 Subject: [PATCH 39/51] chore(core): sync uv.lock to switch-core 0.10.0 after rebase onto main (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) --- core/uv.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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" }, From 5ab0d30d6d704e390eeea12af8eb98c1938a4b3e Mon Sep 17 00:00:00 2001 From: Christian McDermott Date: Fri, 31 Jul 2026 12:04:57 -0400 Subject: [PATCH 40/51] refactor(codex): fix the sandbox flags and mirror them in the registry (CHOO-1436) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- dash/AGENTS.md | 35 +++++---- dash/agents/architecture/shared.md | 19 +++-- dash/agents/integrations/providers.md | 41 ++++++---- .../agents/generate-agent-launch-spec.test.ts | 3 +- .../providers/provider-argv-parity.test.ts | 52 +++++++++++++ .../core/providers/agent-provider-registry.ts | 17 +++- .../agents/impl/codex/auto-approve.test.ts | 61 --------------- .../src/agents/impl/codex/auto-approve.ts | 77 ------------------- .../src/agents/impl/codex/command.test.ts | 35 ++------- .../plugins/src/agents/impl/codex/hooks.ts | 21 +++++ .../plugins/src/agents/impl/codex/index.ts | 17 ++-- scripts/codex-hook-probe/mcp_probe.py | 4 +- scripts/codex-hook-probe/run.sh | 20 ++--- 13 files changed, 173 insertions(+), 229 deletions(-) create mode 100644 dash/apps/switchdash-desktop/src/main/core/providers/provider-argv-parity.test.ts delete mode 100644 dash/packages/plugins/src/agents/impl/codex/auto-approve.test.ts delete mode 100644 dash/packages/plugins/src/agents/impl/codex/auto-approve.ts diff --git a/dash/AGENTS.md b/dash/AGENTS.md index 7330a0e3a..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,19 +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`. - - `CODEX_SANDBOX_MODE` (`read-only` | `workspace-write` | `danger-full-access`) - and `CODEX_APPROVAL_POLICY` (`untrusted` | `on-request` | `never`) override the - `-c sandbox_mode=…` / `-c approval_policy=…` flags switchdash passes to Codex, - defaulting to `danger-full-access` / `never` for headless auto-sessions. An - unrecognized value is a hard error (it will not silently fall back to full - access); the value is resolved when an **auto-approving Codex session - launches**, not at app startup, so a bad value surfaces as a session-start - failure — and only for the sessions that would actually use the flag. Note - that on the resume/restore paths a spawn failure is logged rather than shown, - so a typo there reads as "the session did not come back". See - `packages/plugins/src/agents/impl/codex/auto-approve.ts`. + `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/agents/generate-agent-launch-spec.test.ts b/dash/apps/switchdash-desktop/src/main/core/agents/generate-agent-launch-spec.test.ts index fd1edc44f..55c622cbe 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 @@ -119,7 +119,8 @@ describe('generateAgentLaunchSpec', () => { */ describe('generateAgentLaunchSpec against real provider command builders', () => { const CODEX_SPEC = { - autoApproveFlag: '-c approval_policy="never" --dangerously-bypass-hook-trust', + 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/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/shared/core/providers/agent-provider-registry.ts b/dash/apps/switchdash-desktop/src/shared/core/providers/agent-provider-registry.ts index 4449cf6dc..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,6 +94,13 @@ 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', @@ -105,10 +112,12 @@ export const AGENT_PROVIDERS: AgentProviderDefinition[] = [ commands: ['codex'], versionArgs: ['--version'], cli: 'codex', - // No `autoApproveFlag` here: Codex's sandbox/approval args are configurable - // (CODEX_SANDBOX_MODE / CODEX_APPROVAL_POLICY) and are built by - // `buildCodexAutoApproveFlag` in the plugin. A literal copy in this metadata - // registry could only ever go stale. + // 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/packages/plugins/src/agents/impl/codex/auto-approve.test.ts b/dash/packages/plugins/src/agents/impl/codex/auto-approve.test.ts deleted file mode 100644 index fab815933..000000000 --- a/dash/packages/plugins/src/agents/impl/codex/auto-approve.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { buildCodexAutoApproveFlag, CODEX_HOOK_TRUST_FLAG } from './auto-approve'; - -describe('buildCodexAutoApproveFlag', () => { - it('defaults to full access + no approvals when unset', () => { - expect(buildCodexAutoApproveFlag({})).toBe( - '-c approval_policy="never" -c sandbox_mode="danger-full-access"' - ); - }); - - it('honors a CODEX_SANDBOX_MODE override', () => { - expect(buildCodexAutoApproveFlag({ CODEX_SANDBOX_MODE: 'workspace-write' })).toBe( - '-c approval_policy="never" -c sandbox_mode="workspace-write"' - ); - }); - - it('honors a CODEX_APPROVAL_POLICY override', () => { - expect(buildCodexAutoApproveFlag({ CODEX_APPROVAL_POLICY: 'on-request' })).toBe( - '-c approval_policy="on-request" -c sandbox_mode="danger-full-access"' - ); - }); - - it('honors both overrides together', () => { - expect( - buildCodexAutoApproveFlag({ - CODEX_SANDBOX_MODE: 'read-only', - CODEX_APPROVAL_POLICY: 'untrusted', - }) - ).toBe('-c approval_policy="untrusted" -c sandbox_mode="read-only"'); - }); - - it('trims whitespace and treats a blank value as unset', () => { - expect(buildCodexAutoApproveFlag({ CODEX_SANDBOX_MODE: ' workspace-write ' })).toContain( - 'sandbox_mode="workspace-write"' - ); - expect(buildCodexAutoApproveFlag({ CODEX_SANDBOX_MODE: ' ' })).toContain( - 'sandbox_mode="danger-full-access"' - ); - }); - - it('no longer carries hook trust, which every session needs regardless', () => { - // Gating trust on auto-approve left a default agent running none of - // switchdash's hooks; it is a default arg now. See CODEX_HOOK_TRUST_FLAG. - expect(buildCodexAutoApproveFlag({ CODEX_SANDBOX_MODE: 'read-only' })).not.toContain( - CODEX_HOOK_TRUST_FLAG - ); - expect(CODEX_HOOK_TRUST_FLAG).toBe('--dangerously-bypass-hook-trust'); - }); - - it('throws on an unknown sandbox mode rather than silently widening access', () => { - expect(() => buildCodexAutoApproveFlag({ CODEX_SANDBOX_MODE: 'full' })).toThrow( - /Invalid CODEX_SANDBOX_MODE="full"/ - ); - }); - - it('throws on an unknown approval policy', () => { - expect(() => buildCodexAutoApproveFlag({ CODEX_APPROVAL_POLICY: 'yolo' })).toThrow( - /Invalid CODEX_APPROVAL_POLICY="yolo"/ - ); - }); -}); diff --git a/dash/packages/plugins/src/agents/impl/codex/auto-approve.ts b/dash/packages/plugins/src/agents/impl/codex/auto-approve.ts deleted file mode 100644 index c45fda967..000000000 --- a/dash/packages/plugins/src/agents/impl/codex/auto-approve.ts +++ /dev/null @@ -1,77 +0,0 @@ -/** - * Codex's sandbox and approval behavior is configurable through two switchdash - * environment variables, documented in AGENTS.md: - * - CODEX_SANDBOX_MODE → Codex `-c sandbox_mode=...` - * - CODEX_APPROVAL_POLICY → Codex `-c approval_policy=...` - * - * When unset, both fall back to the automation defaults that headless - * auto-sessions require (full access, no approval prompts). An explicit but - * unrecognized value is a hard error rather than a silent fallback: a typo in - * CODEX_SANDBOX_MODE must never quietly widen the sandbox back to full access. - */ - -export const CODEX_SANDBOX_MODES = ['read-only', 'workspace-write', 'danger-full-access'] as const; -export const CODEX_APPROVAL_POLICIES = ['untrusted', 'on-request', 'never'] as const; - -export type CodexSandboxMode = (typeof CODEX_SANDBOX_MODES)[number]; -export type CodexApprovalPolicy = (typeof CODEX_APPROVAL_POLICIES)[number]; - -const DEFAULT_SANDBOX_MODE: CodexSandboxMode = 'danger-full-access'; -const DEFAULT_APPROVAL_POLICY: CodexApprovalPolicy = 'never'; - -function resolveEnum( - raw: string | undefined, - allowed: readonly T[], - fallback: T, - envVar: string -): T { - const value = raw?.trim(); - if (!value) return fallback; - if ((allowed as readonly string[]).includes(value)) return value as T; - throw new Error(`Invalid ${envVar}="${value}". Expected one of: ${allowed.join(', ')}.`); -} - -/** - * Build Codex's auto-approve argument string, honoring the CODEX_SANDBOX_MODE - * and CODEX_APPROVAL_POLICY overrides. - * - * Hook trust is not part of this: it is orthogonal to the sandbox and every - * session needs it, so it is a default arg rather than an auto-approve one. - * See {@link CODEX_HOOK_TRUST_FLAG}. - */ -export function buildCodexAutoApproveFlag(env: Record): string { - const sandboxMode = resolveEnum( - env.CODEX_SANDBOX_MODE, - CODEX_SANDBOX_MODES, - DEFAULT_SANDBOX_MODE, - 'CODEX_SANDBOX_MODE' - ); - const approvalPolicy = resolveEnum( - env.CODEX_APPROVAL_POLICY, - CODEX_APPROVAL_POLICIES, - DEFAULT_APPROVAL_POLICY, - 'CODEX_APPROVAL_POLICY' - ); - return `-c approval_policy="${approvalPolicy}" -c sandbox_mode="${sandboxMode}"`; -} - -/** - * 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'; diff --git a/dash/packages/plugins/src/agents/impl/codex/command.test.ts b/dash/packages/plugins/src/agents/impl/codex/command.test.ts index 3f0a67660..a9fce255d 100644 --- a/dash/packages/plugins/src/agents/impl/codex/command.test.ts +++ b/dash/packages/plugins/src/agents/impl/codex/command.test.ts @@ -1,5 +1,5 @@ import type { CommandContext } from '@switchdash/core/agents/plugins'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { provider } from './index'; function build(ctx: CommandContext) { @@ -19,21 +19,11 @@ const base: CommandContext = { // rollout id. const TRUST_FLAG = '--dangerously-bypass-hook-trust'; -// The default (unset) sandbox/approval flag, split the way buildStandardCommand -// splits it on whitespace. +// 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', () => { - // Neutralize any ambient CODEX_SANDBOX_MODE / CODEX_APPROVAL_POLICY on the dev - // machine so the auto-approve flag is deterministic (blank → defaults). - beforeEach(() => { - vi.stubEnv('CODEX_SANDBOX_MODE', ''); - vi.stubEnv('CODEX_APPROVAL_POLICY', ''); - }); - afterEach(() => { - vi.unstubAllEnvs(); - }); - it('starts a fresh session with auto-approve flags then the positional prompt', () => { const cmd = build({ ...base, autoApprove: true, initialPrompt: 'Fix the bug' }); @@ -44,8 +34,9 @@ describe('codex buildCommand', () => { }); it('omits auto-approve args when autoApprove is false, but keeps hook trust', () => { - // Gating hook trust on auto-approve left a default agent running none of - // switchdash's hooks, so room tracking and rollout-id capture were dead. + // 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']); @@ -83,20 +74,6 @@ describe('codex buildCommand', () => { expect(cmd.args.slice(1, 3)).toEqual(['resume', '--last']); }); - it('rejects an invalid sandbox mode when the session actually auto-approves', () => { - vi.stubEnv('CODEX_SANDBOX_MODE', 'full'); - expect(() => build({ ...base, autoApprove: true, initialPrompt: 'hi' })).toThrow( - /Invalid CODEX_SANDBOX_MODE="full"/ - ); - }); - - it('does not resolve the sandbox env for a session that never auto-approves', () => { - // The flag is unused on this path, so a typo in the env must not stop the - // session from launching at all. - vi.stubEnv('CODEX_SANDBOX_MODE', 'full'); - expect(build({ ...base, initialPrompt: 'hi' }).args).toEqual([TRUST_FLAG, 'hi']); - }); - it('deduplicates the bypass-approvals-and-sandbox singleton flag', () => { const cmd = build({ ...base, diff --git a/dash/packages/plugins/src/agents/impl/codex/hooks.ts b/dash/packages/plugins/src/agents/impl/codex/hooks.ts index 4afe16b3c..1fcc4cbd5 100644 --- a/dash/packages/plugins/src/agents/impl/codex/hooks.ts +++ b/dash/packages/plugins/src/agents/impl/codex/hooks.ts @@ -11,6 +11,27 @@ import * as toml from 'smol-toml'; export const CODEX_HOOKS_PATH = '.codex/hooks.json'; 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', '-c', diff --git a/dash/packages/plugins/src/agents/impl/codex/index.ts b/dash/packages/plugins/src/agents/impl/codex/index.ts index a22317f67..75216d7e3 100644 --- a/dash/packages/plugins/src/agents/impl/codex/index.ts +++ b/dash/packages/plugins/src/agents/impl/codex/index.ts @@ -6,8 +6,7 @@ import { npmDependency, } from '@switchdash/core/agents/plugins/helpers'; import { SWITCH_MARKETPLACE_SOURCE } from '../../../distribution'; -import { buildCodexAutoApproveFlag, CODEX_HOOK_TRUST_FLAG } from './auto-approve'; -import { buildCodexHookConfig } from './hooks'; +import { buildCodexHookConfig, CODEX_HOOK_TRUST_FLAG } from './hooks'; import { icon } from './icon'; export const plugin = definePlugin( @@ -83,14 +82,14 @@ export const provider = registerPluginBehavior(plugin, { prompt: { buildCommand: (ctx) => buildStandardCommand(ctx, { - // Every session, not just auto-approving ones: Codex runs no hook it has - // no persisted trust entry for, and switchdash's room tracking and - // rollout-id capture are hooks. + // Every session, not just auto-approving ones. See the flag's docblock. defaultArgs: [CODEX_HOOK_TRUST_FLAG], - // Resolved only when it will actually be used: an unrecognised - // CODEX_SANDBOX_MODE is a hard error, and a session that never - // auto-approves has no business failing to launch over it. - autoApproveFlag: ctx.autoApprove ? buildCodexAutoApproveFlag(process.env) : '', + // 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/scripts/codex-hook-probe/mcp_probe.py b/scripts/codex-hook-probe/mcp_probe.py index 03323cfec..d1d5b08c4 100644 --- a/scripts/codex-hook-probe/mcp_probe.py +++ b/scripts/codex-hook-probe/mcp_probe.py @@ -2,8 +2,8 @@ `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`. What Codex then puts in the -PostToolUse hook's `tool_response` is the open question this probe answers. +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 diff --git a/scripts/codex-hook-probe/run.sh b/scripts/codex-hook-probe/run.sh index a7492f2c1..4f2cbdc2e 100755 --- a/scripts/codex-hook-probe/run.sh +++ b/scripts/codex-hook-probe/run.sh @@ -1,17 +1,16 @@ #!/usr/bin/env bash # -# Answer the two questions PR #79 could not settle from the Codex binary alone: +# 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? Commit "post the real hook payload from the generated command" -# drops a `${1:-$(cat)}` fallback on the strength of `$SHELL -lc` and a -# `stdin_error` outcome found in the binary. If `$#` is 0 and stdin carries -# the JSON, that reasoning holds. +# 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; if Codex forwards the `CallToolResult` envelope -# instead, the payload sits under `structuredContent` / `content[0].text`. -# The enricher handles either, but the answer belongs in the PR. +# 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 @@ -111,8 +110,9 @@ echo # `--dangerously-bypass-hook-trust` is required: Codex persists a `trusted_hash` # per hook and silently skips any it has not been told to trust, so without it # the probe reports "HOOK DID NOT FIRE" for reasons that have nothing to do with -# what it is measuring. switchdash passes the same flag (see -# `buildCodexAutoApproveFlag`). +# what it is measuring. switchdash passes the same flag on every Codex session +# (see `CODEX_HOOK_TRUST_FLAG` in +# dash/packages/plugins/src/agents/impl/codex/hooks.ts). CODEX_HOME="$CODEX_HOME" codex exec \ --dangerously-bypass-approvals-and-sandbox \ --dangerously-bypass-hook-trust \ From d1e88ec459b44e7688fb3114eb9d701db783ad0d Mon Sep 17 00:00:00 2001 From: Christian McDermott Date: Fri, 31 Jul 2026 12:05:17 -0400 Subject: [PATCH 41/51] fix(agents): reject an agent name already taken in the location (CHOO-1436) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Everything switchdash provisions per agent is keyed by the agent's name, not its id: Switch credentials at `.switch/agents/.json` and the provider definition at `.claude/agents/.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) --- .../src/main/core/agents/add-agent.test.ts | 22 +++++++- .../src/main/core/agents/add-agent.ts | 11 +++- .../src/main/core/agents/agent-name-taken.ts | 30 +++++++++++ .../src/main/core/agents/renameAgent.test.ts | 51 ++++++++++++++++++- .../src/main/core/agents/renameAgent.ts | 29 +++++++++-- .../add-agent-modal/add-agent-modal.tsx | 3 +- 6 files changed, 138 insertions(+), 8 deletions(-) create mode 100644 dash/apps/switchdash-desktop/src/main/core/agents/agent-name-taken.ts 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 index 4608b1225..8440b1b41 100644 --- 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 @@ -24,13 +24,19 @@ function fakeFs(seed: Record = {}): PluginFs { // 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 } = { + 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', @@ -56,7 +62,9 @@ vi.mock('@main/core/switch-servers/servers-store', () => ({ })); 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 () => {}) }, @@ -93,6 +101,7 @@ function credsOf(fs: PluginFs, slug: string): Promise> { 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' }); @@ -156,4 +165,15 @@ describe('addAgent', () => { 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 1d033e24a..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,6 +10,7 @@ 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'; @@ -68,6 +69,14 @@ 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, 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/renameAgent.test.ts b/dash/apps/switchdash-desktop/src/main/core/agents/renameAgent.test.ts index b8f59daa5..d8b14b39e 100644 --- a/dash/apps/switchdash-desktop/src/main/core/agents/renameAgent.test.ts +++ b/dash/apps/switchdash-desktop/src/main/core/agents/renameAgent.test.ts @@ -27,10 +27,12 @@ const h = vi.hoisted(() => { 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 }; }); @@ -46,7 +48,15 @@ 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' })), + 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 () => {}) })); @@ -76,6 +86,7 @@ const CREDS = JSON.stringify({ 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, @@ -156,4 +167,42 @@ describe('renameAgent', () => { 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 348d4a55a..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,3 +1,4 @@ +import { type Result, err, ok } from '@switchdash/shared'; import { eq, sql } from 'drizzle-orm'; import { agentSidecarTmuxName, @@ -9,6 +10,7 @@ import { agents } from '@main/db/schema'; import { log } from '@main/lib/logger'; import type { Agent, RenameAgentParams } from '@shared/core/agents/agents'; 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'; @@ -98,18 +100,37 @@ async function moveProvisionedFiles(previous: Agent, renamed: Agent): Promise { +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/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 3d74d4de3..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 @@ -429,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; From 8fe737f2bcbd45d2408c47ecca3a77eb670d11f2 Mon Sep 17 00:00:00 2001 From: Christian McDermott Date: Fri, 31 Jul 2026 12:05:17 -0400 Subject: [PATCH 42/51] fix(fs): make PluginFs.delete fail loud on anything but a missing file (CHOO-1436) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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) --- .../agent-runtime/impl/remote-plugin-fs.ts | 3 +- .../src/main/core/agents/deleteAgent.ts | 12 +++- .../src/main/core/fs/assert-removed.ts | 21 +++++++ .../src/main/core/providers/plugin-fs.test.ts | 63 +++++++++++++++++++ .../src/main/core/providers/plugin-fs.ts | 14 ++++- .../core/providers/remote-plugin-fs.test.ts | 18 +++++- .../main/core/providers/remote-plugin-fs.ts | 4 +- 7 files changed, 127 insertions(+), 8 deletions(-) create mode 100644 dash/apps/switchdash-desktop/src/main/core/fs/assert-removed.ts create mode 100644 dash/apps/switchdash-desktop/src/main/core/providers/plugin-fs.test.ts 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/agents/deleteAgent.ts b/dash/apps/switchdash-desktop/src/main/core/agents/deleteAgent.ts index 2340bad01..5f329c9f0 100644 --- a/dash/apps/switchdash-desktop/src/main/core/agents/deleteAgent.ts +++ b/dash/apps/switchdash-desktop/src/main/core/agents/deleteAgent.ts @@ -84,7 +84,17 @@ 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. - await ctx.fs.delete(agentSettingsRelativePath(agent.name ?? agent.id)); + // + // 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/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/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 { From 32374fba4ae11f625a6d0039c25e0876312dfb1a Mon Sep 17 00:00:00 2001 From: Christian McDermott Date: Fri, 31 Jul 2026 12:05:36 -0400 Subject: [PATCH 43/51] fix(switch-setup): repair the marketplace before a remove-then-add update (CHOO-1436) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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) --- .../switch-setup/remote-switch-setup.test.ts | 63 +++++++++++++- .../core/switch-setup/remote-switch-setup.ts | 17 ++++ .../switch-setup/switch-setup-service.test.ts | 84 ++++++++++++++++++- .../core/switch-setup/switch-setup-service.ts | 15 ++++ 4 files changed, 173 insertions(+), 6 deletions(-) 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 index 65c12e8f2..414661bfb 100644 --- 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 @@ -210,6 +210,57 @@ describe('RemoteSwitchSetupService.getStatus', () => { }); 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')); @@ -217,7 +268,13 @@ describe('RemoteSwitchSetupService.update', () => { const result = await service.update('codex'); expect(result.success).toBe(true); - expect(calls()).toEqual([`plugin remove ${CODEX_REF}`, `plugin add ${CODEX_REF}`]); + // 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 () => { @@ -233,7 +290,7 @@ describe('RemoteSwitchSetupService.update', () => { const service = await getRemoteSwitchSetupService(SSH_HOST); const result = await service.update('codex'); - expect(calls()).toEqual([`plugin remove ${CODEX_REF}`, `plugin add ${CODEX_REF}`]); + expect(calls().slice(-2)).toEqual([`plugin remove ${CODEX_REF}`, `plugin add ${CODEX_REF}`]); expect(result).toEqual({ success: false, message: @@ -255,7 +312,7 @@ describe('RemoteSwitchSetupService.checkForUpdates', () => { }); it('re-points a same-named marketplace registered against a stale source', async () => { - mocks.exec.mockImplementation(codexExecImpl('sandbox-quantum/napoleon')); + mocks.exec.mockImplementation(codexExecImpl('sandbox-quantum/switch-legacy')); const service = await getRemoteSwitchSetupService(SSH_HOST); const status = await service.checkForUpdates('codex'); 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 b968d9fa9..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 @@ -235,11 +235,28 @@ export class RemoteSwitchSetupService { : { 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, 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); 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 30215d371..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 @@ -306,7 +306,7 @@ describe('switchSetupService.checkForUpdates', () => { { name: 'switch-plugins', source: 'github', - repo: 'sandbox-quantum/napoleon', + repo: 'sandbox-quantum/switch-legacy', installLocation: MARKET_LOCATION, }, ]), @@ -514,13 +514,91 @@ describe('switchSetupService with the codex dialect', () => { 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); - expect(calls()).toEqual([`plugin remove ${CODEX_REF}`, `plugin add ${CODEX_REF}`]); + // 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 () => { @@ -535,7 +613,7 @@ describe('switchSetupService with the codex dialect', () => { const result = await switchSetupService.update('codex'); - expect(calls()).toEqual([`plugin remove ${CODEX_REF}`, `plugin add ${CODEX_REF}`]); + expect(calls().slice(-2)).toEqual([`plugin remove ${CODEX_REF}`, `plugin add ${CODEX_REF}`]); expect(result).toEqual({ success: false, message: 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 04cb3af90..e40fbe220 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 @@ -298,6 +298,10 @@ class SwitchSetupService { * (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); @@ -305,6 +309,17 @@ class SwitchSetupService { return { success: false, message: 'Switch setup is not supported for this agent.' }; 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); From 8da1e85c38c8e60ab58a4a799136f54e62368dec Mon Sep 17 00:00:00 2001 From: Christian McDermott Date: Fri, 31 Jul 2026 12:05:36 -0400 Subject: [PATCH 44/51] fix(switch-setup): disclose when a connector update cannot be detected (CHOO-1436) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../remote-hosts/remote-host-detail.tsx | 22 +++++++++++++---- .../settings/agents-page/SwitchSetupCard.tsx | 16 +++++++++++++ .../renderer/lib/stores/use-switch-setup.ts | 8 +++++++ .../core/switch-setup/update-check.test.ts | 24 +++++++++++++++++++ .../shared/core/switch-setup/update-check.ts | 21 ++++++++++++++++ 5 files changed, 86 insertions(+), 5 deletions(-) create mode 100644 dash/apps/switchdash-desktop/src/shared/core/switch-setup/update-check.test.ts create mode 100644 dash/apps/switchdash-desktop/src/shared/core/switch-setup/update-check.ts diff --git a/dash/apps/switchdash-desktop/src/renderer/features/remote-hosts/remote-host-detail.tsx b/dash/apps/switchdash-desktop/src/renderer/features/remote-hosts/remote-host-detail.tsx index 0deee5c38..c1d6268db 100644 --- a/dash/apps/switchdash-desktop/src/renderer/features/remote-hosts/remote-host-detail.tsx +++ b/dash/apps/switchdash-desktop/src/renderer/features/remote-hosts/remote-host-detail.tsx @@ -16,6 +16,7 @@ import { Spinner } from '@renderer/lib/ui/spinner'; import { log } from '@renderer/utils/logger'; import { cn } from '@renderer/utils/utils'; import { sshConnectionEventChannel } from '@shared/core/ssh/sshEvents'; +import { updateCheckUnavailable } from '@shared/core/switch-setup/update-check'; import { GhAuthPanel } from './gh-auth-panel'; import { hostSetupQueryKey } from './query-keys'; @@ -502,12 +503,19 @@ function AgentTypeRow({ ? 'partial' : 'ready'; + // Not "no update available": this agent type advertises no versions on a + // remote host, so `updateAvailable` is structurally always false and saying + // nothing would read as "current". + const currencyUnknown = plugin !== undefined && updateCheckUnavailable(plugin); + const pluginStepLabel = plugin?.installed && plugin.updateAvailable ? 'Plugin · update available' - : plugin?.installed - ? `Plugin ${plugin.installedVersion ?? ''}`.trim() - : 'Switch plugin'; + : plugin?.installed && currencyUnknown + ? `Plugin ${plugin.installedVersion ?? ''} · updates not detectable`.trim() + : plugin?.installed + ? `Plugin ${plugin.installedVersion ?? ''}`.trim() + : 'Switch plugin'; const steps: Step[] = [ { @@ -562,14 +570,18 @@ function AgentTypeRow({ {checkUpdates.isPending ? 'Checking…' : 'Check for updates'} )} - {cliInstalled && plugin?.installed && plugin.updateAvailable && ( + {cliInstalled && plugin?.installed && (plugin.updateAvailable || currencyUnknown) && ( )} diff --git a/dash/apps/switchdash-desktop/src/renderer/features/settings/agents-page/SwitchSetupCard.tsx b/dash/apps/switchdash-desktop/src/renderer/features/settings/agents-page/SwitchSetupCard.tsx index c7975c45f..00319f78a 100644 --- a/dash/apps/switchdash-desktop/src/renderer/features/settings/agents-page/SwitchSetupCard.tsx +++ b/dash/apps/switchdash-desktop/src/renderer/features/settings/agents-page/SwitchSetupCard.tsx @@ -3,6 +3,7 @@ import { useSwitchSetup } from '@renderer/lib/stores/use-switch-setup'; import { Button } from '@renderer/lib/ui/button'; import { Field } from '@renderer/lib/ui/field'; import { Label } from '@renderer/lib/ui/label'; +import { updateCheckUnavailable } from '@shared/core/switch-setup/update-check'; import { InstalledBadge, InstallingBadge, @@ -34,6 +35,8 @@ export function SwitchSetupCard({ agentId }: { agentId: string }) { if (isLoading || !status?.supported) return null; const busy = isInstalling || isUpdating || isUninstalling || isChecking; + // Distinct from "no update available": there is nothing to compare against. + const currencyUnknown = updateCheckUnavailable(status); const badge = isInstalling ? ( @@ -89,6 +92,13 @@ export function SwitchSetupCard({ agentId }: { agentId: string }) { )} )} + {/* `updateAvailable` can never go true here, so without this the + plugin could only be refreshed by uninstalling first. */} + {currencyUnknown && !status.updateAvailable && ( + + )} @@ -101,6 +111,12 @@ export function SwitchSetupCard({ agentId }: { agentId: string }) { Couldn't refresh the plugin marketplace — showing cached status. {status.refreshError}

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

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

+ )}

Connects this agent to a Switch instance. Credentials are managed when you add the agent to a Switch server. diff --git a/dash/apps/switchdash-desktop/src/renderer/lib/stores/use-switch-setup.ts b/dash/apps/switchdash-desktop/src/renderer/lib/stores/use-switch-setup.ts index 95a64a6ba..ebc75c2cf 100644 --- a/dash/apps/switchdash-desktop/src/renderer/lib/stores/use-switch-setup.ts +++ b/dash/apps/switchdash-desktop/src/renderer/lib/stores/use-switch-setup.ts @@ -1,6 +1,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { toast } from '@renderer/lib/hooks/use-toast'; import { rpc } from '@renderer/lib/ipc'; +import { updateCheckUnavailable } from '@shared/core/switch-setup/update-check'; function switchSetupQueryKey(agentId: string) { return ['switch-setup', agentId] as const; @@ -49,6 +50,13 @@ export function useSwitchSetup(agentId: string) { description: status.refreshError, variant: 'destructive', }); + } else if (updateCheckUnavailable(status)) { + // No advertised version to compare against, so the refresh proved + // nothing. Saying "up to date" here would assert what was not checked. + toast({ + title: 'Could not determine whether an update exists', + description: 'This agent type does not report plugin versions on a remote host.', + }); } else if (status.supported && status.installed && !status.updateAvailable) { toast({ title: 'Switch connector is up to date' }); } diff --git a/dash/apps/switchdash-desktop/src/shared/core/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; +} From 41ddb8f354391f00589beacc5380c7efd7cb4cc8 Mon Sep 17 00:00:00 2001 From: Christian McDermott Date: Fri, 31 Jul 2026 12:06:03 -0400 Subject: [PATCH 45/51] fix(codex): refuse to launch when the MCP server name is already taken (CHOO-1436) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `-c mcp_servers..` 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) --- .../agent-runtime/impl/local-agent-runtime.ts | 6 ++ .../agent-runtime/impl/ssh-agent-runtime.ts | 7 ++ .../switch-mcp-launch-args.test.ts | 9 ++- .../switch-mcp-preflight.test.ts | 69 +++++++++++++++++++ .../agent-runtime/switch-mcp-preflight.ts | 49 +++++++++++++ 5 files changed, 137 insertions(+), 3 deletions(-) create mode 100644 dash/apps/switchdash-desktop/src/main/core/agent-runtime/switch-mcp-preflight.test.ts create mode 100644 dash/apps/switchdash-desktop/src/main/core/agent-runtime/switch-mcp-preflight.ts 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 d86da530d..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 @@ -5,6 +5,7 @@ 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 { agentCredsSlug } from '@main/core/agents/agent-creds-slug'; import { localDependencyManager } from '@main/core/dependencies/dependency-managers'; @@ -171,6 +172,11 @@ export class LocalAgentRuntime implements AgentRuntimeProvider { ? 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), 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 574060139..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 @@ -3,6 +3,7 @@ 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'; @@ -443,6 +444,12 @@ export class SshAgentRuntime implements AgentRuntimeProvider { ? 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), 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 index 07efe8bdf..be6320482 100644 --- 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 @@ -29,9 +29,12 @@ describe('switchMcpLaunchArgs', () => { expect(args).not.toMatch(/Bearer\s/); }); - it('emits url and token together, because -c replaces a server table wholesale', () => { - // Overriding one key of mcp_servers. discards the rest, so a partial - // set would leave the server unauthenticated rather than merged. + 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]); 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.` + ); +} From aac82fa98141ec2556ce47fdbf2a7795e72526bc Mon Sep 17 00:00:00 2001 From: Christian McDermott Date: Fri, 31 Jul 2026 12:06:03 -0400 Subject: [PATCH 46/51] fix(agent-hooks): require both ids to recognise a connect_to_room result (CHOO-1436) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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) --- .../core/agent-hooks/event-enricher.test.ts | 19 +++++++++++++++++++ .../main/core/agent-hooks/event-enricher.ts | 15 +++++++++++---- 2 files changed, 30 insertions(+), 4 deletions(-) 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 d674b0c4a..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 @@ -82,6 +82,25 @@ describe('parseHookEvent', () => { 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', { 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 dc6c56171..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 @@ -54,13 +54,20 @@ function asRecord(value: unknown): Record | null { } /** - * The value as a Switch `connect_to_room` result — a plain object carrying a - * string `room_id` — or null. The `room_id` probe is what tells the payload - * apart from an MCP envelope wrapping it, since both are plain objects. + * 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); - return record && typeof record.room_id === 'string' ? record : null; + if (!record) return null; + return typeof record.room_id === 'string' && typeof record.agent_id === 'string' ? record : null; } /** From b287d3c1e05f113d08e393f56480c25d5a91e5a6 Mon Sep 17 00:00:00 2001 From: Christian McDermott Date: Fri, 31 Jul 2026 12:06:03 -0400 Subject: [PATCH 47/51] fix(plugins): keep relative PluginFs paths POSIX (CHOO-1436) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../src/agents/impl/claude/subagents.test.ts | 20 ++++++++++++---- .../src/agents/impl/claude/subagents.ts | 24 +++++++++++++------ 2 files changed, 32 insertions(+), 12 deletions(-) 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 93463d95e..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,10 +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 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', () => { @@ -39,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', () => { @@ -220,7 +230,7 @@ describe('claudeRepoAgentsBehavior.removeLocal', () => { }); it('leaves the provider-neutral credentials to the caller, which removes them for every provider', async () => { - const neutralRel = path.join('.switch', 'agents', 'reviewer.json'); + const neutralRel = '.switch/agents/reviewer.json'; const workspaceFs = fakeFs({ [defRel('reviewer')]: '---\nname: reviewer\ndescription: x\n---\n', [neutralRel]: '{"env":{}}', diff --git a/dash/packages/plugins/src/agents/impl/claude/subagents.ts b/dash/packages/plugins/src/agents/impl/claude/subagents.ts index 8dbbf76b6..991b75bd0 100644 --- a/dash/packages/plugins/src/agents/impl/claude/subagents.ts +++ b/dash/packages/plugins/src/agents/impl/claude/subagents.ts @@ -18,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; @@ -326,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`; } /** @@ -349,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. */ @@ -424,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)); @@ -440,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)), ]; }, From 56eb45f167cc19a9907515fbf352e16267f769e4 Mon Sep 17 00:00:00 2001 From: Christian McDermott Date: Fri, 31 Jul 2026 12:06:20 -0400 Subject: [PATCH 48/51] fix(mcp): correct tool docstrings that misdescribe their own contract (CHOO-1436) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- core/switch_core/bridges/agent/mcp/server.py | 95 ++++++++++++++------ 1 file changed, 66 insertions(+), 29 deletions(-) diff --git a/core/switch_core/bridges/agent/mcp/server.py b/core/switch_core/bridges/agent/mcp/server.py index 2015f1e1f..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. @@ -773,10 +796,12 @@ async def cancel_task(task_id: str, reason: str, ctx: Context) -> dict[str, Any] agent_id = _get_agent_id() await _require_connected_room(ctx) - protocol = _get_protocol() - await protocol.cancel_task(agent_id, task_id, reason) - task = await protocol.get_task(agent_id, task_id) - return {"status": task.status, "reason": reason} + # `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 @@ -1085,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 @@ -1471,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, @@ -1505,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 @@ -1545,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() From 7d45962c9667aa62454a18ba5ad4d9bf2fc7a9de Mon Sep 17 00:00:00 2001 From: Christian McDermott Date: Fri, 31 Jul 2026 12:06:41 -0400 Subject: [PATCH 49/51] docs(connectors): document the role-authoring tools and guard skill drift (CHOO-1436) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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) --- .claude-plugin/marketplace.json | 2 +- .../.claude-plugin/plugin.json | 2 +- .../claude-code-plugin/skills/switch/SKILL.md | 22 +++++- .../codex-plugin/.codex-plugin/plugin.json | 2 +- .../codex-plugin/skills/switch/SKILL.md | 22 +++++- .../bridges/agent/test_mcp_tool_surface.py | 74 ++++++++++++++++--- 6 files changed, 108 insertions(+), 16 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 1a1b31e8b..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" }, diff --git a/connectors/claude-code-plugin/.claude-plugin/plugin.json b/connectors/claude-code-plugin/.claude-plugin/plugin.json index e94013ead..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.1", + "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 b99c4eb6c..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, list_roles, get_role_detail, assume_role, release_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. +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 @@ -536,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 @@ -578,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 diff --git a/connectors/codex-plugin/.codex-plugin/plugin.json b/connectors/codex-plugin/.codex-plugin/plugin.json index 96708b3cb..1e1064bfc 100644 --- a/connectors/codex-plugin/.codex-plugin/plugin.json +++ b/connectors/codex-plugin/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "switch-connector-codex", - "version": "0.1.1", + "version": "0.1.2", "description": "Connect Codex to a Switch platform instance as a participating agent", "skills": "./skills/" } diff --git a/connectors/codex-plugin/skills/switch/SKILL.md b/connectors/codex-plugin/skills/switch/SKILL.md index 68c099b22..0da5a8b21 100644 --- a/connectors/codex-plugin/skills/switch/SKILL.md +++ b/connectors/codex-plugin/skills/switch/SKILL.md @@ -1,6 +1,6 @@ --- 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, 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 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 @@ -540,6 +540,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 @@ -582,6 +599,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 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 index a29d26d44..1c42f18d7 100644 --- a/core/tests/switch_core/bridges/agent/test_mcp_tool_surface.py +++ b/core/tests/switch_core/bridges/agent/test_mcp_tool_surface.py @@ -1,13 +1,15 @@ """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. Nothing tied those names to the server's actual -registrations, so `cancel_task` was documented in both skills and in the -in-room instructions for a long while without ever being exposed as an -`@mcp.tool` — it lived only on the HTTP surface. An agent following the -documentation called a tool that was not there. +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 @@ -30,9 +32,8 @@ async def tool_names() -> set[str]: 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; the other five were exposed - without it, which left a lifecycle agents were told to drive but could - only get four-sixths of the way through. + `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 @@ -40,9 +41,9 @@ async def test_task_protocol_is_fully_exposed(tool_names: set[str]) -> None: async def test_documented_tools_exist(tool_names: set[str]) -> None: """Every tool the connector skills advertise is registered. - Mirrors the trigger list in `connectors/*/skills/switch/SKILL.md`. A tool - renamed or dropped here without updating both skills fails this test - rather than surfacing as an agent calling into nothing. + 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", @@ -55,6 +56,11 @@ async def test_documented_tools_exist(tool_names: set[str]) -> None: "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", @@ -76,3 +82,49 @@ async def test_documented_tools_exist(tool_names: set[str]) -> None: 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)}" + ) From d1849c92b50a2030374a5bde3dac57337586af69 Mon Sep 17 00:00:00 2001 From: Christian McDermott Date: Fri, 31 Jul 2026 12:09:37 -0400 Subject: [PATCH 50/51] style(switchdash): drop change narration and first person from comments (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) --- .../main/core/agents/generate-agent-launch-spec.test.ts | 8 ++++---- .../main/core/switch-setup/switch-setup-cli-dialect.ts | 2 +- .../src/main/core/switch-setup/switch-setup-service.ts | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) 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 55c622cbe..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 @@ -112,10 +112,10 @@ describe('generateAgentLaunchSpec', () => { }); /** - * The suite above mocks `buildCommand`, which is what let a real defect through: - * Codex sets `sessionIdOnResumeOnly`, so its fresh-session argv carries no - * session-id token at all, and the watcher rejected every spec it produced. A - * mocked command builder cannot show that. These drive the real provider spec. + * 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 = { 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 index 501a274f3..63fb2b9f2 100644 --- 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 @@ -143,7 +143,7 @@ const codex: SwitchSetupCliRules = { // 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 the thing we want. + // account of what it installed, which is what this returns. return [{ ref: e.pluginId, version: e.version ?? null, manifestPath: null }]; }); }, 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 e40fbe220..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 @@ -171,8 +171,8 @@ class SwitchSetupService { rules: SwitchSetupCliRules ): Promise { const { stdout } = await this.run(bin, ['plugin', 'marketplace', 'list', '--json']); - // An unreadable listing yields no entries, so we fall through and attempt the - // add — which is idempotent — rather than treating it as fatal. + // 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); From b85c82f02c105f71916faa362675b03f5c3ca69c Mon Sep 17 00:00:00 2001 From: Louis Amaudruz Date: Wed, 16 Sep 2026 19:14:25 +0000 Subject: [PATCH 51/51] docs(telemetry): document what Switch Console collects and why it is 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) --- README.md | 39 ++++++ docs/TELEMETRY.md | 335 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 374 insertions(+) create mode 100644 docs/TELEMETRY.md diff --git a/README.md b/README.md index 985790794..5ae03e3e1 100644 --- a/README.md +++ b/README.md @@ -188,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/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.