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
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ 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 { OpenshellCli } from '/@/plugin/openshell-cli/openshell-cli.js';
import type { OpenshellGateway } from '/@/plugin/openshell-cli/openshell-gateway.js';
import type { ProviderImpl } from '/@/plugin/provider-impl.js';
import type { ProviderRegistry } from '/@/plugin/provider-registry.js';
import type { SecretManager } from '/@/plugin/secret-manager/secret-manager.js';
Expand Down Expand Up @@ -136,10 +137,20 @@ const providerRegistry = {
getProvider: vi.fn(),
} as unknown as ProviderRegistry;

let gatewayStartCallback: (() => void) | undefined;

const openshellGateway = {
onDidGatewayStart: vi.fn((cb: () => void) => {
gatewayStartCallback = cb;
return { dispose: vi.fn() };
}),
} as unknown as OpenshellGateway;

const secretManager = {
create: vi.fn(),
init: vi.fn(),
getSecretForModel: vi.fn(),
ensureSecretForModel: vi.fn(),
getConnectionProperties: vi.fn(),
} as unknown as SecretManager;

Expand Down Expand Up @@ -178,6 +189,7 @@ beforeEach(() => {
isSymbolicLink: () => false,
} as Awaited<ReturnType<typeof lstat>>;
});
gatewayStartCallback = undefined;
manager = new AgentWorkspaceManager(
apiSender,
ipcHandle,
Expand All @@ -189,6 +201,7 @@ beforeEach(() => {
secretManager,
openshellCli,
agentRegistry,
openshellGateway,
);
manager.init();
});
Expand Down Expand Up @@ -241,6 +254,15 @@ describe('init', () => {
}),
]);
});

test('subscribes to gateway start event', () => {
expect(openshellGateway.onDidGatewayStart).toHaveBeenCalled();
});

test('sends agent-workspace-update when gateway starts', () => {
gatewayStartCallback!();
expect(apiSender.send).toHaveBeenCalledWith('agent-workspace-update');
});
});

describe('watchInstancesFile', () => {
Expand Down Expand Up @@ -585,8 +607,8 @@ describe('create – OpenShell mode', () => {
expect(rm).toHaveBeenCalledWith(expect.stringContaining('kaiden-policy-my-sandbox'), { force: true });
});

test('attaches secret to sandbox when getSecretForModel returns a secret', async () => {
vi.mocked(secretManager.getSecretForModel).mockResolvedValue({ name: 'vertex-ai-conn-1', type: 'vertex-ai' });
test('attaches secret to sandbox when ensureSecretForModel returns a secret', async () => {
vi.mocked(secretManager.ensureSecretForModel).mockResolvedValue({ name: 'vertex-ai-conn-1', type: 'vertex-ai' });

const options = { ...defaultOptions, model: 'vertexai::claude-sonnet-4::' };
await manager.create(options);
Expand Down Expand Up @@ -629,7 +651,7 @@ describe('create – OpenShell mode', () => {
});

test('calls setInference during create when secret type requires it', async () => {
vi.mocked(secretManager.getSecretForModel).mockResolvedValue({ name: 'vertex-ai-conn-1', type: 'vertex-ai' });
vi.mocked(secretManager.ensureSecretForModel).mockResolvedValue({ name: 'vertex-ai-conn-1', type: 'vertex-ai' });
vi.mocked(secretManager.getConnectionProperties).mockReturnValue({
config: {} as Configuration,
connectionProperties: [['kaiden.vertexai._flags', {} as IConfigurationPropertyRecordedSchema]],
Expand Down Expand Up @@ -801,11 +823,11 @@ describe('ensureModelSecret', () => {
} as AgentWorkspaceCreateOptions;
await manager.ensureModelSecret(options);

expect(secretManager.getSecretForModel).not.toHaveBeenCalled();
expect(secretManager.ensureSecretForModel).not.toHaveBeenCalled();
});

test('skips when getSecretForModel returns undefined (no registered provider)', async () => {
vi.mocked(secretManager.getSecretForModel).mockResolvedValue(undefined);
test('skips when ensureSecretForModel returns undefined (no registered provider)', async () => {
vi.mocked(secretManager.ensureSecretForModel).mockResolvedValue(undefined);

const options = { ...baseOptions, model: 'unknown::model::' };
await manager.ensureModelSecret(options);
Expand All @@ -814,7 +836,7 @@ describe('ensureModelSecret', () => {
});

test('adds secret name to options.secrets when found', async () => {
vi.mocked(secretManager.getSecretForModel).mockResolvedValue({ name: 'cursor-conn-123', type: 'cursor' });
vi.mocked(secretManager.ensureSecretForModel).mockResolvedValue({ name: 'cursor-conn-123', type: 'cursor' });

const options = { ...baseOptions, model: 'cursor::gpt-4o::https://api.cursor.com' };
await manager.ensureModelSecret(options);
Expand All @@ -823,7 +845,7 @@ describe('ensureModelSecret', () => {
});

test('does not call setInference when secret type is not in SET_INFERENCE_TYPES', async () => {
vi.mocked(secretManager.getSecretForModel).mockResolvedValue({ name: 'cursor-conn-123', type: 'cursor' });
vi.mocked(secretManager.ensureSecretForModel).mockResolvedValue({ name: 'cursor-conn-123', type: 'cursor' });

const options = { ...baseOptions, model: 'cursor::gpt-4o::https://api.cursor.com' };
await manager.ensureModelSecret(options);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import { WritableConfigurationFile } from '/@/plugin/agent-workspace/writable-co
import { IPCHandle, WebContentsType } from '/@/plugin/api.js';
import { FilesystemMonitoring } from '/@/plugin/filesystem-monitoring.js';
import { OpenshellCli } from '/@/plugin/openshell-cli/openshell-cli.js';
import { OpenshellGateway } from '/@/plugin/openshell-cli/openshell-gateway.js';
import { buildPolicyObject, rewriteLocalhostUrl } from '/@/plugin/openshell-cli/openshell-network-policy.js';
import { ProviderRegistry } from '/@/plugin/provider-registry.js';
import { SecretManager } from '/@/plugin/secret-manager/secret-manager.js';
Expand Down Expand Up @@ -102,6 +103,8 @@ export class AgentWorkspaceManager implements Disposable {
private readonly openshellCli: OpenshellCli,
@inject(AgentRegistry)
private readonly agentRegistry: AgentRegistry,
@inject(OpenshellGateway)
private readonly openshellGateway: OpenshellGateway,
) {}

async create(options: AgentWorkspaceCreateOptions): Promise<AgentWorkspaceId> {
Expand Down Expand Up @@ -381,7 +384,7 @@ export class AgentWorkspaceManager implements Disposable {
}

private async ensureModelSecretFromConfig(options: AgentWorkspaceCreateOptions): Promise<string | undefined> {
const secret = await this.secretManager.getSecretForModel(options.model);
const secret = await this.secretManager.ensureSecretForModel(options.model);
if (!secret) return undefined;

options.secrets = [...new Set([...(options.secrets ?? []), secret.name])];
Expand Down Expand Up @@ -662,6 +665,10 @@ export class AgentWorkspaceManager implements Disposable {
this.terminalCallbacks.delete(onDataId);
});

this.openshellGateway.onDidGatewayStart(() => {
this.apiSender.send('agent-workspace-update');
});

this.watchInstancesFile();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -508,6 +508,77 @@ describe('dispose', () => {
});
});

describe('onDidGatewayStart', () => {
test('fires when existing gateway is healthy and active', async () => {
vi.spyOn(console, 'log').mockImplementation(() => undefined);
vi.mocked(openshellCli.listGateways).mockResolvedValue([
{ name: 'local-gw', endpoint: 'https://127.0.0.1:8443', active: true, type: 'local' },
]);
vi.mocked(openshellCli.checkEndpointStatus).mockResolvedValue(true);

const listener = vi.fn();
gateway.onDidGatewayStart(listener);
await gateway.init();

expect(listener).toHaveBeenCalledOnce();
});

test('fires when existing gateway is healthy but not active', async () => {
vi.spyOn(console, 'log').mockImplementation(() => undefined);
vi.mocked(openshellCli.listGateways).mockResolvedValue([
{ name: 'kaiden-alt', endpoint: 'http://127.0.0.1:18080', active: false },
]);
vi.mocked(openshellCli.checkEndpointStatus).mockResolvedValue(true);

const listener = vi.fn();
gateway.onDidGatewayStart(listener);
await gateway.init();

expect(listener).toHaveBeenCalledOnce();
});

test('fires when orphan gateway found on default port', async () => {
vi.spyOn(console, 'log').mockImplementation(() => undefined);
vi.spyOn(console, 'warn').mockImplementation(() => undefined);
vi.mocked(openshellCli.listGateways).mockResolvedValue([]);
vi.mocked(openshellCli.checkEndpointStatus).mockResolvedValue(true);

const listener = vi.fn();
gateway.onDidGatewayStart(listener);
await gateway.init();

expect(listener).toHaveBeenCalledOnce();
});

test('fires when auto-start succeeds', async () => {
vi.spyOn(console, 'log').mockImplementation(() => undefined);
vi.spyOn(console, 'warn').mockImplementation(() => undefined);
vi.mocked(openshellCli.listGateways).mockResolvedValue([]);
const proc = createMockChildProcess();
vi.mocked(spawn).mockReturnValue(proc);
vi.mocked(openshellCli.checkEndpointStatus).mockResolvedValueOnce(false).mockResolvedValue(true);
vi.mocked(exec.exec).mockResolvedValue(mockExecResult('openshell-gateway 0.0.69'));

const listener = vi.fn();
gateway.onDidGatewayStart(listener);
await gateway.init();

expect(listener).toHaveBeenCalledOnce();
});

test('does not fire when no binary and no gateways', async () => {
vi.spyOn(console, 'warn').mockImplementation(() => undefined);
vi.mocked(openshellCli.listGateways).mockRejectedValue(new Error('CLI not found'));
vi.mocked(cliToolRegistry.getCliToolInfos).mockReturnValue([] as unknown as CliToolInfo[]);

const listener = vi.fn();
gateway.onDidGatewayStart(listener);
await gateway.init();

expect(listener).not.toHaveBeenCalled();
});
});

describe('gateway config generation', () => {
let proc: ReturnType<typeof createMockChildProcess>;

Expand Down
9 changes: 9 additions & 0 deletions packages/main/src/plugin/openshell-cli/openshell-gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,10 @@ import Mustache from 'mustache';

import { CliToolRegistry } from '/@/plugin/cli-tool-registry.js';
import { Directories } from '/@/plugin/directories.js';
import { Emitter } from '/@/plugin/events/emitter.js';
import { OpenshellCli } from '/@/plugin/openshell-cli/openshell-cli.js';
import { Exec } from '/@/plugin/util/exec.js';
import type { Event } from '/@api/event.js';
import type { OpenshellGatewayStartOptions } from '/@api/openshell-gateway-info.js';

import gatewayConfigTemplate from './openshell-gateway.toml.template?raw';
Expand All @@ -54,6 +56,9 @@ export class OpenshellGateway implements Disposable {
#port: number = DEFAULT_PORT;
#bindAddress: string = DEFAULT_BIND_ADDRESS;

private readonly _onDidGatewayStart = new Emitter<void>();
readonly onDidGatewayStart: Event<void> = this._onDidGatewayStart.event;

constructor(
@inject(CliToolRegistry)
private readonly cliToolRegistry: CliToolRegistry,
Expand All @@ -76,6 +81,7 @@ export class OpenshellGateway implements Disposable {
await this.openshellCli.selectGateway(gw.name);
}
console.log(`[openshell-gateway] gateway detected (${gw.endpoint}) and is healthy`);
this._onDidGatewayStart.fire();
return;
}
}
Expand All @@ -95,11 +101,13 @@ export class OpenshellGateway implements Disposable {
if (await this.isEndpointHealthy()) {
console.log('[openshell-gateway] found healthy gateway on default port, registering');
await this.registerWithCli();
this._onDidGatewayStart.fire();
return;
}

console.log('[openshell-gateway] no existing gateways found, auto-starting local gateway');
await this.start();
this._onDidGatewayStart.fire();
}

private async isEndpointHealthy(endpoint?: string): Promise<boolean> {
Expand Down Expand Up @@ -218,6 +226,7 @@ export class OpenshellGateway implements Disposable {
@preDestroy()
dispose(): void {
this.stop().catch((err: unknown) => console.error('[openshell-gateway] failed to stop: ', err));
this._onDidGatewayStart.dispose();
}

private async generateCerts(binaryPath: string, gatewayDir: string): Promise<void> {
Expand Down
Loading
Loading