diff --git a/connectors/claude-code-plugin/skills/switch/SKILL.md b/connectors/claude-code-plugin/skills/switch/SKILL.md index 8c0d877b5..5b3b17488 100644 --- a/connectors/claude-code-plugin/skills/switch/SKILL.md +++ b/connectors/claude-code-plugin/skills/switch/SKILL.md @@ -791,6 +791,7 @@ failure-mode tools are covered in the sections just above. - `list_room_groups` — the group tree rooms are organised into. - `get_room_group_detail` — one group's rooms and child groups. - `create_room_group` — provision a new room group. +- `create_room_from_yaml` — provision a room or group from a YAML template. - `list_agents` — every agent on the instance, with optional filters. - `get_agent_detail` — one agent's config, capabilities and sessions. - `update_agent_detail` — change an agent you own. diff --git a/connectors/codex-plugin/skills/switch/SKILL.md b/connectors/codex-plugin/skills/switch/SKILL.md index fbaf9a7f9..c95b4dc3b 100644 --- a/connectors/codex-plugin/skills/switch/SKILL.md +++ b/connectors/codex-plugin/skills/switch/SKILL.md @@ -788,6 +788,7 @@ failure-mode tools are covered in the sections just above. - `list_room_groups` — the group tree rooms are organised into. - `get_room_group_detail` — one group's rooms and child groups. - `create_room_group` — provision a new room group. +- `create_room_from_yaml` — provision a room or group from a YAML template. - `list_agents` — every agent on the instance, with optional filters. - `get_agent_detail` — one agent's config, capabilities and sessions. - `update_agent_detail` — change an agent you own. diff --git a/connectors/opencode-plugin/skills/switch/SKILL.md b/connectors/opencode-plugin/skills/switch/SKILL.md index cb274a1ec..6e8a6070c 100644 --- a/connectors/opencode-plugin/skills/switch/SKILL.md +++ b/connectors/opencode-plugin/skills/switch/SKILL.md @@ -791,6 +791,7 @@ failure-mode tools are covered in the sections just above. - `list_room_groups` — the group tree rooms are organised into. - `get_room_group_detail` — one group's rooms and child groups. - `create_room_group` — provision a new room group. +- `create_room_from_yaml` — provision a room or group from a YAML template. - `list_agents` — every agent on the instance, with optional filters. - `get_agent_detail` — one agent's config, capabilities and sessions. - `update_agent_detail` — change an agent you own. diff --git a/console/apps/switch-console-desktop/drizzle/0048_agent_owner_name.sql b/console/apps/switch-console-desktop/drizzle/0048_agent_owner_name.sql new file mode 100644 index 000000000..d2e2254d4 --- /dev/null +++ b/console/apps/switch-console-desktop/drizzle/0048_agent_owner_name.sql @@ -0,0 +1 @@ +ALTER TABLE `agents` ADD `owner_name` text; \ No newline at end of file diff --git a/console/apps/switch-console-desktop/drizzle/meta/_journal.json b/console/apps/switch-console-desktop/drizzle/meta/_journal.json index 2b8b7d0f6..8adcf9253 100644 --- a/console/apps/switch-console-desktop/drizzle/meta/_journal.json +++ b/console/apps/switch-console-desktop/drizzle/meta/_journal.json @@ -337,6 +337,13 @@ "when": 1786983032561, "tag": "0047_materialise_telemetry_consent", "breakpoints": true + }, + { + "idx": 48, + "version": "6", + "when": 1788633600000, + "tag": "0048_agent_owner_name", + "breakpoints": true } ] } diff --git a/console/apps/switch-console-desktop/src/main/core/agents/add-agent.test.ts b/console/apps/switch-console-desktop/src/main/core/agents/add-agent.test.ts index 5c5bb36cd..65f20936b 100644 --- a/console/apps/switch-console-desktop/src/main/core/agents/add-agent.test.ts +++ b/console/apps/switch-console-desktop/src/main/core/agents/add-agent.test.ts @@ -88,6 +88,19 @@ vi.mock('./setAgentAutoSession', () => ({ vi.mock('./agent-events', () => ({ agentEvents: { _emit: vi.fn() } })); vi.mock('@main/core/telemetry/telemetry-service', () => ({ trackEvent: vi.fn() })); vi.mock('@main/lib/logger', () => ({ log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() } })); +vi.mock('@main/db/client', () => ({ + db: { + select: () => ({ + from: () => ({ + where: () => ({ + limit: () => Promise.resolve([]), + }), + }), + }), + }, +})); +vi.mock('@main/db/schema', () => ({ agents: { id: 'id', switchAgentId: 'switchAgentId' } })); +vi.mock('drizzle-orm', () => ({ eq: vi.fn() })); const { addAgent } = await import('./add-agent'); const { trackEvent } = await import('@main/core/telemetry/telemetry-service'); @@ -222,6 +235,31 @@ describe('addAgent', () => { ).toBe(theirs); }); + it('refuses when same-server credentials exist but the agent is unknown to this install (CHOO-2560)', async () => { + // A colleague's Console provisioned this agent on the same Switch server. + // The credentials file carries the same endpoint, but its agent id is not + // in this install's database (the mock returns []). Minting over it would + // destroy their token. + const colleague = JSON.stringify({ + env: { + SWITCH_API_ENDPOINT: 'https://switch.example.com', + SWITCH_API_TOKEN: 'colleague-token', + SWITCH_AGENT_ID: 'colleague-agent', + }, + }); + h.state.workspace = fakeFs({ [agentSettingsRelativePath('codex-hoot')]: colleague }); + + const result = await addAgent(params()); + + expect(result).toEqual({ kind: 'already-configured' }); + expect(h.registerAgentIdentity).not.toHaveBeenCalled(); + expect(h.createAgent).not.toHaveBeenCalled(); + // The colleague's file is intact. + expect( + await (h.state.workspace as PluginFs).read(agentSettingsRelativePath('codex-hoot')) + ).toBe(colleague); + }); + 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 diff --git a/console/apps/switch-console-desktop/src/main/core/agents/add-agent.ts b/console/apps/switch-console-desktop/src/main/core/agents/add-agent.ts index 014df7a72..f86c988c9 100644 --- a/console/apps/switch-console-desktop/src/main/core/agents/add-agent.ts +++ b/console/apps/switch-console-desktop/src/main/core/agents/add-agent.ts @@ -1,5 +1,6 @@ import { randomUUID } from 'node:crypto'; import type { RepoAgentAttributes } from '@switch-console/core/agents/plugins'; +import { eq } from 'drizzle-orm'; import { locationManager } from '@main/core/locations/location-manager'; import { checkIsValidDirectory } from '@main/core/locations/path-utils'; import { ensureLocation, getLocationByHostDir } from '@main/core/locations/store'; @@ -9,6 +10,8 @@ import { agentTypeOf } from '@main/core/telemetry/agent-type'; import type { TelemetryAgentCreateFailure } from '@main/core/telemetry/events'; import { entryPointOf } from '@main/core/telemetry/narrow'; import { trackEvent } from '@main/core/telemetry/telemetry-service'; +import { db } from '@main/db/client'; +import { agents as agentsTable } from '@main/db/schema'; import { log } from '@main/lib/logger'; import { agentAvatarUrlForName } from '@shared/core/agents/agent-avatar'; import type { AgentProviderConfig } from '@shared/core/agents/agent-provider-config'; @@ -18,7 +21,7 @@ import type { UiEntryPoint } from '@shared/core/telemetry/reporting'; import { basenameFromAnyPath } from '@shared/path-name'; import { writeAgentConfigFile } from './agent-config-file'; import { syncAgentConfig } from './agent-config-sync'; -import { foreignCredentialsOwner } from './agent-credentials-slot'; +import { foreignCredentialsOwner, sameEndpointAgentId } from './agent-credentials-slot'; import { agentEvents } from './agent-events'; import { agentNameTaken } from './agent-name-taken'; import { resolveWorkspaceFsFor } from './agent-workspace-fs'; @@ -72,6 +75,7 @@ export type AddAgentResult = | { kind: 'unauthenticated' } | { kind: 'name-conflict' } | { kind: 'credentials-conflict'; endpoint: string } + | { kind: 'already-configured' } | { kind: 'invalid-name'; message: string } | { kind: 'error'; message: string }; @@ -83,6 +87,7 @@ const ADD_AGENT_FAILURE_REASON: Record< unauthenticated: 'unauthenticated', 'name-conflict': 'name_conflict', 'credentials-conflict': 'credentials_conflict', + 'already-configured': 'already_configured', 'invalid-name': 'invalid_name', error: 'error', }; @@ -182,6 +187,28 @@ async function runAddAgent(params: AddAgentParams): Promise { }); } + // The cross-deployment check above passes when the slot belongs to the SAME + // server. That is safe when this install already manages the agent (the + // agentNameTaken check above covers it), but not when the file was written by another + // Console — its agent is in this install's blind spot. Minting here would + // overwrite the existing identity and destroy its token (CHOO-2560). + const slotAgentId = await sameEndpointAgentId( + params.sshHost, + params.dir, + params.name, + server.apiUrl + ); + if (slotAgentId !== null) { + const [knownLocally] = await db + .select({ id: agentsTable.id }) + .from(agentsTable) + .where(eq(agentsTable.switchAgentId, slotAgentId)) + .limit(1); + if (!knownLocally) { + return reportFailedCreate(params, { kind: 'already-configured' }); + } + } + const registered = await registerAgentIdentity(server, { name: params.name, description: params.description, @@ -206,6 +233,7 @@ async function runAddAgent(params: AddAgentParams): Promise { apiEndpoint: server.apiUrl, apiToken: registered.apiKey, agentId: registered.id, + expectedAgentId: slotAgentId ?? undefined, }); // The config file is the agent's configuration; the provider's own file is // generated from it, here and on every later edit. diff --git a/console/apps/switch-console-desktop/src/main/core/agents/agent-credentials-slot.ts b/console/apps/switch-console-desktop/src/main/core/agents/agent-credentials-slot.ts index 1c66c3c23..81bafcd68 100644 --- a/console/apps/switch-console-desktop/src/main/core/agents/agent-credentials-slot.ts +++ b/console/apps/switch-console-desktop/src/main/core/agents/agent-credentials-slot.ts @@ -1,5 +1,6 @@ import { resolveWorkspaceFsFor } from './agent-workspace-fs'; -import { foreignCredentialsOwnerFs } from './write-switch-settings'; +import { agentSettingsRelativePath } from './switch-settings-paths'; +import { existingAgentIdInSlot, foreignCredentialsOwnerFs } from './write-switch-settings'; /** * The Switch deployment that already owns `.switch/agents/.json` in a @@ -28,3 +29,28 @@ export async function foreignCredentialsOwner( workspace.close(); } } + +/** + * The `SWITCH_AGENT_ID` of an agent already configured in the credentials slot + * for `slug`, when the file belongs to the SAME deployment as `apiEndpoint` — + * otherwise null. + * + * The create path calls this after the cross-deployment check + * ({@link foreignCredentialsOwner}) and before minting a new identity. A + * same-server file whose agent id is unknown to this install's database is a + * colleague's agent: minting over it would overwrite their token (CHOO-2560). + */ +export async function sameEndpointAgentId( + sshHost: string | null, + dir: string, + slug: string, + apiEndpoint: string +): Promise { + const workspace = await resolveWorkspaceFsFor(sshHost, dir); + try { + const existingRaw = await workspace.fs.read(agentSettingsRelativePath(slug)); + return existingAgentIdInSlot(existingRaw, apiEndpoint); + } finally { + workspace.close(); + } +} diff --git a/console/apps/switch-console-desktop/src/main/core/agents/agent-events-renderer-bridge.ts b/console/apps/switch-console-desktop/src/main/core/agents/agent-events-renderer-bridge.ts new file mode 100644 index 000000000..b67b9ddcf --- /dev/null +++ b/console/apps/switch-console-desktop/src/main/core/agents/agent-events-renderer-bridge.ts @@ -0,0 +1,14 @@ +import { events } from '@main/lib/events'; +import { agentsChangedChannel } from '@shared/events/appEvents'; +import { agentEvents } from './agent-events'; + +/** + * Forward agent CRUD from the main-only `agentEvents` bus to the renderer, so + * renderer stores and queries can react to create/update/delete without each + * mutating call site refetching by hand. + */ +export function bridgeAgentEventsToRenderer(): void { + agentEvents.on('agent:created', () => events.emit(agentsChangedChannel, { kind: 'created' })); + agentEvents.on('agent:updated', () => events.emit(agentsChangedChannel, { kind: 'updated' })); + agentEvents.on('agent:deleted', () => events.emit(agentsChangedChannel, { kind: 'deleted' })); +} diff --git a/console/apps/switch-console-desktop/src/main/core/agents/attach-configured-agents.test.ts b/console/apps/switch-console-desktop/src/main/core/agents/attach-configured-agents.test.ts index b99e25029..bf6e13a6a 100644 --- a/console/apps/switch-console-desktop/src/main/core/agents/attach-configured-agents.test.ts +++ b/console/apps/switch-console-desktop/src/main/core/agents/attach-configured-agents.test.ts @@ -103,6 +103,7 @@ vi.mock('./setAgentAutoSession', () => ({ reconcileAgentAutoSessionFromGateway: vi.fn(async () => {}), })); vi.mock('./agent-events', () => ({ agentEvents: { _emit: h.emit } })); +vi.mock('./remote-watcher', () => ({ startRemoteDiscovery: vi.fn(async () => {}) })); vi.mock('@main/lib/logger', () => ({ log: { info: vi.fn(), warn: h.warn, error: vi.fn() } })); const { attachConfiguredAgents } = await import('./attach-configured-agents'); diff --git a/console/apps/switch-console-desktop/src/main/core/agents/attach-configured-agents.ts b/console/apps/switch-console-desktop/src/main/core/agents/attach-configured-agents.ts index f232387c2..a56bb053a 100644 --- a/console/apps/switch-console-desktop/src/main/core/agents/attach-configured-agents.ts +++ b/console/apps/switch-console-desktop/src/main/core/agents/attach-configured-agents.ts @@ -28,7 +28,7 @@ export type AttachConfiguredAgentsParams = { * taken from the scan: discovery infers it best-effort and reports `null` when * the directory names none, in which case the user picks. */ - agents: Array<{ name: string; providerId: AgentProviderId }>; + agents: Array<{ name: string; providerId: AgentProviderId; ownerName?: string | null }>; }; export type AttachConfiguredAgentsResult = Result; @@ -81,7 +81,8 @@ export async function attachConfiguredAgents( ).map((d) => [d.name, d]) ); - const selected: Array<{ name: string; providerId: AgentProviderId }> = []; + const selected: Array<{ name: string; providerId: AgentProviderId; ownerName?: string | null }> = + []; for (const requested of params.agents) { const found = discovered.get(requested.name); if (!found) { @@ -110,7 +111,7 @@ export async function attachConfiguredAgents( }); const created: Agent[] = []; - for (const { name, providerId } of selected) { + for (const { name, providerId, ownerName } of selected) { const found = discovered.get(name); if (!found) continue; @@ -159,6 +160,7 @@ export async function attachConfiguredAgents( apiEndpoint: found.apiEndpoint, serverId: params.serverId, autoApprove: params.sshHost !== null, + ownerName: ownerName ?? null, }); created.push(agent); @@ -171,9 +173,22 @@ export async function attachConfiguredAgents( } await locationManager.openLocation(location); - // No control to name: an attach is driven by whichever screen offered the - // scan, and nothing on the way here says which. `unknown` reports the absence - // of a claim rather than inventing one. for (const agent of created) agentEvents._emit('agent:created', agent, 'unknown'); + + if (params.sshHost !== null) { + // Lazy import: remote-watcher transitively loads Electron's `app` module at + // the top level, which breaks the unit-test environment where `app` is + // undefined. A dynamic import defers that cost to runtime (always Electron). + const { startRemoteDiscovery } = await import('./remote-watcher'); + for (const agent of created) { + startRemoteDiscovery(agent.id).catch((error) => { + log.warn('attachConfiguredAgents: failed to start session discovery', { + agentId: agent.id, + error: String(error), + }); + }); + } + } + return ok(created); } diff --git a/console/apps/switch-console-desktop/src/main/core/agents/controller.ts b/console/apps/switch-console-desktop/src/main/core/agents/controller.ts index 39ec76ee3..f50e7725a 100644 --- a/console/apps/switch-console-desktop/src/main/core/agents/controller.ts +++ b/console/apps/switch-console-desktop/src/main/core/agents/controller.ts @@ -23,11 +23,18 @@ import { createAgent } from './createAgent'; import { getAgentDefinitionFields } from './definition-fields'; import { deleteAgent, type DeleteAgentOptions } from './deleteAgent'; import { discoverConfiguredAgents } from './discover-configured-agents'; +import { + discoverLoadableAgentsInDir, + discoverLoadableAgentsOnHost, + type DiscoverLoadableAgentsParams, +} from './discover-loadable-agents'; import { discoverLocationAgents } from './discover-location-agents'; import { getAgentById } from './getAgentById'; import { getAgents } from './getAgents'; import { onboardAgent } from './onboard-agent'; import { onboardLocationAgents, type OnboardLocationParams } from './onboard-location-agents'; +import type { RemoveLoadableAgentConfigParams } from './remove-loadable-agent-config'; +import { removeLoadableAgentConfig } from './remove-loadable-agent-config'; import { renameAgent } from './renameAgent'; import { resetRemoteAgent } from './reset-remote-agent'; import { setAgentAutoApprove, type AgentAutoApproveParams } from './setAgentAutoApprove'; @@ -85,13 +92,20 @@ export const agentsController = createRPCController({ }) => discoverLocationAgents(params), discoverConfiguredAgents: (params: { sshHost: string | null; dir: string; serverId: string }) => discoverConfiguredAgents(params), + discoverLoadableAgentsOnHost: (params: DiscoverLoadableAgentsParams) => + discoverLoadableAgentsOnHost(params), + discoverLoadableAgentsInDir: (params: { sshHost: string; dir: string; serverId: string }) => + discoverLoadableAgentsInDir(params), attachConfiguredAgents: (params: AttachConfiguredAgentsParams) => attachConfiguredAgents(params), + removeLoadableAgentConfig: (params: RemoveLoadableAgentConfigParams) => + removeLoadableAgentConfig(params), getAgents: (locationId?: string) => getAgents(locationId), getAgentById: (agentId: string) => getAgentById(agentId), renameAgent: (params: RenameAgentParams) => renameAgent(params), deleteAgent: (params: { agentId: string } & DeleteAgentOptions) => deleteAgent(params.agentId, { deleteInSwitch: params.deleteInSwitch, + removeProvisionedFiles: params.removeProvisionedFiles, trigger: params.trigger, }), resetRemoteAgent: (params: { agentId: string }) => resetRemoteAgent(params.agentId), diff --git a/console/apps/switch-console-desktop/src/main/core/agents/createAgent.ts b/console/apps/switch-console-desktop/src/main/core/agents/createAgent.ts index 52f875245..8cdebcabb 100644 --- a/console/apps/switch-console-desktop/src/main/core/agents/createAgent.ts +++ b/console/apps/switch-console-desktop/src/main/core/agents/createAgent.ts @@ -16,6 +16,7 @@ export async function createAgent(params: CreateAgentParams): Promise { apiEndpoint: params.apiEndpoint, serverId: params.serverId, autoApprove: params.autoApprove, + ownerName: params.ownerName ?? null, providerConfig: params.providerConfig ?? null, updatedAt: sql`CURRENT_TIMESTAMP`, }) diff --git a/console/apps/switch-console-desktop/src/main/core/agents/deleteAgent.test.ts b/console/apps/switch-console-desktop/src/main/core/agents/deleteAgent.test.ts index 3780b49c8..29ae201d4 100644 --- a/console/apps/switch-console-desktop/src/main/core/agents/deleteAgent.test.ts +++ b/console/apps/switch-console-desktop/src/main/core/agents/deleteAgent.test.ts @@ -119,7 +119,11 @@ describe('deleteAgent', () => { const fs = fakeFs({ [agentSettingsRelativePath('codex-hoot')]: CREDS }); h.state.fs = fs; - await deleteAgent('agent-1', { deleteInSwitch: false, trigger: 'user' }); + await deleteAgent('agent-1', { + deleteInSwitch: false, + removeProvisionedFiles: true, + trigger: 'user', + }); expect(await fs.exists(agentSettingsRelativePath('codex-hoot'))).toBe(false); }); @@ -131,12 +135,38 @@ describe('deleteAgent', () => { }); h.state.fs = fs; - await deleteAgent('agent-1', { deleteInSwitch: false, trigger: 'user' }); + await deleteAgent('agent-1', { + deleteInSwitch: false, + removeProvisionedFiles: true, + trigger: 'user', + }); expect(await fs.exists(agentSettingsRelativePath('cc-hoot'))).toBe(false); expect(await fs.exists('.claude/agents/cc-hoot.md')).toBe(false); }); + it('leaves the working directory untouched on a plain remove (CHOO-2560)', async () => { + // An agent loaded from a shared host has credentials another install owns; + // a remove without removeProvisionedFiles must not reach into the directory. + h.state.agent = { id: 'agent-1', name: 'cc-hoot', providerId: 'claude', locationId: 'loc' }; + const fs = fakeFs({ + [agentSettingsRelativePath('cc-hoot')]: CREDS, + '.claude/agents/cc-hoot.md': '# cc-hoot', + }); + h.state.fs = fs; + + await deleteAgent('agent-1', { + deleteInSwitch: false, + removeProvisionedFiles: false, + trigger: 'user', + }); + + expect(await fs.exists(agentSettingsRelativePath('cc-hoot'))).toBe(true); + expect(await fs.exists('.claude/agents/cc-hoot.md')).toBe(true); + expect(h.removeLocal).not.toHaveBeenCalled(); + expect(h.removeSwitchCredentials).not.toHaveBeenCalled(); + }); + it('leaves a sibling agent sharing the directory untouched', async () => { const fs = fakeFs({ [agentSettingsRelativePath('cc-hoot')]: CREDS, @@ -144,14 +174,22 @@ describe('deleteAgent', () => { }); h.state.fs = fs; - await deleteAgent('agent-1', { deleteInSwitch: false, trigger: 'user' }); + await deleteAgent('agent-1', { + deleteInSwitch: false, + removeProvisionedFiles: true, + trigger: 'user', + }); expect(await fs.exists(agentSettingsRelativePath('cc-sibling'))).toBe(true); }); describe('what it reports', () => { it('describes the agent that was removed, from the row before it goes', async () => { - await deleteAgent('agent-1', { deleteInSwitch: false, trigger: 'user' }); + await deleteAgent('agent-1', { + deleteInSwitch: false, + removeProvisionedFiles: false, + trigger: 'user', + }); expect(h.trackEvent).toHaveBeenCalledWith('agent_removed', { agent_type: 'claude', @@ -166,7 +204,11 @@ describe('deleteAgent', () => { it('separates a server teardown from a person removing an agent', async () => { // Wiping a managed server deletes every agent on it through this same // function; without the distinction one click looks like an exodus. - await deleteAgent('agent-1', { deleteInSwitch: false, trigger: 'server_teardown' }); + await deleteAgent('agent-1', { + deleteInSwitch: false, + removeProvisionedFiles: false, + trigger: 'server_teardown', + }); expect(h.trackEvent).toHaveBeenCalledWith( 'agent_removed', @@ -179,7 +221,11 @@ describe('deleteAgent', () => { // every one of them reported starting. h.state.sessionRows = [{ id: 's-1' }, { id: 's-2' }]; - await deleteAgent('agent-1', { deleteInSwitch: false, trigger: 'user' }); + await deleteAgent('agent-1', { + deleteInSwitch: false, + removeProvisionedFiles: false, + trigger: 'user', + }); expect(h.sessionHookEmit).toHaveBeenCalledWith('session:deleted', 's-1'); expect(h.sessionHookEmit).toHaveBeenCalledWith('session:deleted', 's-2'); @@ -201,7 +247,11 @@ describe('deleteAgent', () => { vi.mocked(getServer).mockResolvedValue({ id: 'srv-1' } as never); await expect( - deleteAgent('agent-1', { deleteInSwitch: true, trigger: 'user' }) + deleteAgent('agent-1', { + deleteInSwitch: true, + removeProvisionedFiles: false, + trigger: 'user', + }) ).rejects.toThrow(); expect(h.trackEvent).toHaveBeenCalledWith( @@ -221,7 +271,11 @@ describe('deleteAgent', () => { }; await expect( - deleteAgent('agent-1', { deleteInSwitch: true, trigger: 'user' }) + deleteAgent('agent-1', { + deleteInSwitch: true, + removeProvisionedFiles: false, + trigger: 'user', + }) ).rejects.toThrow(); expect(h.trackEvent).toHaveBeenCalledWith( diff --git a/console/apps/switch-console-desktop/src/main/core/agents/deleteAgent.ts b/console/apps/switch-console-desktop/src/main/core/agents/deleteAgent.ts index cfa87aab4..57c312eea 100644 --- a/console/apps/switch-console-desktop/src/main/core/agents/deleteAgent.ts +++ b/console/apps/switch-console-desktop/src/main/core/agents/deleteAgent.ts @@ -44,6 +44,19 @@ export type DeleteAgentOptions = { * left registered on the server. */ deleteInSwitch: boolean; + /** + * Also tear down what was provisioned on disk for this agent (its + * `.switch/agents/.json` credentials, provider definition files, launch + * profile) and kill its sidecar on the host. + * + * Required with no default, because the right answer depends on who owns the + * on-disk state: an agent this Console created can carry its files out, but an + * agent merely loaded from a shared host (CHOO-2560) has credentials that + * belong to ANOTHER install — deleting them there is data loss on a + * colleague's machine. Plain remove must mirror attach's guarantee: nothing in + * the working directory is touched. + */ + removeProvisionedFiles: boolean; /** * Whether a person removed this agent or a server teardown swept it up. * @@ -188,9 +201,11 @@ async function killRemoteSidecar(agent: Agent): Promise { * caches the agent's Switch credentials in memory, so without an explicit * stop it keeps heartbeating and polling notifications for an agent that * no longer exists. - * 4. The Switch credentials + definition file Switch Console provisioned on disk for - * THIS agent (local or remote), which a bare row delete would leave orphaned. - * Sibling agents' files in the same directory are untouched. + * 4. Only when `removeProvisionedFiles` is set: the Switch credentials + + * definition file provisioned on disk for THIS agent (local or remote), and + * its sidecar. A plain remove leaves the working directory and the host's + * processes untouched — on a shared host they may belong to another install + * (CHOO-2560). Sibling agents' files are never touched either way. * * The agent's location row is intentionally kept — locations are reusable * and other agents may still live there. @@ -247,14 +262,17 @@ async function removeAgent( await stopRemoteWatcher(agentId).catch((error) => { log.warn('deleteAgent: failed to stop remote watcher', { agentId, error: String(error) }); }); - if (agent) await killRemoteSidecar(agent); + // The sidecar is host state, like the files: killing it under an agent + // another install still manages takes their agent offline. Only tear it + // down when the on-disk teardown was asked for. + if (agent && options.removeProvisionedFiles) await killRemoteSidecar(agent); } else { autoSessionWatcher.stopForAgent(agentId); } await setAutoSessionAgent(agentId, false); - if (agent && location) { + if (agent && location && options.removeProvisionedFiles) { await removeProvisionedFiles(agent, location).catch((error) => { log.warn('deleteAgent: failed to remove provisioned files', { agentId, diff --git a/console/apps/switch-console-desktop/src/main/core/agents/discover-loadable-agents.ts b/console/apps/switch-console-desktop/src/main/core/agents/discover-loadable-agents.ts new file mode 100644 index 000000000..b43e7a13b --- /dev/null +++ b/console/apps/switch-console-desktop/src/main/core/agents/discover-loadable-agents.ts @@ -0,0 +1,280 @@ +import { SshExecutionContext } from '@main/core/execution-context/ssh-execution-context'; +import { sshConnectionIdForHost } from '@main/core/locations/location-transport'; +import { ensureSshConnected } from '@main/core/ssh/connect/connect-agent-ssh'; +import { fetchAgents, fetchMe } from '@main/core/switch-servers/gateway-client'; +import { getServer } from '@main/core/switch-servers/servers-store'; +import { log } from '@main/lib/logger'; +import type { AgentProviderId } from '@shared/core/providers/agent-provider-registry'; +import { sameApiEndpoint } from '@shared/core/switch-servers/switch-servers'; +import type { DiscoveredConfiguredAgent, ProviderSource } from './discover-configured-agents'; +import { discoverConfiguredAgents } from './discover-configured-agents'; + +/** + * An agent found on a remote host that can be loaded into this Console. + * + * Merges server-assisted discovery (which carries owner attribution) with a + * bounded on-host scan (which catches unregistered agents). Server-attributed + * entries win on dedup so attribution is preserved. + */ +export type LoadableAgent = { + name: string; + dir: string; + switchAgentId: string; + apiEndpoint: string; + providerId: AgentProviderId | null; + providerSource: ProviderSource; + /** Whether this Console already has a row for this agent. */ + alreadyAgent: boolean; + /** The agent's owner on the server, when known via server-assisted discovery. */ + ownerName: string | null; + /** True when the signed-in user is the agent's owner on the server. */ + viewerIsOwner: boolean; + /** The agent's server-side description, when known via server-assisted discovery. */ + description: string | null; + /** The source that found this agent. */ + source: 'server' | 'scan'; + /** True when the on-disk endpoint does not match the server's URL. */ + endpointMismatch: boolean; + /** When set, the agent cannot be loaded and this is the human-readable reason. */ + blockedReason: string | null; +}; + +export type DiscoverLoadableAgentsParams = { + sshHost: string; + serverId: string; + /** When true, run a depth-limited `find` over `$HOME` in addition to the + * cheap server-assisted discovery. Off by default — the walk can be slow + * on large VMs. */ + includeHomeScan?: boolean; +}; + +export type DiscoverLoadableAgentsResult = { + agents: LoadableAgent[]; + /** The target server's API URL, for rendering endpoint mismatches legibly. */ + serverApiUrl: string; +}; + +/** + * Discover agents on a remote host that can be loaded into this Console, + * merging two sources and deduping by `(dir, name)`. + * + * 1. **Server-assisted:** `GET /agents` for the deployment's agents with + * `repo_dir`; confirm each dir on-host via `discoverConfiguredAgents`. + * 2. **Bounded `$HOME` scan:** depth-limited `find` under the host's `$HOME` + * for dirs holding `.switch/agents/*.json`. + * + * Server-attributed entries win on dedup so owner attribution is preserved. + */ +export async function discoverLoadableAgentsOnHost( + params: DiscoverLoadableAgentsParams +): Promise { + const server = await getServer(params.serverId); + if (!server) throw new Error(`No Switch server with id ${params.serverId}`); + + // Key: "dir\0name" → LoadableAgent. Server entries inserted first win. + const seen = new Map(); + + // --- Source 1: Server-assisted discovery --- + try { + const remoteAgents = await fetchAgents(server); + // Marks rows the signed-in user owns; an auth failure degrades to not-owner. + const me = await fetchMe(server).catch(() => null); + + // Collect distinct repo_dirs from agents whose known_agent_options carry one. + type ServerAgentInfo = { + ownerName: string | null; + ownerId: string | null; + description: string | null; + }; + const dirOwners = new Map>(); + for (const agent of remoteAgents) { + const repoDir = + agent.knownAgentOptions && + typeof agent.knownAgentOptions === 'object' && + typeof (agent.knownAgentOptions as Record).repo_dir === 'string' + ? ((agent.knownAgentOptions as Record).repo_dir as string) + : null; + if (!repoDir) continue; + + if (!dirOwners.has(repoDir)) dirOwners.set(repoDir, new Map()); + dirOwners.get(repoDir)!.set(agent.name, { + ownerName: agent.ownerName, + ownerId: agent.ownerId, + description: agent.description ?? null, + }); + } + + for (const [dir, nameOwners] of dirOwners) { + try { + const discovered = await discoverConfiguredAgents({ + sshHost: params.sshHost, + dir, + serverId: params.serverId, + }); + for (const agent of discovered) { + const key = `${dir}\0${agent.name}`; + const info = nameOwners.get(agent.name) ?? null; + seen.set(key, { + name: agent.name, + dir, + switchAgentId: agent.switchAgentId, + apiEndpoint: agent.apiEndpoint, + providerId: agent.providerId, + providerSource: agent.providerSource, + alreadyAgent: agent.alreadyAgent, + ownerName: info?.ownerName ?? null, + viewerIsOwner: !!(me && info?.ownerId && info.ownerId === me.id), + description: info?.description ?? null, + source: 'server', + endpointMismatch: !sameApiEndpoint(agent.apiEndpoint, server.apiUrl), + blockedReason: blockedReasonFor(agent, server.apiUrl), + }); + } + } catch (error) { + log.warn('discoverLoadableAgentsOnHost: server-assisted dir scan failed', { + dir, + sshHost: params.sshHost, + error: error instanceof Error ? error.message : String(error), + }); + } + } + } catch (error) { + log.warn('discoverLoadableAgentsOnHost: server-assisted discovery failed', { + serverId: params.serverId, + error: error instanceof Error ? error.message : String(error), + }); + } + + // --- Source 2: Bounded $HOME scan (opt-in) --- + if (!params.includeHomeScan) return { agents: [...seen.values()], serverApiUrl: server.apiUrl }; + try { + const scannedDirs = await findSwitchAgentDirsOnHost(params.sshHost); + for (const dir of scannedDirs) { + try { + const discovered = await discoverConfiguredAgents({ + sshHost: params.sshHost, + dir, + serverId: params.serverId, + }); + for (const agent of discovered) { + const key = `${dir}\0${agent.name}`; + if (!seen.has(key)) { + seen.set(key, { + name: agent.name, + dir, + switchAgentId: agent.switchAgentId, + apiEndpoint: agent.apiEndpoint, + providerId: agent.providerId, + providerSource: agent.providerSource, + alreadyAgent: agent.alreadyAgent, + ownerName: null, + viewerIsOwner: false, + description: null, + source: 'scan', + endpointMismatch: !sameApiEndpoint(agent.apiEndpoint, server.apiUrl), + blockedReason: blockedReasonFor(agent, server.apiUrl), + }); + } + } + } catch (error) { + log.warn('discoverLoadableAgentsOnHost: scan dir discovery failed', { + dir, + sshHost: params.sshHost, + error: error instanceof Error ? error.message : String(error), + }); + } + } + } catch (error) { + log.warn('discoverLoadableAgentsOnHost: bounded scan failed', { + sshHost: params.sshHost, + error: error instanceof Error ? error.message : String(error), + }); + } + + return { agents: [...seen.values()], serverApiUrl: server.apiUrl }; +} + +function blockedReasonFor(agent: DiscoveredConfiguredAgent, serverApiUrl: string): string | null { + if (agent.alreadyAgent) return 'Already loaded in this Console'; + if (!sameApiEndpoint(agent.apiEndpoint, serverApiUrl)) + return 'Endpoint does not match this server'; + return null; +} + +/** + * Bounded depth-limited scan of `$HOME` on a remote host for directories + * containing `.switch/agents/*.json`. Prunes `node_modules` and every hidden + * directory except `.switch` itself — dot-trees like `.cargo`, `.npm` or + * `.vscode-server` hold hundreds of thousands of entries on a dev box and can + * never contain a working directory we would surface. + * + * Returns the parent working directories (the dirs that contain `.switch/`), + * not the `.switch/agents/` paths themselves. + */ +async function findSwitchAgentDirsOnHost(sshHost: string): Promise { + const proxy = await ensureSshConnected(sshConnectionIdForHost(sshHost), sshHost); + const ctx = new SshExecutionContext(proxy); + let result: { stdout: string }; + try { + result = await ctx.exec('sh', [ + '-c', + [ + 'find "$HOME" -maxdepth 6', + '-type d \\( -name node_modules -o \\( -name ".*" ! -name .switch \\) \\) -prune', + '-o -type f -path "*/.switch/agents/*.json" -print', + '2>/dev/null', + '| sed "s|/\\.switch/agents/.*||"', + '| sort -u', + ].join(' '), + ]); + } catch (error) { + // Disclosed fallback: an exec failure must not read as "empty host", so + // leave a trace even though discovery continues with the server source. + log.warn('findSwitchAgentDirsOnHost: $HOME scan failed', { + sshHost, + error: error instanceof Error ? error.message : String(error), + }); + return []; + } + return result.stdout + .trim() + .split('\n') + .filter((line) => line.length > 0); +} + +/** + * Discover agents in a single manually-specified directory — the "scan a + * directory" fallback. A thin wrapper that calls the existing per-dir scan + * and attaches the same metadata shape as the merged discovery. + */ +export async function discoverLoadableAgentsInDir(params: { + sshHost: string; + dir: string; + serverId: string; +}): Promise<{ agents: LoadableAgent[]; serverApiUrl: string }> { + const server = await getServer(params.serverId); + if (!server) throw new Error(`No Switch server with id ${params.serverId}`); + + const discovered = await discoverConfiguredAgents({ + sshHost: params.sshHost, + dir: params.dir, + serverId: params.serverId, + }); + + const agents = discovered.map((agent) => ({ + name: agent.name, + dir: params.dir, + switchAgentId: agent.switchAgentId, + apiEndpoint: agent.apiEndpoint, + providerId: agent.providerId, + providerSource: agent.providerSource, + alreadyAgent: agent.alreadyAgent, + ownerName: null, + viewerIsOwner: false, + description: null, + source: 'scan' as const, + endpointMismatch: !sameApiEndpoint(agent.apiEndpoint, server.apiUrl), + blockedReason: blockedReasonFor(agent, server.apiUrl), + })); + return { agents, serverApiUrl: server.apiUrl }; +} diff --git a/console/apps/switch-console-desktop/src/main/core/agents/remove-loadable-agent-config.ts b/console/apps/switch-console-desktop/src/main/core/agents/remove-loadable-agent-config.ts new file mode 100644 index 000000000..dafa03c4e --- /dev/null +++ b/console/apps/switch-console-desktop/src/main/core/agents/remove-loadable-agent-config.ts @@ -0,0 +1,44 @@ +import { log } from '@main/lib/logger'; +import { resolveWorkspaceFsFor } from './agent-workspace-fs'; +import { agentSettingsRelativePath } from './switch-settings-paths'; + +export type RemoveLoadableAgentConfigParams = { + sshHost: string | null; + dir: string; + name: string; +}; + +export type RemoveLoadableAgentConfigResult = + | { removed: true } + | { removed: false; reason: 'not-found' }; + +/** + * Delete an agent's on-disk config (`.switch/agents/.json`) from a + * working directory, so a stale or wrong-endpoint entry surfaced by "Load + * existing agents" can be cleaned up in place (CHOO-2560). + * + * Deliberately host-file-only: the agent's registration on the Switch server + * is untouched (deregistering is the owner's call, made from an attached + * agent's own page), and no Console rows anywhere are affected. Callers + * should not offer this for agents already loaded in this Console — deleting + * the file under a managed agent breaks its launches. + */ +export async function removeLoadableAgentConfig( + params: RemoveLoadableAgentConfigParams +): Promise { + const relPath = agentSettingsRelativePath(params.name); + const ctx = await resolveWorkspaceFsFor(params.sshHost, params.dir); + try { + const existing = await ctx.fs.read(relPath); + if (existing === null) return { removed: false, reason: 'not-found' }; + await ctx.fs.delete(relPath); + log.info('removeLoadableAgentConfig: deleted agent config', { + sshHost: params.sshHost, + dir: params.dir, + name: params.name, + }); + return { removed: true }; + } finally { + ctx.close(); + } +} diff --git a/console/apps/switch-console-desktop/src/main/core/agents/utils.ts b/console/apps/switch-console-desktop/src/main/core/agents/utils.ts index f3a542541..801b36b72 100644 --- a/console/apps/switch-console-desktop/src/main/core/agents/utils.ts +++ b/console/apps/switch-console-desktop/src/main/core/agents/utils.ts @@ -17,6 +17,7 @@ export function mapAgentRowToAgent(row: AgentRow): Agent { serverId: row.serverId ?? null, status: row.status ?? null, autoApprove: row.autoApprove, + ownerName: row.ownerName ?? null, providerConfig: row.providerConfig ?? null, createdAt: row.createdAt, updatedAt: row.updatedAt, diff --git a/console/apps/switch-console-desktop/src/main/core/agents/write-switch-settings.test.ts b/console/apps/switch-console-desktop/src/main/core/agents/write-switch-settings.test.ts index d3722681d..f0a806d0e 100644 --- a/console/apps/switch-console-desktop/src/main/core/agents/write-switch-settings.test.ts +++ b/console/apps/switch-console-desktop/src/main/core/agents/write-switch-settings.test.ts @@ -10,6 +10,7 @@ import { SWITCH_SETTINGS_RELATIVE_PATH, } from './switch-settings-paths'; import { + ExistingAgentCredentialsError, ForeignAgentCredentialsError, foreignCredentialsEndpoint, foreignCredentialsOwnerFs, @@ -219,11 +220,14 @@ describe('writeNeutralAgentSettingsFs, against another install of Switch Console }, }); + // The caller passes expectedAgentId to confirm it owns the slot — without + // this, the same-endpoint guard refuses the write (CHOO-2560). await writeNeutralAgentSettingsFs(createPluginFs(dir), { slug: 'ours', apiEndpoint: 'https://switch.example.com', apiToken: 'new-token', agentId: 'new-agent', + expectedAgentId: 'old-agent', }); const raw = await fs.readFile(path.join(dir, agentSettingsRelativePath('ours')), 'utf8'); @@ -234,6 +238,25 @@ describe('writeNeutralAgentSettingsFs, against another install of Switch Console }); }); + it('refuses when same-endpoint slot holds a different agent and no expectedAgentId is given (CHOO-2560)', async () => { + await seed('colleague', { + env: { + SWITCH_API_ENDPOINT: 'https://switch.example.com', + SWITCH_API_TOKEN: 'their-token', + SWITCH_AGENT_ID: 'their-agent', + }, + }); + + await expect( + writeNeutralAgentSettingsFs(createPluginFs(dir), { + slug: 'colleague', + apiEndpoint: 'https://switch.example.com', + apiToken: 'our-token', + agentId: 'our-agent', + }) + ).rejects.toBeInstanceOf(ExistingAgentCredentialsError); + }); + it('reports the owning server through foreignCredentialsOwnerFs, for a caller checking before it registers', async () => { await seed('shared-name', otherInstall); const workspaceFs = createPluginFs(dir); diff --git a/console/apps/switch-console-desktop/src/main/core/agents/write-switch-settings.ts b/console/apps/switch-console-desktop/src/main/core/agents/write-switch-settings.ts index 3ca57d23c..b9019e6ad 100644 --- a/console/apps/switch-console-desktop/src/main/core/agents/write-switch-settings.ts +++ b/console/apps/switch-console-desktop/src/main/core/agents/write-switch-settings.ts @@ -367,6 +367,36 @@ export class ForeignAgentCredentialsError extends Error { } } +/** + * The credentials file for this agent name already belongs to a different + * agent on the SAME Switch deployment. Writing would overwrite that agent's + * identity and destroy its API token — the same hazard as + * {@link ForeignAgentCredentialsError}, but between agents on the same server + * rather than across deployments (CHOO-2560). + */ +export class ExistingAgentCredentialsError extends Error { + readonly slug: string; + readonly relPath: string; + readonly existingAgentId: string; + readonly incomingAgentId: string; + + constructor(params: { + slug: string; + relPath: string; + existingAgentId: string; + incomingAgentId: string; + }) { + super( + `${params.relPath} in this directory already holds credentials for agent ${params.existingAgentId} on this Switch server. Writing it would overwrite that agent's identity and destroy its API token, which cannot be recovered. Load the existing agent instead, or use a different agent name.` + ); + this.name = 'ExistingAgentCredentialsError'; + this.slug = params.slug; + this.relPath = params.relPath; + this.existingAgentId = params.existingAgentId; + this.incomingAgentId = params.incomingAgentId; + } +} + /** * Read the per-agent credentials slot for `slug` and report the Switch * deployment it already belongs to, when that is a different one. Callers use @@ -383,6 +413,49 @@ export async function foreignCredentialsOwnerFs( return foreignCredentialsEndpoint(existingRaw, apiEndpoint); } +/** + * The `SWITCH_AGENT_ID` already in a credentials slot when the file belongs to + * the SAME deployment — otherwise null (no file, not a provisioned agent, or a + * different deployment). + * + * This is the inverse of {@link foreignCredentialsEndpoint}: that one catches + * a different-server collision; this one catches a same-server collision where + * the identity exists on-disk but is unknown to this install's database — i.e. + * a colleague's agent that would be clobbered by a blind write. + * + * Pure: takes the existing file text, or null when no file exists. A failed + * read is not null — `PluginFs.read` throws on transport errors, and callers + * must let that propagate: mapping it to null would make a transient error + * read as "slot free" and clobber real credentials. + */ +export function existingAgentIdInSlot( + existingRaw: string | null, + apiEndpoint: string +): string | null { + if (existingRaw === null) return null; + + let parsed: unknown; + try { + parsed = JSON.parse(existingRaw); + } catch { + return null; + } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null; + + const env = (parsed as Record).env; + if (!env || typeof env !== 'object' || Array.isArray(env)) return null; + + const existingEndpoint = (env as Record).SWITCH_API_ENDPOINT; + if (typeof existingEndpoint !== 'string' || existingEndpoint.trim() === '') return null; + + if (!sameApiEndpoint(existingEndpoint, apiEndpoint)) return null; + + const agentId = (env as Record).SWITCH_AGENT_ID; + if (typeof agentId !== 'string' || agentId.trim() === '') return null; + + return agentId; +} + /** * 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` @@ -402,7 +475,7 @@ export async function foreignCredentialsOwnerFs( */ export async function writeNeutralAgentSettingsFs( workspaceFs: PluginFs, - params: { slug: string } & SwitchSettingsCredentials + params: { slug: string; expectedAgentId?: string } & SwitchSettingsCredentials ): Promise { const relPath = agentSettingsRelativePath(params.slug); const existingRaw = await workspaceFs.read(relPath); @@ -416,6 +489,25 @@ export async function writeNeutralAgentSettingsFs( }); } + // Defence in depth: if the slot holds a same-endpoint identity that is NOT + // the one we are about to write, refuse — it belongs to a colleague's agent + // and overwriting it would destroy their token (CHOO-2560). Callers that have + // already verified the overwrite is safe pass `expectedAgentId` to bypass this + // guard for their own agent (e.g. runAddAgent after its pre-mint DB lookup). + const slotAgentId = existingAgentIdInSlot(existingRaw, params.apiEndpoint); + if ( + slotAgentId !== null && + slotAgentId !== params.agentId && + slotAgentId !== params.expectedAgentId + ) { + throw new ExistingAgentCredentialsError({ + slug: params.slug, + relPath, + existingAgentId: slotAgentId, + incomingAgentId: params.agentId, + }); + } + if (!(await workspaceFs.exists(SWITCH_AGENTS_GITIGNORE_RELATIVE))) { await workspaceFs.write(SWITCH_AGENTS_GITIGNORE_RELATIVE, '*\n'); } diff --git a/console/apps/switch-console-desktop/src/main/core/switch-servers/backfill-agent-icons.test.ts b/console/apps/switch-console-desktop/src/main/core/switch-servers/backfill-agent-icons.test.ts index b765f6040..90aee30fa 100644 --- a/console/apps/switch-console-desktop/src/main/core/switch-servers/backfill-agent-icons.test.ts +++ b/console/apps/switch-console-desktop/src/main/core/switch-servers/backfill-agent-icons.test.ts @@ -42,6 +42,7 @@ function agent(overrides: Partial): RemoteAgentSummary { ownerId: 'user-me', ownerName: 'me', knownAgentType: 'claude-code', + knownAgentOptions: null, addressingPolicy: null, iconUrl: null, createdAt: '2026-01-01T00:00:00Z', diff --git a/console/apps/switch-console-desktop/src/main/core/switch-servers/delete-server-agents.db.test.ts b/console/apps/switch-console-desktop/src/main/core/switch-servers/delete-server-agents.db.test.ts index b38f92171..f9167e769 100644 --- a/console/apps/switch-console-desktop/src/main/core/switch-servers/delete-server-agents.db.test.ts +++ b/console/apps/switch-console-desktop/src/main/core/switch-servers/delete-server-agents.db.test.ts @@ -82,6 +82,7 @@ describe('deleteAgentsForServer', () => { expect(mocks.deleteAgent).toHaveBeenCalledWith('agent-a', { deleteInSwitch: false, + removeProvisionedFiles: true, // Wiping a server is one action, not a person deleting each agent. trigger: 'server_teardown', }); diff --git a/console/apps/switch-console-desktop/src/main/core/switch-servers/delete-server-agents.ts b/console/apps/switch-console-desktop/src/main/core/switch-servers/delete-server-agents.ts index 2a3ebd6e1..6fd47dbde 100644 --- a/console/apps/switch-console-desktop/src/main/core/switch-servers/delete-server-agents.ts +++ b/console/apps/switch-console-desktop/src/main/core/switch-servers/delete-server-agents.ts @@ -40,7 +40,13 @@ export async function deleteAgentsForServer( for (const row of rows) { try { - await deleteAgent(row.id, { deleteInSwitch: false, trigger: 'server_teardown' }); + // A destroyed managed server's agents were provisioned by this install, + // so their on-disk files go with them. + await deleteAgent(row.id, { + deleteInSwitch: false, + removeProvisionedFiles: true, + trigger: 'server_teardown', + }); deleted.push(row.id); } catch (error) { failed.push({ agentId: row.id, error: String(error) }); diff --git a/console/apps/switch-console-desktop/src/main/core/switch-servers/gateway-client.ts b/console/apps/switch-console-desktop/src/main/core/switch-servers/gateway-client.ts index cf5b98038..55a1825a6 100644 --- a/console/apps/switch-console-desktop/src/main/core/switch-servers/gateway-client.ts +++ b/console/apps/switch-console-desktop/src/main/core/switch-servers/gateway-client.ts @@ -353,6 +353,7 @@ type AgentSummaryJson = { owner_id?: string | null; owner_name: string | null; known_agent_type: string | null; + known_agent_options?: Record | null; addressing_policy?: AddressingPolicy | null; icon_url?: string | null; created_at: string; @@ -371,6 +372,7 @@ function toRemoteAgentSummary(json: AgentSummaryJson): RemoteAgentSummary { ownerId: json.owner_id ?? null, ownerName: json.owner_name, knownAgentType: json.known_agent_type, + knownAgentOptions: json.known_agent_options ?? null, addressingPolicy: json.addressing_policy ?? null, iconUrl: json.icon_url ?? null, createdAt: json.created_at, diff --git a/console/apps/switch-console-desktop/src/main/core/telemetry/events.ts b/console/apps/switch-console-desktop/src/main/core/telemetry/events.ts index d8c676766..582bb22ca 100644 --- a/console/apps/switch-console-desktop/src/main/core/telemetry/events.ts +++ b/console/apps/switch-console-desktop/src/main/core/telemetry/events.ts @@ -51,6 +51,7 @@ export type TelemetryAgentCreateFailure = | 'unauthenticated' | 'name_conflict' | 'credentials_conflict' + | 'already_configured' | 'invalid_name' /** * The two the other way into this — dropping a folder on the sidebar — hits diff --git a/console/apps/switch-console-desktop/src/main/db/schema.ts b/console/apps/switch-console-desktop/src/main/db/schema.ts index 98ce833e0..539b0e919 100644 --- a/console/apps/switch-console-desktop/src/main/db/schema.ts +++ b/console/apps/switch-console-desktop/src/main/db/schema.ts @@ -161,6 +161,9 @@ export const agents = sqliteTable( // Defaults false for local agents; onboarding seeds it true for remote // agents (see onboard-agent). Editable per agent in location settings. autoApprove: integer('auto_approve', { mode: 'boolean' }).notNull().default(false), + // The display name of the agent's owner on the Switch server, set when the + // agent was loaded from another install rather than created here. + ownerName: text('owner_name'), // Per-agent, provider-specific launch config (Codex model / effort / // instructions folded into the agent's Codex profile). Null when unset. providerConfig: versionedJsonColumn(agentProviderConfig)('provider_config'), diff --git a/console/apps/switch-console-desktop/src/main/index.ts b/console/apps/switch-console-desktop/src/main/index.ts index 59a96b772..15b449016 100644 --- a/console/apps/switch-console-desktop/src/main/index.ts +++ b/console/apps/switch-console-desktop/src/main/index.ts @@ -11,6 +11,7 @@ import { registerAppScheme, setupAppProtocol } from './app/protocol'; import { createMainWindow, getMainWindow } from './app/window'; import { agentHookService } from './core/agent-hooks/agent-hook-service'; import { reapOrphanedAgentRuntimes } from './core/agent-runtime/reap-orphaned-runtimes'; +import { bridgeAgentEventsToRenderer } from './core/agents/agent-events-renderer-bridge'; import { migrateAgentStorage } from './core/agents/migrate-agent-storage'; import { initializeRemoteDiscovery, initializeRemoteWatchers } from './core/agents/remote-watcher'; import { resolveAgentServers } from './core/agents/resolve-servers'; @@ -231,6 +232,7 @@ void app.whenReady().then(async () => { log.error('Failed to initialise remote watchers at startup:', e); } try { + bridgeAgentEventsToRenderer(); await initializeRemoteDiscovery(); } catch (e) { log.error('Failed to initialise remote session discovery at startup:', e); diff --git a/console/apps/switch-console-desktop/src/renderer/App.tsx b/console/apps/switch-console-desktop/src/renderer/App.tsx index 3e5c3e088..05264a712 100644 --- a/console/apps/switch-console-desktop/src/renderer/App.tsx +++ b/console/apps/switch-console-desktop/src/renderer/App.tsx @@ -1,4 +1,5 @@ import { QueryClientProvider } from '@tanstack/react-query'; +import { AgentCrudEvents } from './app/agent-crud-events'; import { AppMenuEvents } from './app/app-menu-events'; import { Workspace } from './app/workspace'; import { SessionFocusReporter } from './features/sessions/session-focus-reporter-mount'; @@ -20,6 +21,7 @@ function AppContent() { + diff --git a/console/apps/switch-console-desktop/src/renderer/app/agent-crud-events.tsx b/console/apps/switch-console-desktop/src/renderer/app/agent-crud-events.tsx new file mode 100644 index 000000000..64b91b02f --- /dev/null +++ b/console/apps/switch-console-desktop/src/renderer/app/agent-crud-events.tsx @@ -0,0 +1,29 @@ +import { useQueryClient } from '@tanstack/react-query'; +import { useEffect } from 'react'; +import { agentsStore } from '@renderer/features/locations/stores/agents-store'; +import { getLocationManagerStore } from '@renderer/features/locations/stores/location-selectors'; +import { LOAD_AGENTS_QUERY_KEY } from '@renderer/features/remote-hosts/load-existing-agents-section'; +import { events } from '@renderer/lib/ipc'; +import { agentsChangedChannel } from '@shared/events/appEvents'; + +/** + * React to agent CRUD from the main process (CHOO-2560): reload the sidebar's + * agents store AND location manager, and refetch any open "Load existing + * agents" discovery, so a created or removed agent is reflected everywhere + * without per-call-site invalidation. + */ +export function AgentCrudEvents() { + const queryClient = useQueryClient(); + + useEffect( + () => + events.on(agentsChangedChannel, () => { + void agentsStore.load(); + void getLocationManagerStore().reload(); + void queryClient.invalidateQueries({ queryKey: [LOAD_AGENTS_QUERY_KEY] }); + }), + [queryClient] + ); + + return null; +} diff --git a/console/apps/switch-console-desktop/src/renderer/app/modal-registry.ts b/console/apps/switch-console-desktop/src/renderer/app/modal-registry.ts index ee1720368..22808fac9 100644 --- a/console/apps/switch-console-desktop/src/renderer/app/modal-registry.ts +++ b/console/apps/switch-console-desktop/src/renderer/app/modal-registry.ts @@ -3,6 +3,7 @@ import { AddAgentModal } from '@renderer/features/locations/components/add-agent import { DeleteAgentModal } from '@renderer/features/locations/components/delete-agent-modal'; import { ResetAgentModal } from '@renderer/features/locations/components/reset-agent-modal'; import { AddHostModal } from '@renderer/features/remote-hosts/add-host-modal'; +import { RemoveAgentConfigModal } from '@renderer/features/remote-hosts/remove-agent-config-modal'; import { CreateSessionModal } from '@renderer/features/sessions/create-session-modal/create-session-modal'; import { DeleteSessionModal } from '@renderer/features/sessions/delete-session-modal'; import { RenameSessionModal } from '@renderer/features/sessions/rename-session-modal'; @@ -52,6 +53,7 @@ export const modalRegistry = { addAgentModal: createModal(AddAgentModal, { size: 'lg', dismissOnOutsideClick: false }), confirmActionModal: createModal(ConfirmActionDialog, { size: 'xs' }), deleteAgentModal: createModal(DeleteAgentModal, { size: 'sm' }), + removeAgentConfigModal: createModal(RemoveAgentConfigModal, { size: 'sm' }), resetAgentModal: createModal(ResetAgentModal, { size: 'sm' }), confirmExternalLinkModal: createModal(ExternalLinkChoiceDialog, { size: 'sm' }), unsavedChangesModal: createModal(UnsavedChangesDialog, { size: 'xs' }), diff --git a/console/apps/switch-console-desktop/src/renderer/features/locations/components/add-agent-modal/add-agent-modal.tsx b/console/apps/switch-console-desktop/src/renderer/features/locations/components/add-agent-modal/add-agent-modal.tsx index 435c23376..91c607534 100644 --- a/console/apps/switch-console-desktop/src/renderer/features/locations/components/add-agent-modal/add-agent-modal.tsx +++ b/console/apps/switch-console-desktop/src/renderer/features/locations/components/add-agent-modal/add-agent-modal.tsx @@ -293,6 +293,15 @@ export const AddAgentModal = observer(function AddAgentModal({ }); return; } + if (result.kind === 'already-configured') { + toast({ + title: 'An agent with this name is already configured here', + description: + 'This directory already holds credentials for an agent of that name. Load the existing agent instead of creating a new one.', + variant: 'destructive', + }); + return; + } if (result.kind === 'invalid-name') { toast({ title: 'Switch rejected these agent details', diff --git a/console/apps/switch-console-desktop/src/renderer/features/locations/components/delete-agent-modal.tsx b/console/apps/switch-console-desktop/src/renderer/features/locations/components/delete-agent-modal.tsx index b56fa6431..f1346e772 100644 --- a/console/apps/switch-console-desktop/src/renderer/features/locations/components/delete-agent-modal.tsx +++ b/console/apps/switch-console-desktop/src/renderer/features/locations/components/delete-agent-modal.tsx @@ -15,15 +15,21 @@ export type DeleteAgentModalArgs = { agentId: string; /** Display name for the agent (its Switch name, falling back to the location). */ agentLabel: string; + /** The agent's host (null = this machine), for naming where its files live. */ + sshHost: string | null; + /** The agent's working directory, for naming where its files live. */ + dir: string | null; }; -/** What the confirm resolves with: whether to also delete the agent in Switch. */ -export type DeleteAgentModalResult = { deleteInSwitch: boolean }; +/** What the confirm resolves with: what to tear down beyond this Console's row. */ +export type DeleteAgentModalResult = { deleteInSwitch: boolean; removeProvisionedFiles: boolean }; type Props = BaseModalProps & DeleteAgentModalArgs; -export function DeleteAgentModal({ agentLabel, onSuccess, onClose }: Props) { +export function DeleteAgentModal({ agentLabel, sshHost, dir, onSuccess, onClose }: Props) { const [deleteInSwitch, setDeleteInSwitch] = useState(false); + const [removeProvisionedFiles, setRemoveProvisionedFiles] = useState(false); + const filesPlace = dir ? (sshHost ? `${sshHost}:${dir}` : dir) : null; return ( <> @@ -33,10 +39,32 @@ export function DeleteAgentModal({ agentLabel, onSuccess, onClose }: Props) {

{agentLabel} will be removed from - Switch Console and the Switch credentials it stored on this machine will be cleared. The - folder stays on the filesystem. + Switch Console. Its working directory, the credentials stored there, and any running + sidecar are untouched unless you choose below.

+ {filesPlace && ( + + )} +