Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions connectors/claude-code-plugin/skills/switch/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions connectors/codex-plugin/skills/switch/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions connectors/opencode-plugin/skills/switch/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE `agents` ADD `owner_name` text;
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
]
}
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -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 };

Expand All @@ -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',
};
Expand Down Expand Up @@ -182,6 +187,28 @@ async function runAddAgent(params: AddAgentParams): Promise<AddAgentResult> {
});
}

// 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,
Expand All @@ -206,6 +233,7 @@ async function runAddAgent(params: AddAgentParams): Promise<AddAgentResult> {
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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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/<slug>.json` in a
Expand Down Expand Up @@ -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<string | null> {
const workspace = await resolveWorkspaceFsFor(sshHost, dir);
try {
const existingRaw = await workspace.fs.read(agentSettingsRelativePath(slug));
return existingAgentIdInSlot(existingRaw, apiEndpoint);
} finally {
workspace.close();
}
}
Original file line number Diff line number Diff line change
@@ -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' }));
}
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Agent[], OnboardAgentError>;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -159,6 +160,7 @@ export async function attachConfiguredAgents(
apiEndpoint: found.apiEndpoint,
serverId: params.serverId,
autoApprove: params.sshHost !== null,
ownerName: ownerName ?? null,
});
created.push(agent);

Expand All @@ -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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export async function createAgent(params: CreateAgentParams): Promise<Agent> {
apiEndpoint: params.apiEndpoint,
serverId: params.serverId,
autoApprove: params.autoApprove,
ownerName: params.ownerName ?? null,
providerConfig: params.providerConfig ?? null,
updatedAt: sql`CURRENT_TIMESTAMP`,
})
Expand Down
Loading
Loading