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/openshell-gateway-info.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ export interface CreateSandboxOptions {
labels?: Record<string, string>;
uploads?: Array<{ local: string; remote: string }>;
command?: string[];
noTty?: boolean;
}

export interface GatewayAddOptions {
Expand Down
7 changes: 6 additions & 1 deletion packages/api/src/secret-info.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,13 @@ export type SecretService = components['schemas']['SecretService'];
/**
* Options for creating a new secret via `kdn secret create`.
*/
export interface SecretValue {
credentials: Record<string, string>;
config?: Record<string, string>;
}

export interface SecretCreateOptions extends SecretInfo {
value: string;
value: string | SecretValue;
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,14 @@ import type { FileSystemWatcher, InferenceProviderConnection } from '@openkaiden
import type { WebContents } from 'electron';
import type { IPty } from 'node-pty';
import { spawn } from 'node-pty';
import { beforeEach, describe, expect, test, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';

import type { IPCHandle } from '/@/plugin/api.js';
import type { CliToolRegistry } from '/@/plugin/cli-tool-registry.js';
import type { FilesystemMonitoring } from '/@/plugin/filesystem-monitoring.js';
import { KdnCli } from '/@/plugin/kdn-cli/kdn-cli.js';
import { OpenshellCli } from '/@/plugin/openshell-cli/openshell-cli.js';
import { OpenshellImageBuilder } from '/@/plugin/openshell-cli/openshell-image-builder.js';
import type { ProviderRegistry } from '/@/plugin/provider-registry.js';
import type { SafeStorageRegistry, SecretStorageWrapper } from '/@/plugin/safe-storage/safe-storage-registry.js';
import type { SecretManager } from '/@/plugin/secret-manager/secret-manager.js';
Expand All @@ -49,6 +50,7 @@ vi.mock(import('node-pty'));

vi.mock(import('/@/plugin/kdn-cli/kdn-cli.js'));
vi.mock(import('/@/plugin/openshell-cli/openshell-cli.js'));
vi.mock(import('/@/plugin/openshell-cli/openshell-image-builder.js'));

const TEST_SUMMARIES: AgentWorkspaceSummary[] = [
{
Expand Down Expand Up @@ -85,6 +87,7 @@ const apiSender: ApiSenderType = {
const ipcHandle: IPCHandle = vi.fn();
const kdnCli = new KdnCli({} as Exec, {} as CliToolRegistry);
const openshellCli = new OpenshellCli({} as Exec, {} as CliToolRegistry);
const imageBuilderCli = new OpenshellImageBuilder({} as Exec, {} as CliToolRegistry);

const mockTask = {
id: 'task-1',
Expand Down Expand Up @@ -170,6 +173,7 @@ beforeEach(() => {
secretManager,
openshellCli,
safeStorageRegistry,
imageBuilderCli,
);
manager.init();
});
Expand Down Expand Up @@ -358,6 +362,107 @@ describe('create', () => {
});
});

describe('create – OpenShell mode', () => {
const defaultOptions: AgentWorkspaceCreateOptions = {
sourcePath: '/tmp/my-project',
agent: 'claude',
runtime: 'podman',
name: 'my-sandbox',
};

beforeEach(() => {
process.env['KAIDEN_OPENSHELL'] = '1';
vi.mocked(kdnCli.writeWorkspaceConfig).mockResolvedValue(undefined);
vi.mocked(imageBuilderCli.buildImage).mockResolvedValue(undefined);
vi.mocked(openshellCli.createSandbox).mockResolvedValue(undefined);
});

afterEach(() => {
delete process.env['KAIDEN_OPENSHELL'];
});

test('calls imageBuilderCli.buildImage with correct tag and agent option', async () => {
await manager.create(defaultOptions);

expect(imageBuilderCli.buildImage).toHaveBeenCalledWith(
'kaiden-workspace-my-sandbox:latest',
expect.objectContaining({ agent: 'claude', cwd: '/tmp/my-project' }),
);
});

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

expect(openshellCli.createSandbox).toHaveBeenCalledWith({
name: 'my-sandbox',
from: 'kaiden-workspace-my-sandbox:latest',
providers: ['my-secret'],
labels: { 'ai.openkaiden.kaiden.workspace': Buffer.from('/tmp/my-project').toString('base64url') },
noTty: true,
command: ['true'],
});
});

test('returns { id: sandboxName }', async () => {
const result = await manager.create(defaultOptions);

expect(result).toEqual({ id: 'my-sandbox' });
});

test('does not call kdnCli.createWorkspace', async () => {
await manager.create(defaultOptions);

expect(kdnCli.createWorkspace).not.toHaveBeenCalled();
});

test('derives sandbox name from sourcePath basename when name is omitted', async () => {
const options: AgentWorkspaceCreateOptions = { sourcePath: '/tmp/my-project', agent: 'claude' };
const result = await manager.create(options);

expect(openshellCli.createSandbox).toHaveBeenCalledWith(expect.objectContaining({ name: 'my-project' }));
expect(result).toEqual({ id: 'my-project' });
});

test('sanitizes uppercase and special characters in sandbox name for image tag', async () => {
const options = { ...defaultOptions, name: 'My Project/V2!' };
await manager.create(options);

expect(imageBuilderCli.buildImage).toHaveBeenCalledWith(
'kaiden-workspace-my-project-v2:latest',
expect.any(Object),
);
});

test('passes model name, inference, and endpoint to buildImage', async () => {
vi.mocked(providerRegistry.getInferenceConnectionCredentials).mockReturnValue({
credentials: { 'claude:tokens': 'sk-ant-secret' },
llmMetadataName: 'anthropic',
endpoint: 'https://api.anthropic.com',
});
vi.mocked(secretManager.create).mockResolvedValue({ name: 'my-sandbox-anthropic' });

const options = { ...defaultOptions, model: 'anthropic::claude-3-5-sonnet::' };
await manager.create(options);

expect(imageBuilderCli.buildImage).toHaveBeenCalledWith(
'kaiden-workspace-my-sandbox:latest',
expect.objectContaining({
model: 'claude-3-5-sonnet',
inference: 'anthropic',
endpoint: 'https://api.anthropic.com',
}),
);
});

test('falls back to "workspace" image tag component when name sanitizes to empty', async () => {
const options = { ...defaultOptions, name: '!!!' };
await manager.create(options);

expect(imageBuilderCli.buildImage).toHaveBeenCalledWith('kaiden-workspace-workspace:latest', expect.any(Object));
});
});

describe('checkWorkspaceConfigExists', () => {
test('returns true when workspace.json exists', async () => {
vi.mocked(access).mockResolvedValue(undefined);
Expand Down Expand Up @@ -781,11 +886,15 @@ describe('ensureModelSecret', () => {
expect(safeStorageRegistry.getExtensionStorage).toHaveBeenCalledWith('kaiden.cursor');
expect(extensionStorageMock.get).toHaveBeenCalledWith('cursor:conn-1:token');
expect(secretManager.create).toHaveBeenCalledWith({
name: 'my-workspace-cursor-token',
name: 'my-workspace-cursor',
type: 'cursor',
value: 'actual-api-key',
value: {
credentials: {
token: 'actual-api-key',
},
},
});
expect(options.secrets).toContain('my-workspace-cursor-token');
expect(options.secrets).toContain('my-workspace-cursor');
expect(providerRegistry.getInferenceConnectionCredentials).not.toHaveBeenCalled();
});

Expand Down Expand Up @@ -917,7 +1026,7 @@ describe('ensureModelSecret', () => {
};
await manager.ensureModelSecret(options);

expect(secretManager.create).toHaveBeenCalledWith(expect.objectContaining({ name: 'my-project-mistral-token' }));
expect(secretManager.create).toHaveBeenCalledWith(expect.objectContaining({ name: 'my-project-mistral' }));
});
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import { IPCHandle, WebContentsType } from '/@/plugin/api.js';
import { FilesystemMonitoring } from '/@/plugin/filesystem-monitoring.js';
import { KdnCli } from '/@/plugin/kdn-cli/kdn-cli.js';
import { OpenshellCli } from '/@/plugin/openshell-cli/openshell-cli.js';
import { OpenshellImageBuilder } from '/@/plugin/openshell-cli/openshell-image-builder.js';
import { ProviderRegistry } from '/@/plugin/provider-registry.js';
import { SafeStorageRegistry } from '/@/plugin/safe-storage/safe-storage-registry.js';
import { SecretManager } from '/@/plugin/secret-manager/secret-manager.js';
Expand All @@ -47,7 +48,7 @@ import type { IConfigurationNode } from '/@api/configuration/models.js';
import { IConfigurationRegistry } from '/@api/configuration/models.js';
import type { GatewaySandboxes } from '/@api/openshell-gateway-info.js';
import type { InferenceConnectionCredentials } from '/@api/provider-info.js';
import type { SecretCreateOptions } from '/@api/secret-info.js';
import type { SecretCreateOptions, SecretValue } from '/@api/secret-info.js';

/**
* Manages agent workspaces by delegating to the `kdn` CLI.
Expand Down Expand Up @@ -84,6 +85,8 @@ export class AgentWorkspaceManager implements Disposable {
private readonly openshellCli: OpenshellCli,
@inject(SafeStorageRegistry)
private readonly safeStorageRegistry: SafeStorageRegistry,
@inject(OpenshellImageBuilder)
private readonly imageBuilderCli: OpenshellImageBuilder,
) {}

async getCliInfo(): Promise<CliInfo> {
Expand All @@ -102,7 +105,9 @@ export class AgentWorkspaceManager implements Disposable {
}

await this.ensureModelSecret(options);
const workspaceId = await this.kdnCli.createWorkspace(options);
const workspaceId = process.env['KAIDEN_OPENSHELL']
? await this.createOpenshell(options)
: await this.kdnCli.createWorkspace(options);
this.apiSender.send('agent-workspace-update');
task.status = 'success';
return workspaceId;
Expand All @@ -116,6 +121,50 @@ export class AgentWorkspaceManager implements Disposable {
}
}

private sanitizeImageTag(name: string): string {
const sanitized = name
.toLowerCase()
.replace(/[^a-z0-9-]+/g, '-')
.split('-')
.filter(Boolean)
.join('-');
return sanitized || 'workspace';
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

private async createOpenshell(options: AgentWorkspaceCreateOptions): Promise<AgentWorkspaceId> {
const connectionInfo = options.model
? this.providerRegistry.getInferenceConnectionCredentials(options.model)
: undefined;

const modelName = options.model?.split('::')[1];
const inference = connectionInfo?.llmMetadataName;
const endpoint = connectionInfo?.endpoint;

await this.kdnCli.writeWorkspaceConfig(options);

const sandboxName = options.name ?? basename(options.sourcePath);
const imageTag = `kaiden-workspace-${this.sanitizeImageTag(sandboxName)}:latest`;

await this.imageBuilderCli.buildImage(imageTag, {
agent: options.agent,
model: modelName,
inference,
endpoint,
cwd: options.sourcePath,
});

await this.openshellCli.createSandbox({
name: sandboxName,
from: imageTag,
providers: options.secrets,
labels: { 'ai.openkaiden.kaiden.workspace': Buffer.from(options.sourcePath).toString('base64url') },
noTty: true,
command: ['true'],
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return { id: sandboxName };
}

async checkWorkspaceConfigExists(sourcePath: string): Promise<boolean> {
try {
await access(join(sourcePath, '.kaiden', 'workspace.json'));
Expand Down Expand Up @@ -207,6 +256,7 @@ export class AgentWorkspaceManager implements Disposable {

const extensionStorage = this.safeStorageRegistry.getExtensionStorage(info.extensionId);

const value: SecretValue = { credentials: {} };
for (const propertyName of passwordKeys) {
const secretRefName = config.get<string>(propertyName);
if (!secretRefName) continue;
Expand All @@ -215,11 +265,14 @@ export class AgentWorkspaceManager implements Disposable {
if (!actualValue) continue;

const shortPropertyName = propertyName.split('.').pop()!;
const secretName = `${workspaceName}-${secretType}-${shortPropertyName}`;
value.credentials[shortPropertyName] = actualValue;
}
if (Object.keys(value.credentials).length > 0) {
const secretName = `${workspaceName}-${secretType}`;
await this.secretManager.create({
name: secretName,
type: secretType,
value: actualValue,
value: value,
});

options.secrets = [...new Set([...(options.secrets ?? []), secretName])];
Expand Down
3 changes: 3 additions & 0 deletions packages/main/src/plugin/kdn-cli/kdn-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,9 @@ export class KdnCli implements SecretCliBackend {

async createSecret(options: SecretCreateOptions): Promise<SecretName> {
const cliPath = this.getCliPath();
if (typeof options.value !== 'string') {
throw new Error('options.value must be a string');
}
const args = ['secret', 'create', options.name, '--type', options.type, '--value', options.value];
if (options.description) {
args.push('--description', options.description);
Expand Down
15 changes: 13 additions & 2 deletions packages/main/src/plugin/openshell-cli/openshell-cli.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,18 @@ describe('createSandbox', () => {
);
});

test('passes --no-tty before command when noTty is true', async () => {
vi.mocked(exec.exec).mockResolvedValue(mockExecResult(''));

await openshellCli.createSandbox({ noTty: true, command: ['true'] });

expect(exec.exec).toHaveBeenCalledWith(
OPENSHELL_CLI_PATH,
['sandbox', 'create', '--no-tty', '--', 'true'],
undefined,
);
});

test('rejects when CLI fails', async () => {
vi.spyOn(console, 'log').mockImplementation(() => undefined);
vi.spyOn(console, 'error').mockImplementation(() => undefined);
Expand Down Expand Up @@ -776,8 +788,7 @@ describe('createProvider', () => {

const loggedMessage = logSpy.mock.calls[0]?.[0] as string;
expect(loggedMessage).not.toContain('sk-secret-123');
expect(loggedMessage).not.toContain('gpt-4');
expect(loggedMessage).toContain('***');
expect(loggedMessage).toContain('gpt-4');
});

test('rejects when CLI fails', async () => {
Expand Down
5 changes: 4 additions & 1 deletion packages/main/src/plugin/openshell-cli/openshell-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,9 @@ export class OpenshellCli {
args.push('--upload', `${upload.local}:${upload.remote}`);
}
}
if (options.noTty) {
args.push('--no-tty');
}
if (options.command?.length) {
args.push('--', ...options.command);
}
Expand Down Expand Up @@ -291,7 +294,7 @@ export class OpenshellCli {
args.push('--config', `${key}=${value}`);
}
}
await this.runCli(args, { redact: true, env });
await this.runCli(args, { env });
}

// ── helpers ───────────────────────────────────────────────────────
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ export interface BuildImageOptions {
endpoint?: string;
model?: string;
config?: string;
/** Working directory for the process. The tool reads `.kaiden/workspace.json` from here. */
cwd?: string;
}

/**
Expand Down Expand Up @@ -96,7 +98,7 @@ export class OpenshellImageBuilder {
const cliPath = this.getCliPath();
console.log(`Executing: ${cliPath} ${args.join(' ')}`);
try {
await this.exec.exec(cliPath, args);
await this.exec.exec(cliPath, args, { cwd: options.cwd });
} catch (err: unknown) {
const detail = err instanceof Error ? err.message : String(err);
console.error(`openshell-image-builder failed: ${cliPath} ${args.join(' ')} — ${detail}`);
Expand Down
Loading