Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
69d5efe
fix(switchdash): offer to create a missing remote working directory (…
amaudruz Aug 6, 2026
5759298
fix(switchdash): only offer to create a remote directory that can be …
amaudruz Aug 6, 2026
6e0697a
fix(switchdash): report a missing remote working directory instead of…
amaudruz Aug 6, 2026
1962bf4
fix(switchdash): only refuse a remote directory whose parent is missi…
amaudruz Aug 6, 2026
68c815a
Merge origin/main into remote-agent-missing-parent-dir (CHOO-1416)
amaudruz Aug 6, 2026
c8df3e0
fix(switchdash): don't ask for agent details a location cannot hold (…
amaudruz Aug 6, 2026
3172bd7
fix(switchdash): a pending directory check is not a failed one (CHOO-…
amaudruz Aug 6, 2026
4c74ba9
fix(switchdash): an agent already in a directory does not block addin…
amaudruz Aug 6, 2026
cf1e6a4
refactor(switchdash): drop the unused legacy credential writers (CHOO…
amaudruz Aug 6, 2026
f24c26c
fix(switchdash): propagate a server API URL to the file agents actual…
amaudruz Aug 6, 2026
a8a12bc
feat(switchdash): title the agent page with the agent, and drop folde…
amaudruz Aug 6, 2026
20a2985
fix(switchdash): propagate a server API URL only to the per-agent fil…
amaudruz Aug 6, 2026
5232cb9
Merge remote-tracking branch 'origin/main' into bug-fix/remote-agent-…
amaudruz Aug 6, 2026
620a1c3
refactor(switchdash): stop treating the shared settings file as an id…
amaudruz Aug 6, 2026
97794f1
refactor(switchdash): stop reading credentials from the shared settin…
amaudruz Aug 6, 2026
f2044ae
fix(switchdash): switching agent type no longer re-checks the locatio…
amaudruz Aug 6, 2026
c0a8739
Merge remote-tracking branch 'origin/main' into bug-fix/remote-agent-…
amaudruz Aug 7, 2026
45ddef3
Merge origin/main into remote-agent-missing-parent-dir (CHOO-1416)
amaudruz Aug 7, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -167,13 +167,10 @@ 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
// A session talks to Switch as its own agent. 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/<slug>.json`
// (empty when absent — the session then falls back to settings.local.json,
// which Claude reads natively).
// and a plain agent from its provider-neutral `.switch/agents/<slug>.json`.
// Resolved before the command is built because a provider that registers
// the Switch server at launch keys it on this identity (see below).
const workspaceFs = createPluginFs(this.sessionPath);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ const h = vi.hoisted(() => {
apiKey: 'tok-123',
})),
createAgent: vi.fn(async (input: Record<string, unknown>) => ({ ...input })),
inspectRemoteDir: vi.fn(async (_host: string, dir: string) => ({
dir,
status: 'directory' as const,
})),
};
});

Expand All @@ -65,6 +69,9 @@ vi.mock('@main/core/locations/store', () => ({
getLocationByHostDir: vi.fn(async () => ({ id: 'loc-1' })),
}));
vi.mock('./agent-name-taken', () => ({ agentNameTaken: h.agentNameTaken }));
// Also keeps ssh-fs (and through it the Electron-bound db) out of this file's
// module graph.
vi.mock('./remote-dir', () => ({ inspectRemoteDir: h.inspectRemoteDir }));
vi.mock('@main/core/locations/path-utils', () => ({ checkIsValidDirectory: () => true }));
vi.mock('@main/core/locations/location-manager', () => ({
locationManager: { openLocation: vi.fn(async () => {}) },
Expand Down Expand Up @@ -105,6 +112,10 @@ describe('addAgent', () => {
h.state.repoAgents = { writeDefinition: h.writeDefinition };
h.state.workspace = fakeFs();
h.registerAgentIdentity.mockResolvedValue({ kind: 'created', id: 'sw-1', apiKey: 'tok-123' });
h.inspectRemoteDir.mockImplementation(async (_host: string, dir: string) => ({
dir,
status: 'directory' as const,
}));
});

it('writes name-keyed credentials for a provider with no repo-agent definitions', async () => {
Expand Down Expand Up @@ -176,4 +187,53 @@ describe('addAgent', () => {
expect(h.registerAgentIdentity).not.toHaveBeenCalled();
expect(h.createAgent).not.toHaveBeenCalled();
});

describe('remote working directory', () => {
const remote = { sshHost: 'louis-1-cluster', dir: '/home/ubuntu/switch-agents/deploys' };

it('reports a missing remote directory without minting an identity', async () => {
// The whole point of checking first: this used to surface as a raw
// FileSystemError from the first credentials write, leaving the agent on
// the gateway but nowhere else (CHOO-1416).
h.inspectRemoteDir.mockResolvedValue({ dir: remote.dir, status: 'missing' } as never);

const result = await addAgent(params(remote));

expect(result).toEqual({
kind: 'directory-missing',
sshHost: remote.sshHost,
inspection: expect.objectContaining({ status: 'missing' }),
});
expect(h.registerAgentIdentity).not.toHaveBeenCalled();
expect(h.createAgent).not.toHaveBeenCalled();
});

it('refuses a remote path that is a file', async () => {
h.inspectRemoteDir.mockResolvedValue({ dir: remote.dir, status: 'file' } as never);

expect((await addAgent(params(remote))).kind).toBe('directory-missing');
expect(h.registerAgentIdentity).not.toHaveBeenCalled();
});

it('proceeds when the remote directory exists', async () => {
const result = await addAgent(params(remote));

expect(h.inspectRemoteDir).toHaveBeenCalledWith(remote.sshHost, remote.dir);
expect(result.kind).toBe('created');
});

// The credentials write creates a leaf under an existing parent by itself,
// so gating on "exists" would have broken a path that worked before.
it('proceeds when the remote directory does not exist but its parent does', async () => {
h.inspectRemoteDir.mockResolvedValue({ dir: remote.dir, status: 'creatable' } as never);

expect((await addAgent(params(remote))).kind).toBe('created');
expect(h.registerAgentIdentity).toHaveBeenCalled();
});

it('does not probe over SSH for a local agent', async () => {
await addAgent(params());
expect(h.inspectRemoteDir).not.toHaveBeenCalled();
});
});
});
24 changes: 24 additions & 0 deletions dash/apps/switchdash-desktop/src/main/core/agents/add-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,15 @@ import { log } from '@main/lib/logger';
import type { AgentProviderConfig } from '@shared/core/agents/agent-provider-config';
import type { Agent } from '@shared/core/agents/agents';
import type { AgentProviderId } from '@shared/core/providers/agent-provider-registry';
import { isUsableRemoteDir, type RemoteDirInspection } from '@shared/core/remote-hosts/remote-dir';
import { basenameFromAnyPath } from '@shared/path-name';
import { agentEvents } from './agent-events';
import { agentNameTaken } from './agent-name-taken';
import { resolveWorkspaceFsFor } from './agent-workspace-fs';
import { createAgent } from './createAgent';
import { knownAgentTypeForProvider } from './known-agent-type';
import { registerAgentIdentity } from './register-agent-identity';
import { inspectRemoteDir } from './remote-dir';
import { reconcileAgentAutoSessionFromGateway } from './setAgentAutoSession';
import { writeNeutralAgentSettingsFs } from './write-switch-settings';

Expand Down Expand Up @@ -51,6 +53,11 @@ export type AddAgentResult =
| { kind: 'unauthenticated' }
| { kind: 'name-conflict' }
| { kind: 'invalid-name'; message: string }
/** The remote working directory is unusable: it is a file, or neither it nor
* its parent exists so the credentials write cannot create it. Reported
* before anything is minted, so no Switch-side agent is left behind
* (CHOO-1416). */
| { kind: 'directory-missing'; sshHost: string; inspection: RemoteDirInspection }
| { kind: 'error'; message: string };

/**
Expand All @@ -65,11 +72,28 @@ export type AddAgentResult =
* written to disk and never returned. A recoverable gateway failure is mapped to
* a typed result the modal can act on; a filesystem failure after registration
* throws (leaving the gateway agent, as the pre-existing provision path did).
*
* Both run locations therefore check the working directory *before* minting the
* identity — locally with `checkIsValidDirectory`, remotely with
* `inspectRemoteDir`. The remote check refuses only what the write genuinely
* cannot handle: a file, or a directory whose parent is missing too. A missing
* directory under an existing parent still goes through and is created by the
* write, as it always was. The refused case used to surface as a raw
* `FileSystemError` from the first credentials write, by which point the agent
* existed on the gateway but nowhere else (CHOO-1416). Ordering the check first
* removes that orphan for this failure; creation as a whole is still not atomic
* (CHOO-1415).
*/
export async function addAgent(params: AddAgentParams): Promise<AddAgentResult> {
if (params.sshHost === null && !checkIsValidDirectory(params.dir)) {
return { kind: 'error', message: `Invalid directory: ${params.dir}` };
}
if (params.sshHost !== null) {
const inspection = await inspectRemoteDir(params.sshHost, params.dir);
if (!isUsableRemoteDir(inspection)) {
return { kind: 'directory-missing', sshHost: params.sshHost, inspection };
}
}

const server = await getServer(params.serverId);
if (!server) return { kind: 'error', message: `No Switch server with id ${params.serverId}` };
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import type { RepoAgentAttributes } from '@switchdash/core/agents/plugins';
import type { CreateAgentParams, RenameAgentParams } from '@shared/core/agents/agents';
import type { OnboardAgentParams } from '@shared/core/agents/onboarding';
import type { AgentProviderId } from '@shared/core/providers/agent-provider-registry';
import type { AgentVerifyResult } from '@shared/core/switch-servers/switch-servers';
import { createRPCController } from '@shared/lib/ipc/rpc';
Expand All @@ -18,7 +17,6 @@ import { discoverConfiguredAgents } from './discover-configured-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 { renameAgent } from './renameAgent';
import { resetRemoteAgent } from './reset-remote-agent';
Expand All @@ -38,7 +36,6 @@ export const agentsController = createRPCController({
readAgentDefinition: (params: { agentId: string }) => readAgentDefinition(params.agentId),
updateAgentDefinition: (params: { agentId: string; attributes: RepoAgentAttributes }) =>
updateAgentDefinition(params),
onboardAgent: (params: OnboardAgentParams) => onboardAgent(params),
onboardLocationAgents: (params: OnboardLocationParams) => onboardLocationAgents(params),
discoverLocationAgents: (params: {
sshHost: string | null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ import { connectRemoteAgent } from './connect-remote-agent';
import { getAgentById } from './getAgentById';
import { stopRemoteWatcher } from './remote-watcher';
import { removeAgentLaunchProfile } from './remove-launch-profile';
import { removeSwitchCredentials } from './remove-switch-settings';
import { agentSettingsRelativePath } from './switch-settings-paths';

export type DeleteAgentOptions = {
Expand Down Expand Up @@ -96,7 +95,6 @@ async function removeProvisionedFiles(agent: Agent, location: Location): Promise
error: String(error),
});
});
await removeSwitchCredentials(agent.providerId, ctx.fs);
// A provider that registers the Switch server itself (Codex) leaves a
// per-agent launch profile under the user's home — a different scope than
// ctx.fs, reached through its own home filesystem (local or remote).
Expand Down

This file was deleted.

This file was deleted.

Loading
Loading