diff --git a/packages/main/src/plugin/agent-workspace/agent-workspace-manager.spec.ts b/packages/main/src/plugin/agent-workspace/agent-workspace-manager.spec.ts index 3d668975cb..75c3d93f45 100644 --- a/packages/main/src/plugin/agent-workspace/agent-workspace-manager.spec.ts +++ b/packages/main/src/plugin/agent-workspace/agent-workspace-manager.spec.ts @@ -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'; @@ -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; @@ -178,6 +189,7 @@ beforeEach(() => { isSymbolicLink: () => false, } as Awaited>; }); + gatewayStartCallback = undefined; manager = new AgentWorkspaceManager( apiSender, ipcHandle, @@ -189,6 +201,7 @@ beforeEach(() => { secretManager, openshellCli, agentRegistry, + openshellGateway, ); manager.init(); }); @@ -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', () => { @@ -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); @@ -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]], @@ -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); @@ -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); @@ -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); diff --git a/packages/main/src/plugin/agent-workspace/agent-workspace-manager.ts b/packages/main/src/plugin/agent-workspace/agent-workspace-manager.ts index 6137beb8a4..affb1f143d 100644 --- a/packages/main/src/plugin/agent-workspace/agent-workspace-manager.ts +++ b/packages/main/src/plugin/agent-workspace/agent-workspace-manager.ts @@ -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'; @@ -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 { @@ -381,7 +384,7 @@ export class AgentWorkspaceManager implements Disposable { } private async ensureModelSecretFromConfig(options: AgentWorkspaceCreateOptions): Promise { - 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])]; @@ -662,6 +665,10 @@ export class AgentWorkspaceManager implements Disposable { this.terminalCallbacks.delete(onDataId); }); + this.openshellGateway.onDidGatewayStart(() => { + this.apiSender.send('agent-workspace-update'); + }); + this.watchInstancesFile(); } diff --git a/packages/main/src/plugin/openshell-cli/openshell-gateway.spec.ts b/packages/main/src/plugin/openshell-cli/openshell-gateway.spec.ts index 8616009b09..352a6d2469 100644 --- a/packages/main/src/plugin/openshell-cli/openshell-gateway.spec.ts +++ b/packages/main/src/plugin/openshell-cli/openshell-gateway.spec.ts @@ -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; diff --git a/packages/main/src/plugin/openshell-cli/openshell-gateway.ts b/packages/main/src/plugin/openshell-cli/openshell-gateway.ts index 7bbb46f54a..83f23d6265 100644 --- a/packages/main/src/plugin/openshell-cli/openshell-gateway.ts +++ b/packages/main/src/plugin/openshell-cli/openshell-gateway.ts @@ -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'; @@ -54,6 +56,9 @@ export class OpenshellGateway implements Disposable { #port: number = DEFAULT_PORT; #bindAddress: string = DEFAULT_BIND_ADDRESS; + private readonly _onDidGatewayStart = new Emitter(); + readonly onDidGatewayStart: Event = this._onDidGatewayStart.event; + constructor( @inject(CliToolRegistry) private readonly cliToolRegistry: CliToolRegistry, @@ -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; } } @@ -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 { @@ -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 { diff --git a/packages/main/src/plugin/secret-manager/secret-manager.spec.ts b/packages/main/src/plugin/secret-manager/secret-manager.spec.ts index 9ff3818475..e6696c139c 100644 --- a/packages/main/src/plugin/secret-manager/secret-manager.spec.ts +++ b/packages/main/src/plugin/secret-manager/secret-manager.spec.ts @@ -23,6 +23,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 { SafeStorageRegistry } from '/@/plugin/safe-storage/safe-storage-registry.js'; @@ -46,6 +47,7 @@ const ipcHandle: IPCHandle = vi.fn(); const openshellCli = new OpenshellCli({} as Exec, {} as CliToolRegistry); const openshellAdapter = new OpenshellSecretAdapter(openshellCli); +let gatewayStartCallback: (() => void) | undefined; let registerInferenceCallback: ((event: RegisterInferenceConnectionEvent) => void) | undefined; let unregisterInferenceCallback: | ((event: { providerId: string; connection: InferenceProviderConnection }) => void) @@ -77,6 +79,13 @@ const safeStorageRegistry = { getExtensionStorage: vi.fn().mockReturnValue(extensionStorageMock), } as unknown as SafeStorageRegistry; +const openshellGateway = { + onDidGatewayStart: vi.fn((cb: () => void) => { + gatewayStartCallback = cb; + return { dispose: vi.fn() }; + }), +} as unknown as OpenshellGateway; + const mockWatcher = { onDidChange: vi.fn(), onDidCreate: vi.fn(), @@ -89,6 +98,7 @@ const filesystemMonitoring = { beforeEach(() => { vi.resetAllMocks(); + gatewayStartCallback = undefined; registerInferenceCallback = undefined; unregisterInferenceCallback = undefined; vi.mocked(filesystemMonitoring.createFileSystemWatcher).mockReturnValue(mockWatcher); @@ -100,6 +110,7 @@ beforeEach(() => { providerRegistry, configurationRegistry, safeStorageRegistry, + openshellGateway, ); manager.init(); }); @@ -121,6 +132,15 @@ describe('init', () => { expect(providerRegistry.onDidRegisterInferenceConnection).toHaveBeenCalled(); expect(providerRegistry.onDidUnregisterInferenceConnection).toHaveBeenCalled(); }); + + test('subscribes to gateway start event', () => { + expect(openshellGateway.onDidGatewayStart).toHaveBeenCalled(); + }); + + test('sends secret-manager-update when gateway starts', () => { + gatewayStartCallback!(); + expect(apiSender.send).toHaveBeenCalledWith('secret-manager-update'); + }); }); describe('openshellAdapter', () => { @@ -136,6 +156,7 @@ describe('openshellAdapter', () => { beforeEach(() => { vi.resetAllMocks(); + gatewayStartCallback = undefined; registerInferenceCallback = undefined; unregisterInferenceCallback = undefined; vi.mocked(filesystemMonitoring.createFileSystemWatcher).mockReturnValue(mockWatcher); @@ -147,6 +168,7 @@ describe('openshellAdapter', () => { providerRegistry, configurationRegistry, safeStorageRegistry, + openshellGateway, ); manager.init(); }); @@ -376,3 +398,175 @@ describe('inference connection lifecycle', () => { expect(secret).toEqual({ name: 'kaiden.vertex-ai-conn-123', type: 'vertex-ai' }); }); }); + +describe('createSecretForConnection', () => { + const mockConnection: InferenceProviderConnection = { + id: 'conn-456', + name: 'test-connection', + type: 'cloud', + sdk: {} as InferenceProviderConnection['sdk'], + status: () => 'started', + models: [{ label: 'model-1' }], + credentials: () => ({ token: 'secret-token' }), + }; + + function setupConfigMocksForCreate(secretType: string): void { + const properties = { + 'cursor.connection._type': { + scope: 'InferenceProviderConnection', + extension: { id: 'kaiden.cursor' }, + title: 'Cursor', + parentId: 'cursor', + }, + 'cursor.connection.token': { + scope: 'InferenceProviderConnection', + extension: { id: 'kaiden.cursor' }, + format: 'password', + title: 'Cursor', + parentId: 'cursor', + }, + } as Record>; + + vi.mocked(configurationRegistry.getConfigurationProperties).mockReturnValue( + properties as unknown as ReturnType, + ); + vi.mocked(configurationRegistry.getConfiguration).mockReturnValue({ + get: vi.fn((key: string) => { + if (key === 'cursor.connection._type') return secretType; + if (key === 'cursor.connection.token') return 'cursor:conn-456:token'; + return undefined; + }), + has: vi.fn(), + update: vi.fn(), + } as unknown as ReturnType); + + vi.mocked(extensionStorageMock.get).mockResolvedValue('actual-api-key'); + vi.mocked(openshellCli.listProviders).mockResolvedValue([]); + vi.mocked(openshellCli.createProvider).mockResolvedValue(undefined); + vi.mocked(providerRegistry.getProvider).mockReturnValue({ + extensionId: 'kaiden.cursor', + } as unknown as ProviderImpl); + } + + test('creates secret and returns SecretInfo when none exists', async () => { + setupConfigMocksForCreate('cursor'); + + const result = await manager.createSecretForConnection('kaiden.cursor', mockConnection, false); + + expect(openshellCli.createProvider).toHaveBeenCalledWith({ + name: 'kaiden.cursor-conn-456', + type: 'cursor', + credentials: { token: 'actual-api-key' }, + }); + expect(result).toEqual({ name: 'kaiden.cursor-conn-456', type: 'cursor' }); + }); + + test('returns undefined when _type is not configured', async () => { + vi.mocked(configurationRegistry.getConfigurationProperties).mockReturnValue({}); + vi.mocked(configurationRegistry.getConfiguration).mockReturnValue({ + get: vi.fn(() => undefined), + has: vi.fn(), + update: vi.fn(), + } as unknown as ReturnType); + vi.mocked(providerRegistry.getProvider).mockReturnValue({ + extensionId: 'kaiden.cursor', + } as unknown as ProviderImpl); + + const result = await manager.createSecretForConnection('kaiden.cursor', mockConnection, false); + + expect(result).toBeUndefined(); + expect(openshellCli.createProvider).not.toHaveBeenCalled(); + }); + + test('returns undefined when secret already exists', async () => { + setupConfigMocksForCreate('cursor'); + vi.mocked(openshellCli.listProviders).mockResolvedValue([{ name: 'kaiden.cursor-conn-456', type: 'cursor' }]); + + const result = await manager.createSecretForConnection('kaiden.cursor', mockConnection, true); + + expect(result).toBeUndefined(); + expect(openshellCli.createProvider).not.toHaveBeenCalled(); + }); +}); + +describe('ensureSecretForModel', () => { + const mockConnection: InferenceProviderConnection = { + id: 'conn-789', + name: 'test-connection', + type: 'cloud', + sdk: {} as InferenceProviderConnection['sdk'], + status: () => 'started', + models: [{ label: 'model-1' }], + credentials: () => ({ token: 'secret-token' }), + }; + + test('returns existing secret without creating', async () => { + vi.mocked(providerRegistry.getInferenceConnection).mockReturnValue({ + connection: mockConnection, + providerId: 'kaiden.cursor', + }); + vi.mocked(openshellCli.listProviders).mockResolvedValue([{ name: 'kaiden.cursor-conn-789', type: 'cursor' }]); + + const result = await manager.ensureSecretForModel('cursor::model-1::'); + + expect(result).toEqual({ name: 'kaiden.cursor-conn-789', type: 'cursor' }); + expect(openshellCli.createProvider).not.toHaveBeenCalled(); + }); + + test('creates and returns secret when missing but connection exists', async () => { + vi.mocked(providerRegistry.getInferenceConnection).mockReturnValue({ + connection: mockConnection, + providerId: 'kaiden.cursor', + }); + // First call from getSecretForModel: secret not found + // Second call from createSecretForConnection: still not found (dedup check) + vi.mocked(openshellCli.listProviders).mockResolvedValue([]); + vi.mocked(openshellCli.createProvider).mockResolvedValue(undefined); + vi.mocked(providerRegistry.getProvider).mockReturnValue({ + extensionId: 'kaiden.cursor', + } as unknown as ProviderImpl); + + const properties = { + 'cursor.connection._type': { + scope: 'InferenceProviderConnection', + extension: { id: 'kaiden.cursor' }, + title: 'Cursor', + parentId: 'cursor', + }, + 'cursor.connection.token': { + scope: 'InferenceProviderConnection', + extension: { id: 'kaiden.cursor' }, + format: 'password', + title: 'Cursor', + parentId: 'cursor', + }, + } as Record>; + vi.mocked(configurationRegistry.getConfigurationProperties).mockReturnValue( + properties as unknown as ReturnType, + ); + vi.mocked(configurationRegistry.getConfiguration).mockReturnValue({ + get: vi.fn((key: string) => { + if (key === 'cursor.connection._type') return 'cursor'; + if (key === 'cursor.connection.token') return 'cursor:conn-789:token'; + return undefined; + }), + has: vi.fn(), + update: vi.fn(), + } as unknown as ReturnType); + vi.mocked(extensionStorageMock.get).mockResolvedValue('actual-api-key'); + + const result = await manager.ensureSecretForModel('cursor::model-1::'); + + expect(openshellCli.createProvider).toHaveBeenCalled(); + expect(result).toEqual({ name: 'kaiden.cursor-conn-789', type: 'cursor' }); + }); + + test('returns undefined when no inference connection found', async () => { + vi.mocked(providerRegistry.getInferenceConnection).mockReturnValue(undefined); + + const result = await manager.ensureSecretForModel('unknown::model::'); + + expect(result).toBeUndefined(); + expect(openshellCli.createProvider).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/main/src/plugin/secret-manager/secret-manager.ts b/packages/main/src/plugin/secret-manager/secret-manager.ts index 1b218fb224..bba065a99a 100644 --- a/packages/main/src/plugin/secret-manager/secret-manager.ts +++ b/packages/main/src/plugin/secret-manager/secret-manager.ts @@ -25,6 +25,7 @@ import type { import { inject, injectable } from 'inversify'; import { IPCHandle } from '/@/plugin/api.js'; +import { OpenshellGateway } from '/@/plugin/openshell-cli/openshell-gateway.js'; import { ProviderImpl } from '/@/plugin/provider-impl.js'; import { ProviderRegistry } from '/@/plugin/provider-registry.js'; import { SafeStorageRegistry } from '/@/plugin/safe-storage/safe-storage-registry.js'; @@ -60,6 +61,8 @@ export class SecretManager { private readonly configurationRegistry: IConfigurationRegistry, @inject(SafeStorageRegistry) private readonly safeStorageRegistry: SafeStorageRegistry, + @inject(OpenshellGateway) + private readonly openshellGateway: OpenshellGateway, ) {} private get cli(): SecretCliBackend { @@ -95,17 +98,29 @@ export class SecretManager { return secrets.find(s => s.name === expectedName); } - private async onInferenceConnectionRegistered(event: RegisterInferenceConnectionEvent): Promise { - const connection = event.connection; - const providerId = event.providerId; + async ensureSecretForModel(modelId: string): Promise { + const existing = await this.getSecretForModel(modelId); + if (existing) return existing; + + const info = this.providerRegistry.getInferenceConnection(modelId); + if (!info) return undefined; + + return this.createSecretForConnection(info.providerId, info.connection, false); + } + + async createSecretForConnection( + providerId: string, + connection: InferenceProviderConnection, + checkDuplicates: boolean, + ): Promise { const provider = this.providerRegistry.getProvider(providerId); const { config, connectionProperties } = this.getConnectionProperties(connection, provider); const typeEntry = connectionProperties.find(([fullKey]) => fullKey.endsWith('_type')); - if (!typeEntry) return; + if (!typeEntry) return undefined; const secretType = config.get(typeEntry[0]); - if (!secretType) return; + if (!secretType) return undefined; const flagsEntry = connectionProperties.find(([fullKey]) => fullKey.endsWith('._flags')); const flagsRaw = flagsEntry ? config.get(flagsEntry[0]) : undefined; @@ -149,14 +164,22 @@ export class SecretManager { const secretName = `${providerId}-${connection.id}`; - const existingSecrets = await this.list(); - if (existingSecrets.some(s => s.name === secretName)) return; + if (checkDuplicates) { + const existingSecrets = await this.list(); + if (existingSecrets.some(s => s.name === secretName)) return undefined; + } await this.create({ name: secretName, type: secretType, value: value, }); + + return { name: secretName, type: secretType }; + } + + private async onInferenceConnectionRegistered(event: RegisterInferenceConnectionEvent): Promise { + await this.createSecretForConnection(event.providerId, event.connection, true); } public getConnectionProperties( @@ -203,6 +226,10 @@ export class SecretManager { }); }); + this.openshellGateway.onDidGatewayStart(() => { + this.apiSender.send('secret-manager-update'); + }); + this.ipcHandle( 'secret-manager:create', async (_listener: unknown, options: SecretCreateOptions): Promise => {