Skip to content
Merged
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 packages/api/src/agent-workspace-info.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ export interface AgentWorkspaceCreateOptions {
sourcePath: string;
agent: string;
model: string;
gateway: string;
name?: string;
project?: string;
skills?: string[];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,7 @@ describe('create – OpenShell mode', () => {
agent: 'claude',
name: 'my-sandbox',
model: 'ramalama::granite-4.6::',
gateway: 'kaiden',
};

const mockAgent: Agent = {
Expand All @@ -306,13 +307,14 @@ describe('create – OpenShell mode', () => {
vi.mocked(realpath).mockImplementation(async (p: unknown) => p as string);
});

test('calls openshellCli.createSandbox with name, providers, workspace label, and agent label', async () => {
test('calls openshellCli.createSandbox with gateway, name, providers, workspace label, and agent label', async () => {
const options = { ...defaultOptions, secrets: ['my-secret'] };
await manager.create(options);

expect(openshellCli.createSandbox).toHaveBeenCalledWith(
expect.objectContaining({
name: 'my-sandbox',
gateway: 'kaiden',
providers: ['my-secret'],
labels: { ...encodeWorkspaceLabels('/tmp/my-project'), [AGENT_LABEL]: 'claude' },
noTty: true,
Expand Down Expand Up @@ -346,6 +348,7 @@ describe('create – OpenShell mode', () => {
sourcePath: '/tmp/my-project',
agent: 'claude',
model: 'ramalama::granite-4::',
gateway: 'kaiden',
};
const result = await manager.create(options);

Expand All @@ -369,6 +372,7 @@ describe('create – OpenShell mode', () => {
sourcePath: `/tmp/${longBasename}`,
agent: 'claude',
model: 'ramalama::granite-4::',
gateway: 'kaiden',
};

await expect(manager.create(options)).rejects.toThrow(/must not exceed 56 characters/);
Expand Down Expand Up @@ -641,7 +645,7 @@ describe('create – OpenShell mode', () => {

await expect(manager.create(options)).rejects.toThrow('policy update failed');

expect(openshellCli.deleteSandbox).toHaveBeenCalledWith('my-sandbox');
expect(openshellCli.deleteSandbox).toHaveBeenCalledWith('my-sandbox', 'kaiden');
});

test('attaches secret to sandbox when ensureSecretForModel returns a secret', async () => {
Expand Down Expand Up @@ -858,6 +862,7 @@ describe('ensureModelSecret', () => {
agent: 'claude',
model: 'anthropic::claude-sonnet-4::',
name: 'my-workspace',
gateway: 'kaiden',
};

test('skips when workspaceConfiguration already has secrets (e.g. onboarding)', async () => {
Expand Down Expand Up @@ -971,17 +976,17 @@ describe('remove', () => {
vi.mocked(openshellCli.listSandboxesPerGateway).mockResolvedValue(TEST_SUMMARIES);
vi.mocked(openshellCli.deleteSandbox).mockResolvedValue(undefined);

const result = await manager.remove('ws-1');
const result = await manager.remove('ws-1', 'kaiden');

expect(openshellCli.deleteSandbox).toHaveBeenCalledWith('test-workspace-1');
expect(openshellCli.deleteSandbox).toHaveBeenCalledWith('test-workspace-1', 'kaiden');
expect(result).toEqual({ id: 'ws-1' });
});

test('creates a task with workspace name and sets success status on completion', async () => {
vi.mocked(openshellCli.listSandboxesPerGateway).mockResolvedValue(TEST_SUMMARIES);
vi.mocked(openshellCli.deleteSandbox).mockResolvedValue(undefined);

await manager.remove('ws-1');
await manager.remove('ws-1', 'kaiden');

expect(taskManager.createTask).toHaveBeenCalledWith({ title: 'Deleting workspace "test-workspace-1"' });
expect(mockTask.status).toBe('success');
Expand All @@ -992,7 +997,7 @@ describe('remove', () => {
vi.mocked(openshellCli.listSandboxesPerGateway).mockResolvedValue([]);
vi.mocked(openshellCli.deleteSandbox).mockResolvedValue(undefined);

await manager.remove('unknown-id');
await manager.remove('unknown-id', 'kaiden');

expect(taskManager.createTask).toHaveBeenCalledWith({ title: 'Deleting workspace "unknown-id"' });
});
Expand All @@ -1001,7 +1006,7 @@ describe('remove', () => {
vi.mocked(openshellCli.listSandboxesPerGateway).mockResolvedValue(TEST_SUMMARIES);
vi.mocked(openshellCli.deleteSandbox).mockRejectedValue(new Error('workspace not found: unknown-id'));

await expect(manager.remove('unknown-id')).rejects.toThrow('workspace not found: unknown-id');
await expect(manager.remove('unknown-id', 'kaiden')).rejects.toThrow('workspace not found: unknown-id');

expect(mockTask.status).toBe('failure');
expect(mockTask.error).toContain('workspace not found: unknown-id');
Expand All @@ -1012,7 +1017,7 @@ describe('remove', () => {
vi.mocked(openshellCli.listSandboxesPerGateway).mockResolvedValue(TEST_SUMMARIES);
vi.mocked(openshellCli.deleteSandbox).mockRejectedValue(new Error('failed to remove workspace: permission denied'));

await expect(manager.remove('ws-1')).rejects.toThrow('failed to remove workspace: permission denied');
await expect(manager.remove('ws-1', 'kaiden')).rejects.toThrow('failed to remove workspace: permission denied');

expect(mockTask.error).toBe('Failed to delete workspace: failed to remove workspace: permission denied');
});
Expand All @@ -1021,12 +1026,22 @@ describe('remove', () => {
vi.mocked(openshellCli.listSandboxesPerGateway).mockResolvedValue(TEST_SUMMARIES);
vi.mocked(openshellCli.deleteSandbox).mockResolvedValue(undefined);

await manager.remove('ws-1');
await manager.remove('ws-1', 'kaiden');

expect(apiSender.send).toHaveBeenCalledWith('agent-workspace-update');
});
});

describe('deleteOpenshellSandbox', () => {
test('deletes the sandbox from the requested gateway', async () => {
vi.mocked(openshellCli.deleteSandbox).mockResolvedValue(undefined);

await manager.deleteOpenshellSandbox('shared-name', 'remote-gateway');

expect(openshellCli.deleteSandbox).toHaveBeenCalledWith('shared-name', 'remote-gateway');
});
});

describe('getConfiguration', () => {
test('reads JSON configuration file from workspace directory', async () => {
vi.mocked(openshellCli.listSandboxesPerGateway).mockResolvedValue(TEST_SUMMARIES);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,7 @@ export class AgentWorkspaceManager implements Disposable {

await this.openshellCli.createSandbox({
name: sandboxName,
gateway: options.gateway,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
from: agent.baseImage,
providers: options.secrets,
env: env && Object.keys(env).length > 0 ? env : undefined,
Expand All @@ -244,7 +245,7 @@ export class AgentWorkspaceManager implements Disposable {
try {
await this.openshellCli.updatePolicy(sandboxName, endpointFlags, collectBinaryFlags(networkPolicy));
} catch (err) {
await this.openshellCli.deleteSandbox(sandboxName).catch(() => {});
await this.openshellCli.deleteSandbox(sandboxName, options.gateway).catch(() => {});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
throw err;
}
}
Expand Down Expand Up @@ -406,15 +407,18 @@ export class AgentWorkspaceManager implements Disposable {
return secret.name;
}

async remove(id: string): Promise<AgentWorkspaceId> {
async remove(id: string, gateway: string): Promise<AgentWorkspaceId> {
const workspaces = await this.listOpenshellSandboxes();
const workspace = workspaces.flatMap(gw => gw.sandboxes).find(ws => ws.id === id);
const workspace = workspaces
.filter(entry => entry.gateway.name === gateway)
.flatMap(entry => entry.sandboxes)
.find(ws => ws.id === id);
const workspaceName = workspace?.name ?? id;
const task = this.taskManager.createTask({ title: `Deleting workspace "${workspaceName}"` });
task.state = 'running';
task.status = 'in-progress';
try {
await this.openshellCli.deleteSandbox(workspaceName);
await this.openshellCli.deleteSandbox(workspaceName, gateway);
this.closeWorkspaceTerminal(id);
this.apiSender.send('agent-workspace-update');
task.status = 'success';
Expand Down Expand Up @@ -495,12 +499,12 @@ export class AgentWorkspaceManager implements Disposable {
return this.openshellCli.listGateways();
}

async deleteOpenshellSandbox(name: string): Promise<void> {
async deleteOpenshellSandbox(name: string, gateway: string): Promise<void> {
const task = this.taskManager.createTask({ title: `Deleting workspace ${name}` });
task.state = 'running';
task.status = 'in-progress';
try {
await this.openshellCli.deleteSandbox(name);
await this.openshellCli.deleteSandbox(name, gateway);
this.apiSender.send('agent-workspace-update');
task.status = 'success';
} catch (err: unknown) {
Expand Down Expand Up @@ -602,9 +606,12 @@ export class AgentWorkspaceManager implements Disposable {
},
);

this.ipcHandle('agent-workspace:remove', async (_listener: unknown, id: string): Promise<AgentWorkspaceId> => {
return this.remove(id);
});
this.ipcHandle(
'agent-workspace:remove',
async (_listener: unknown, id: string, gateway: string): Promise<AgentWorkspaceId> => {
return this.remove(id, gateway);
},
);

this.ipcHandle(
'agent-workspace:getConfiguration',
Expand Down Expand Up @@ -637,8 +644,8 @@ export class AgentWorkspaceManager implements Disposable {

this.ipcHandle(
'agent-workspace:deleteOpenshellSandbox',
async (_listener: unknown, name: string): Promise<void> => {
return this.deleteOpenshellSandbox(name);
async (_listener: unknown, name: string, gateway: string): Promise<void> => {
return this.deleteOpenshellSandbox(name, gateway);
},
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ const defaultOptions: AgentWorkspaceCreateOptions = {
sourcePath: '/tmp/my-project',
agent: 'claude',
model: 'anthropic::claude-sonnet-4::',
gateway: 'kaiden',
};

beforeEach(() => {
Expand Down
13 changes: 13 additions & 0 deletions packages/main/src/plugin/openshell-cli/openshell-cli.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -507,6 +507,19 @@ describe('deleteSandbox', () => {
expect(exec.exec).toHaveBeenCalledWith(OPENSHELL_CLI_PATH, ['sandbox', 'delete', 'my-sandbox'], undefined);
});

test('includes -g flag when gateway is provided', async () => {
vi.spyOn(console, 'log').mockImplementation(() => undefined);
vi.mocked(exec.exec).mockResolvedValue(mockExecResult(''));

await openshellCli.deleteSandbox('my-sandbox', 'remote-gateway');

expect(exec.exec).toHaveBeenCalledWith(
OPENSHELL_CLI_PATH,
['sandbox', 'delete', 'my-sandbox', '-g', 'remote-gateway'],
undefined,
);
});

test('rejects when CLI fails', async () => {
vi.spyOn(console, 'log').mockImplementation(() => undefined);
vi.spyOn(console, 'error').mockImplementation(() => undefined);
Expand Down
8 changes: 6 additions & 2 deletions packages/main/src/plugin/openshell-cli/openshell-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,8 +237,12 @@ export class OpenshellCli {
await this.runCli(['sandbox', 'stop', name]);
}

async deleteSandbox(name: string): Promise<void> {
await this.runCli(['sandbox', 'delete', name]);
async deleteSandbox(name: string, gatewayName?: string): Promise<void> {
const args = ['sandbox', 'delete', name];
if (gatewayName) {
args.push('-g', gatewayName);
}
await this.runCli(args);
}

async deleteAllSandboxes(gatewayName?: string): Promise<void> {
Expand Down
13 changes: 8 additions & 5 deletions packages/preload/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -340,9 +340,12 @@ export function initExposure(): void {
},
);

contextBridge.exposeInMainWorld('removeAgentWorkspace', async (id: string): Promise<AgentWorkspaceId> => {
return ipcInvoke('agent-workspace:remove', id);
});
contextBridge.exposeInMainWorld(
'removeAgentWorkspace',
async (id: string, gateway: string): Promise<AgentWorkspaceId> => {
return ipcInvoke('agent-workspace:remove', id, gateway);
},
);

contextBridge.exposeInMainWorld(
'getAgentWorkspaceConfiguration',
Expand Down Expand Up @@ -381,8 +384,8 @@ export function initExposure(): void {
return ipcInvoke('agent-workspace:listOpenshellGateways');
});

contextBridge.exposeInMainWorld('deleteOpenshellSandbox', async (name: string): Promise<void> => {
return ipcInvoke('agent-workspace:deleteOpenshellSandbox', name);
contextBridge.exposeInMainWorld('deleteOpenshellSandbox', async (name: string, gateway: string): Promise<void> => {
return ipcInvoke('agent-workspace:deleteOpenshellSandbox', name, gateway);
});

// Agent Workspace Terminal
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import * as agentsStore from '/@/stores/agents';
import * as mcpStore from '/@/stores/mcp-remote-servers';
import * as modelCatalogStore from '/@/stores/model-catalog';
import * as modelsStore from '/@/stores/models';
import * as openshellGatewaysStore from '/@/stores/openshell-gateways';
import * as openshellSandboxesStore from '/@/stores/openshell-sandboxes';
import * as providerStore from '/@/stores/providers';
import * as ragStore from '/@/stores/rag-environments';
Expand Down Expand Up @@ -55,6 +56,7 @@ vi.mock(import('/@/stores/providers'));
vi.mock(import('/@/stores/rag-environments'));
vi.mock(import('/@/stores/model-catalog'));
vi.mock(import('/@/stores/models'));
vi.mock(import('/@/stores/openshell-gateways'));
vi.mock(import('/@/stores/openshell-sandboxes'));
vi.mock(import('/@/stores/workspace-projects'));

Expand Down Expand Up @@ -173,6 +175,9 @@ beforeEach(() => {
(providerId: string, connectionId: string, label: string): string => `${providerId}::${connectionId}::${label}`,
);
vi.mocked(workspaceProjectsStore).workspaceProjectInfos = writable<readonly WorkspaceProjectInfo[]>([]);
vi.mocked(openshellGatewaysStore).openshellGateways = writable([
{ name: 'kaiden', endpoint: 'http://localhost:17670', active: true },
]);
vi.mocked(openshellSandboxesStore).allOpenshellSandboxes = writable<(SandboxInfo & { gatewayName: string })[]>([]);
vi.mocked(window.checkAgentWorkspaceConfigExists).mockResolvedValue(false);
vi.mocked(window.showMessageBox).mockResolvedValue({ response: 0 });
Expand Down Expand Up @@ -239,6 +244,50 @@ test('Expect workspace name auto-suggested from source path', async () => {
expect((screen.getByPlaceholderText('e.g., Frontend Refactoring') as HTMLInputElement).value).toBe('my-project');
});

test('Expect gateway selector hidden when only one gateway is available', () => {
render(AgentWorkspaceCreate);

expect(screen.queryByRole('combobox')).not.toBeInTheDocument();
});

test('Expect gateway selector defaults to the active OpenShell gateway when multiple gateways are available', async () => {
vi.mocked(openshellGatewaysStore).openshellGateways.set([
{ name: 'local', endpoint: 'http://localhost:17670', active: false },
{ name: 'remote', endpoint: 'https://remote.example.com', active: true },
]);
render(AgentWorkspaceCreate);

await fireEvent.input(screen.getByPlaceholderText('/path/to/project'), {
target: { value: '/home/user/my-repo' },
});

const gatewaySelector = screen.getByRole('combobox');
expect(gatewaySelector).toHaveValue('remote');
expect(screen.queryByRole('option', { name: 'Select a gateway' })).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Continue' })).not.toBeDisabled();

await fireEvent.change(gatewaySelector, { target: { value: 'local' } });

expect(gatewaySelector).toHaveValue('local');
expect(screen.getByRole('button', { name: 'Continue' })).not.toBeDisabled();
});

test('Expect selected gateway included when creating a workspace', async () => {
vi.mocked(openshellGatewaysStore).openshellGateways.set([
{ name: 'local', endpoint: 'http://localhost:17670', active: true },
{ name: 'remote', endpoint: 'https://remote.example.com', active: false },
]);
render(AgentWorkspaceCreate);

await fireEvent.change(screen.getByRole('combobox'), { target: { value: 'remote' } });
await fireEvent.input(screen.getByPlaceholderText('/path/to/project'), {
target: { value: '/home/user/my-repo' },
});
await fireEvent.click(screen.getByRole('button', { name: 'Use all defaults and create workspace' }));

expect(window.createAgentWorkspace).toHaveBeenCalledWith(expect.objectContaining({ gateway: 'remote' }));
});

test('Expect Continue button rendered on step 1', () => {
render(AgentWorkspaceCreate);

Expand Down
Loading
Loading