Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ import type { PluginFs } from '@switch-console/core/agents/plugins';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { agentSettingsRelativePath } from './switch-settings-paths';

const inspectRemoteDir = vi.hoisted(() => vi.fn());
vi.mock('./remote-dir', () => ({ inspectRemoteDir }));

/** In-memory {@link PluginFs} keyed by the exact relative paths the writers use. */
function fakeFs(seed: Record<string, string> = {}): PluginFs {
const files = new Map<string, string>(Object.entries(seed));
Expand Down Expand Up @@ -125,6 +128,20 @@ describe('addAgent', () => {
h.registerAgentIdentity.mockResolvedValue({ kind: 'created', id: 'sw-1', apiKey: 'tok-123' });
});

it('refuses a remote directory whose parent is missing, before minting (CHOO-1416)', async () => {
inspectRemoteDir.mockResolvedValue({ dir: '/home/u/agents/deploy', status: 'missing' });

const result = await addAgent(params({ sshHost: 'vm-1', dir: '/home/u/agents/deploy' }));

expect(result.kind).toBe('directory-missing');
expect(h.registerAgentIdentity).not.toHaveBeenCalled();
});

it('never inspects the remote directory for a local add', async () => {
await addAgent(params());
expect(inspectRemoteDir).not.toHaveBeenCalled();
});

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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { agentAvatarUrlForName } from '@shared/core/agents/agent-avatar';
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 type { UiEntryPoint } from '@shared/core/telemetry/reporting';
import { basenameFromAnyPath } from '@shared/path-name';
import { writeAgentConfigFile } from './agent-config-file';
Expand All @@ -25,6 +26,7 @@ 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 @@ -70,6 +72,11 @@ export type AddAgentResult =
| { kind: 'name-conflict' }
| { kind: 'credentials-conflict'; endpoint: string }
| { 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 };

/** The result's discriminant as a reportable code. Never its message. */
Expand All @@ -81,6 +88,7 @@ const ADD_AGENT_FAILURE_REASON: Record<
'name-conflict': 'name_conflict',
'credentials-conflict': 'credentials_conflict',
'invalid-name': 'invalid_name',
'directory-missing': 'directory_missing',
error: 'error',
};

Expand Down Expand Up @@ -143,6 +151,20 @@ async function runAddAgent(params: AddAgentParams): Promise<AddAgentResult> {
message: `Invalid directory: ${params.dir}`,
});
}
if (params.sshHost !== null) {
// Checked before minting: the directory is the one free-text input in the
// flow, and failing at write time used to leave an agent registered on the
// gateway with nothing on disk (CHOO-1416). A missing directory under an
// existing parent still goes through — the first write creates it.
const inspection = await inspectRemoteDir(params.sshHost, params.dir);
if (!isUsableRemoteDir(inspection)) {
return reportFailedCreate(params, {
kind: 'directory-missing',
sshHost: params.sshHost,
inspection,
});
}
}

const server = await getServer(params.serverId);
if (!server) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { FileSystemError, FileSystemErrorCodes } from '@main/core/fs/types';

const stat = vi.hoisted(() => vi.fn());
const close = vi.hoisted(() => vi.fn());
const constructedWith = vi.hoisted(() => [] as string[]);

vi.mock('@main/core/fs/impl/ssh-fs', () => ({
SshFileSystem: class {
constructor(_proxy: unknown, base: string) {
constructedWith.push(base);
}
stat = stat;
close = close;
},
}));
vi.mock('@main/core/locations/location-transport', () => ({
sshConnectionIdForHost: (host: string) => `conn:${host}`,
}));
vi.mock('@main/core/ssh/connect/connect-agent-ssh', () => ({
ensureSshConnected: vi.fn(async () => ({})),
}));

const { inspectRemoteDir } = await import('./remote-dir');

/** Report `paths` as existing directories and everything else as absent. */
function existingDirs(paths: string[]) {
stat.mockImplementation(async (path: string) =>
paths.includes(path) ? { path, type: 'dir' } : null
);
}

const REPO_DIR = '/home/ubuntu/switch-agents/internal-deployments';

beforeEach(() => {
vi.clearAllMocks();
constructedWith.length = 0;
});

describe('inspectRemoteDir', () => {
it('reports an existing directory', async () => {
existingDirs([REPO_DIR]);

expect(await inspectRemoteDir('host', REPO_DIR)).toEqual({
dir: REPO_DIR,
status: 'directory',
});
});

// Long-standing behaviour, and not something this ticket should take away:
// recursive mkdir may create the working directory itself, just not its
// ancestors, so a missing leaf under an existing parent needs no intervention.
it('reports a missing directory whose parent exists as creatable', async () => {
existingDirs(['/home/ubuntu/switch-agents']);

expect(await inspectRemoteDir('host', REPO_DIR)).toEqual({
dir: REPO_DIR,
status: 'creatable',
});
});

// The ticket's repro: the parent is missing too, so the write cannot recover.
it('reports a missing directory whose parent is also missing', async () => {
existingDirs(['/home/ubuntu']);

expect(await inspectRemoteDir('host', REPO_DIR)).toEqual({
dir: REPO_DIR,
status: 'missing',
});
// Opened at the host root: an FS rooted at the missing directory could not
// stat its way out to look at the parent.
expect(constructedWith).toEqual(['/']);
});

it('reports a path that is a file', async () => {
stat.mockImplementation(async (path: string) =>
path === REPO_DIR ? { path, type: 'file' } : null
);

expect(await inspectRemoteDir('host', REPO_DIR)).toEqual({ dir: REPO_DIR, status: 'file' });
});

it('refuses a directory whose parent is a file', async () => {
stat.mockImplementation(async (path: string) =>
path === '/home/ubuntu/switch-agents' ? { path, type: 'file' } : null
);

expect(await inspectRemoteDir('host', REPO_DIR)).toEqual({ dir: REPO_DIR, status: 'missing' });
});

// An unreadable path is not a missing one; saying so would send the user off
// to create a directory that is already there.
it('propagates a probe failure that is not absence', async () => {
stat.mockRejectedValue(
new FileSystemError('Permission denied: /home', FileSystemErrorCodes.PERMISSION_DENIED)
);

await expect(inspectRemoteDir('host', REPO_DIR)).rejects.toThrow('Permission denied');
});

it('rejects a relative path rather than resolving it against the login dir', async () => {
await expect(inspectRemoteDir('host', 'switch-agents/repo')).rejects.toThrow(
'must be an absolute path'
);
expect(stat).not.toHaveBeenCalled();
});

it('normalises a trailing slash', async () => {
existingDirs([REPO_DIR]);

expect(await inspectRemoteDir('host', `${REPO_DIR}/`)).toMatchObject({ dir: REPO_DIR });
});

it('closes the SFTP channel even when the probe throws', async () => {
stat.mockRejectedValue(new Error('boom'));

await expect(inspectRemoteDir('host', REPO_DIR)).rejects.toThrow('boom');
expect(close).toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { posix as pathPosix } from 'node:path';
import { SshFileSystem } from '@main/core/fs/impl/ssh-fs';
import { sshConnectionIdForHost } from '@main/core/locations/location-transport';
import { ensureSshConnected } from '@main/core/ssh/connect/connect-agent-ssh';
import type { RemoteDirInspection } from '@shared/core/remote-hosts/remote-dir';

/**
* Inspect a prospective remote working directory on `sshHost` (CHOO-1416).
*
* Only two stats, because only two things decide the outcome: whether the
* directory is there, and — if not — whether its parent is. A missing
* directory under an existing parent is created by the first credentials
* write, as it always has been; a missing parent is not, because the
* working directory's own FS is rooted at the directory and its recursive
* mkdir stops there.
*
* The FS here is opened at the host root instead, since one rooted at a
* missing directory cannot stat its way out to look at the parent.
*
* `dir` must be absolute — a relative path would resolve against whatever
* directory the SSH session happens to start in, which is not a thing the user
* chose.
*
* A path that cannot be stat'd for any reason *other* than absence (permission
* denied, dead connection) propagates rather than being reported as `missing`.
* An unreadable path is not a missing one, and saying so would send the user
* off to fix the wrong problem.
*/
export async function inspectRemoteDir(sshHost: string, dir: string): Promise<RemoteDirInspection> {
if (!pathPosix.isAbsolute(dir)) {
throw new Error(`Remote working directory must be an absolute path: ${dir}`);
}
const normalized = pathPosix.normalize(dir).replace(/\/+$/, '') || '/';

const proxy = await ensureSshConnected(sshConnectionIdForHost(sshHost), sshHost);
const fs = new SshFileSystem(proxy, '/');
try {
const entry = await fs.stat(normalized);
if (entry) {
return { dir: normalized, status: entry.type === 'dir' ? 'directory' : 'file' };
}

const parent = await fs.stat(pathPosix.dirname(normalized));
return { dir: normalized, status: parent?.type === 'dir' ? 'creatable' : 'missing' };
} finally {
fs.close();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ export type TelemetryAgentCreateFailure =
| 'name_conflict'
| 'credentials_conflict'
| 'invalid_name'
| 'directory_missing'
/**
* The two the other way into this — dropping a folder on the sidebar — hits
* most: the directory holds no agent configuration, or it holds one belonging
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,17 @@ export const AddAgentModal = observer(function AddAgentModal({
});
return;
}
if (result.kind === 'directory-missing') {
toast({
title: 'That working directory cannot be used. Nothing was created.',
description:
result.inspection.status === 'file'
? `${result.inspection.dir} is a file on ${result.sshHost}.`
: `Neither ${result.inspection.dir} nor its parent exists on ${result.sshHost} — create the parent directory first.`,
Comment thread
abeldantas marked this conversation as resolved.
Outdated
variant: 'destructive',
});
return;
}
if (result.kind === 'invalid-name') {
toast({
title: 'That agent name cannot be used',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/**
* Existence model for a prospective remote working directory (CHOO-1416).
*
* A remote agent's working directory is typed as free text, so it is the one
* input in the add-agent flow that can be wrong in a way nothing else catches:
* the SSH host is probed for reachability, the server is picked from a list,
* but the directory was only ever touched at write time — by which point an
* identity had already been minted on the gateway.
*/

/** What an inspection found at a remote path. */
export type RemoteDirStatus =
/** Exists and is a directory. */
| 'directory'
/**
* Does not exist, but its parent does, so the first credentials write creates
* it — which is what already happened before this check existed. Usable.
*/
| 'creatable'
/** Exists, but is a regular file. */
| 'file'
/**
* Neither the directory nor its parent exists. This is the failing case: a
Comment thread
abeldantas marked this conversation as resolved.
Outdated
* working directory's FS is rooted at the directory itself, and its recursive
* mkdir will not create anything above that root.
*/
| 'missing';

/** The result of inspecting a prospective remote working directory. */
export type RemoteDirInspection = {
/** The absolute path inspected, as resolved on the host. */
dir: string;
status: RemoteDirStatus;
};

/** Whether an agent can be created in this directory. */
export function isUsableRemoteDir(inspection: RemoteDirInspection): boolean {
return inspection.status === 'directory' || inspection.status === 'creatable';
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
* in the encrypted secrets store, never in plain settings.
*/

import type { RemoteDirInspection } from '@shared/core/remote-hosts/remote-dir';

/**
* Origin (protocol + host + port, lowercased) of a URL, or null if unparseable.
* Agents are matched to servers by origin rather than full URL: an agent's
Expand Down Expand Up @@ -709,6 +711,10 @@ export type ProvisionAgentResult =
* the Switch deployment at `endpoint` — another install's agent. Refused
* before minting, so nothing was created. */
| { kind: 'credentials-conflict'; endpoint: string }
/** The remote working directory is unusable — a file, or its parent is
* missing too. Produced by the add-agent path, which checks before minting
* (CHOO-1416). */
| { kind: 'directory-missing'; sshHost: string; inspection: RemoteDirInspection }
| RegisterIdentityFailure;

/**
Expand Down
Loading