diff --git a/.github/workflows/daily-testing-build.yaml b/.github/workflows/daily-testing-build.yaml index 05268dd8d..14e9f0e7e 100644 --- a/.github/workflows/daily-testing-build.yaml +++ b/.github/workflows/daily-testing-build.yaml @@ -33,6 +33,7 @@ env: permissions: contents: read + packages: read jobs: tag: diff --git a/.github/workflows/next-build.yaml b/.github/workflows/next-build.yaml index 70d22f208..9174475f9 100644 --- a/.github/workflows/next-build.yaml +++ b/.github/workflows/next-build.yaml @@ -29,6 +29,7 @@ env: permissions: contents: read + packages: read jobs: diff --git a/.github/workflows/npmjs-publish.yaml b/.github/workflows/npmjs-publish.yaml index a0797f728..4c64e1dec 100644 --- a/.github/workflows/npmjs-publish.yaml +++ b/.github/workflows/npmjs-publish.yaml @@ -27,6 +27,7 @@ on: permissions: contents: read + packages: read jobs: prepare-version: @@ -81,6 +82,8 @@ jobs: # For tag releases, require an environment (so people can approve it) environment: ${{ needs.prepare-version.outputs.is-tag == 'true' && 'npmjs-publication' || '' }} permissions: + contents: read + packages: read id-token: write steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/.github/workflows/pr-check.yaml b/.github/workflows/pr-check.yaml index 4afdb3880..a7eb4350c 100644 --- a/.github/workflows/pr-check.yaml +++ b/.github/workflows/pr-check.yaml @@ -27,6 +27,7 @@ concurrency: permissions: contents: read + packages: read jobs: windows: diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index a99751999..b5b979e4c 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -38,6 +38,7 @@ env: permissions: contents: read + packages: read jobs: diff --git a/.github/workflows/workspace-e2e.yaml b/.github/workflows/workspace-e2e.yaml index 1d66d71ae..147a4c89c 100644 --- a/.github/workflows/workspace-e2e.yaml +++ b/.github/workflows/workspace-e2e.yaml @@ -132,6 +132,7 @@ jobs: permissions: contents: read checks: write + packages: read env: WINDOWS_VERSION: '11' WINDOWS_FEATUREPACK: '25h2-ent' diff --git a/.npmrc b/.npmrc index 919b37d40..5b3cd3900 100644 --- a/.npmrc +++ b/.npmrc @@ -1,2 +1,3 @@ node-linker=hoisted - +@nvidia:registry=https://npm.pkg.github.com +//npm.pkg.github.com/:_authToken=${GITHUB_TOKEN} diff --git a/AGENTS.md b/AGENTS.md index 557124b04..356ba11b8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -71,7 +71,9 @@ Extensions interact with Kaiden through `@openkaiden/api` (`packages/extension-a ### Setup and Installation ```bash -# Install dependencies +# Install dependencies (requires GITHUB_TOKEN with read:packages for @nvidia/openshell-sdk) +# If your token lacks read:packages, run: gh auth refresh -s read:packages +export GITHUB_TOKEN=$(gh auth token) pnpm install # Start in watch/development mode diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c11bcc980..faf9dfd93 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -101,12 +101,15 @@ git clone https://github.com//kaiden && cd kaiden ### Step 2. Install dependencies -Fetch all dependencies using the command `pnpm`: +Kaiden depends on `@nvidia/openshell-sdk` which is published to GitHub Packages. A GitHub token with `read:packages` scope is required for `pnpm install` to succeed: ```sh +export GITHUB_TOKEN=$(gh auth token) pnpm install ``` +If `gh auth token` does not include `read:packages`, run `gh auth refresh -s read:packages` first. + ### Step 3. Start in watch mode Run the application in watch mode: diff --git a/README.md b/README.md index 6ecde8e68..bf19d9c6c 100644 --- a/README.md +++ b/README.md @@ -65,12 +65,15 @@ git clone https://github.com/openkaiden/kaiden && cd kaiden ### Step 2. Install dependencies -Fetch all dependencies using the command `pnpm`: +Kaiden depends on `@nvidia/openshell-sdk` which is published to GitHub Packages. A GitHub token with `read:packages` scope is required for `pnpm install` to succeed: ```sh +export GITHUB_TOKEN=$(gh auth token) pnpm install ``` +If `gh auth token` does not include `read:packages`, run `gh auth refresh -s read:packages` first. + ### Step 3. Start in watch mode Run the application in watch mode: diff --git a/package.json b/package.json index d78f61fb5..c0ca79ac8 100644 --- a/package.json +++ b/package.json @@ -218,6 +218,7 @@ "@expo/sudo-prompt": "^9.3.2", "@kubernetes/client-node": "^1.4.0", "@modelcontextprotocol/sdk": "^1.29.0", + "@nvidia/openshell-sdk": "0.0.106", "@oslojs/crypto": "^1.0.1", "@oslojs/encoding": "^1.1.0", "@segment/analytics-node": "^2.3.0", diff --git a/packages/main/src/plugin/index.ts b/packages/main/src/plugin/index.ts index aaa6585fe..eeef163e8 100644 --- a/packages/main/src/plugin/index.ts +++ b/packages/main/src/plugin/index.ts @@ -73,8 +73,10 @@ import { MenuRegistry } from '/@/plugin/menu-registry.js'; import { NavigationManager } from '/@/plugin/navigation/navigation-manager.js'; import { OpenshellCli } from '/@/plugin/openshell-cli/openshell-cli.js'; import { OpenshellGateway } from '/@/plugin/openshell-cli/openshell-gateway.js'; +import { OpenshellGatewayConfig } from '/@/plugin/openshell-cli/openshell-gateway-config.js'; import { OpenshellGatewayStateManager } from '/@/plugin/openshell-cli/openshell-gateway-state-manager.js'; import { OpenshellImageBuilder } from '/@/plugin/openshell-cli/openshell-image-builder.js'; +import { OpenshellSdkClientManager } from '/@/plugin/openshell-cli/openshell-sdk-client-manager.js'; import { OpenShellRegistry } from '/@/plugin/openshell-registry.js'; import { RagEnvironmentRegistry } from '/@/plugin/rag-environment-registry.js'; import { SchedulerRegistry } from '/@/plugin/scheduler/scheduler-registry.js'; @@ -603,6 +605,8 @@ export class PluginSystem { container.bind(AgentRegistry).toSelf().inSingletonScope(); container.bind(OpenShellRegistry).toSelf().inSingletonScope(); container.bind(OpenshellCli).toSelf().inSingletonScope(); + container.bind(OpenshellGatewayConfig).toSelf(); + container.bind(OpenshellSdkClientManager).toSelf().inSingletonScope(); container.bind(OpenshellGateway).toSelf().inSingletonScope(); container.bind(OpenshellGatewayStateManager).toSelf().inSingletonScope(); container.bind(OpenshellImageBuilder).toSelf().inSingletonScope(); diff --git a/packages/main/src/plugin/openshell-cli/openshell-gateway-config.spec.ts b/packages/main/src/plugin/openshell-cli/openshell-gateway-config.spec.ts new file mode 100644 index 000000000..c78d1fb4d --- /dev/null +++ b/packages/main/src/plugin/openshell-cli/openshell-gateway-config.spec.ts @@ -0,0 +1,126 @@ +/********************************************************************** + * Copyright (C) 2026 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import { join } from 'node:path'; + +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +import type { GatewayInfo } from '/@api/openshell-gateway-info.js'; + +import { OpenshellGatewayConfig } from './openshell-gateway-config.js'; + +vi.mock(import('node:fs/promises')); +vi.mock(import('node:os')); + +function gateway(overrides: Partial = {}): GatewayInfo { + return { + name: 'kaiden-local', + endpoint: 'http://127.0.0.1:17670', + active: true, + ...overrides, + }; +} + +beforeEach(() => { + vi.resetAllMocks(); +}); + +describe('OpenshellGatewayConfig', () => { + describe('buildConnectOptions', () => { + test('returns gateway URL only for http endpoints', async () => { + const config = new OpenshellGatewayConfig(); + + const options = await config.buildConnectOptions(gateway()); + + expect(options).toStrictEqual({ gateway: 'http://127.0.0.1:17670' }); + }); + + test('loads mTLS certs for https endpoints', async () => { + vi.stubEnv('XDG_CONFIG_HOME', '/test/config'); + const { readFile } = await import('node:fs/promises'); + vi.mocked(readFile) + .mockResolvedValueOnce(Buffer.from('ca-data')) + .mockResolvedValueOnce(Buffer.from('cert-data')) + .mockResolvedValueOnce(Buffer.from('key-data')); + + const config = new OpenshellGatewayConfig(); + const gw = gateway({ name: 'remote-gw', endpoint: 'https://gw.example.com', auth: 'mtls' }); + + const options = await config.buildConnectOptions(gw); + + const expectedMtlsDir = join('/test/config', 'openshell', 'gateways', 'remote-gw', 'mtls'); + expect(vi.mocked(readFile)).toHaveBeenCalledWith(join(expectedMtlsDir, 'ca.crt')); + expect(vi.mocked(readFile)).toHaveBeenCalledWith(join(expectedMtlsDir, 'tls.crt')); + expect(vi.mocked(readFile)).toHaveBeenCalledWith(join(expectedMtlsDir, 'tls.key')); + expect(options).toStrictEqual({ + gateway: 'https://gw.example.com', + caCert: Buffer.from('ca-data'), + clientCert: Buffer.from('cert-data'), + clientKey: Buffer.from('key-data'), + }); + }); + + test('passes undefined certs when mTLS files are missing', async () => { + vi.stubEnv('XDG_CONFIG_HOME', '/test/config'); + const { readFile } = await import('node:fs/promises'); + const enoent = Object.assign(new Error('ENOENT: no such file or directory'), { code: 'ENOENT' }); + vi.mocked(readFile).mockRejectedValue(enoent); + + const config = new OpenshellGatewayConfig(); + const gw = gateway({ name: 'no-certs', endpoint: 'https://gw.example.com' }); + + const options = await config.buildConnectOptions(gw); + + expect(options).toStrictEqual({ + gateway: 'https://gw.example.com', + caCert: undefined, + clientCert: undefined, + clientKey: undefined, + }); + }); + + test('propagates non-ENOENT cert read errors', async () => { + vi.stubEnv('XDG_CONFIG_HOME', '/test/config'); + const { readFile } = await import('node:fs/promises'); + const permError = Object.assign(new Error('EACCES: permission denied'), { code: 'EACCES' }); + vi.mocked(readFile).mockRejectedValue(permError); + + const config = new OpenshellGatewayConfig(); + const gw = gateway({ name: 'bad-perms', endpoint: 'https://gw.example.com' }); + + await expect(config.buildConnectOptions(gw)).rejects.toThrow(/EACCES/); + }); + + test('respects XDG_CONFIG_HOME', async () => { + const { readFile } = await import('node:fs/promises'); + const enoent = Object.assign(new Error('ENOENT: no such file or directory'), { code: 'ENOENT' }); + vi.mocked(readFile).mockRejectedValue(enoent); + vi.stubEnv('XDG_CONFIG_HOME', '/custom/config'); + + const config = new OpenshellGatewayConfig(); + const gw = gateway({ name: 'xdg-gw', endpoint: 'https://gw.example.com' }); + + await config.buildConnectOptions(gw); + + const expectedMtlsDir = join('/custom/config', 'openshell', 'gateways', 'xdg-gw', 'mtls'); + expect(vi.mocked(readFile)).toHaveBeenCalledWith(join(expectedMtlsDir, 'ca.crt')); + + vi.unstubAllEnvs(); + }); + }); +}); diff --git a/packages/main/src/plugin/openshell-cli/openshell-gateway-config.ts b/packages/main/src/plugin/openshell-cli/openshell-gateway-config.ts new file mode 100644 index 000000000..1ab2784ef --- /dev/null +++ b/packages/main/src/plugin/openshell-cli/openshell-gateway-config.ts @@ -0,0 +1,88 @@ +/********************************************************************** + * Copyright (C) 2026 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import { readFile } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +import type { ConnectOptions } from '@nvidia/openshell-sdk'; +import { injectable } from 'inversify'; + +import type { GatewayInfo } from '/@api/openshell-gateway-info.js'; + +/** + * Resolves OpenShell gateway configuration (config paths, mTLS certificates) + * and builds the `ConnectOptions` needed by the SDK to connect to a gateway. + */ +@injectable() +export class OpenshellGatewayConfig { + async buildConnectOptions(gateway: GatewayInfo): Promise { + const isHttps = gateway.endpoint.startsWith('https://'); + + if (!isHttps) { + return { gateway: gateway.endpoint }; + } + + const mtlsDir = this.#gatewayMtlsDir(gateway.name); + const [caCert, clientCert, clientKey] = await Promise.all([ + this.#readCertIfExists(join(mtlsDir, 'ca.crt')), + this.#readCertIfExists(join(mtlsDir, 'tls.crt')), + this.#readCertIfExists(join(mtlsDir, 'tls.key')), + ]); + + return { + gateway: gateway.endpoint, + caCert, + clientCert, + clientKey, + }; + } + + /** + * Resolve the OpenShell config root following the same logic as the Rust CLI: + * 1. $XDG_CONFIG_HOME (all platforms, including Windows) + * 2. %APPDATA% on win32 + * 3. $HOME/.config + */ + #openshellConfigRoot(): string { + const xdg = process.env['XDG_CONFIG_HOME']; + if (xdg) return xdg; + + if (process.platform === 'win32') { + const appdata = process.env['APPDATA']; + if (appdata) return appdata; + } + + return join(homedir(), '.config'); + } + + #gatewayMtlsDir(gatewayName: string): string { + return join(this.#openshellConfigRoot(), 'openshell', 'gateways', gatewayName, 'mtls'); + } + + async #readCertIfExists(filePath: string): Promise { + try { + return await readFile(filePath); + } catch (error: unknown) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return undefined; + } + throw error; + } + } +} diff --git a/packages/main/src/plugin/openshell-cli/openshell-sdk-client-manager.spec.ts b/packages/main/src/plugin/openshell-cli/openshell-sdk-client-manager.spec.ts new file mode 100644 index 000000000..16763ce65 --- /dev/null +++ b/packages/main/src/plugin/openshell-cli/openshell-sdk-client-manager.spec.ts @@ -0,0 +1,194 @@ +/********************************************************************** + * Copyright (C) 2026 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +import type { GatewayInfo } from '/@api/openshell-gateway-info.js'; + +import type { OpenshellGatewayConfig } from './openshell-gateway-config.js'; +import { OpenshellSdkClientManager } from './openshell-sdk-client-manager.js'; + +vi.mock(import('@nvidia/openshell-sdk')); +vi.mock(import('/@/plugin/openshell-cli/openshell-cli.js')); +vi.mock(import('/@/plugin/openshell-cli/openshell-gateway-config.js')); + +const mockConnect = vi.fn(); + +beforeEach(async () => { + vi.resetAllMocks(); + + const sdk = await import('@nvidia/openshell-sdk'); + vi.mocked(sdk.OpenShellClient.connect).mockImplementation(mockConnect); + mockConnect.mockResolvedValue({ sandbox: {}, raw: {}, transport: {} }); +}); + +function gateway(overrides: Partial = {}): GatewayInfo { + return { + name: 'kaiden-local', + endpoint: 'http://127.0.0.1:17670', + active: true, + ...overrides, + }; +} + +function createSdkClient( + listGateways: () => Promise, + buildConnectOptions?: OpenshellGatewayConfig['buildConnectOptions'], +): OpenshellSdkClientManager { + const openshellCli = { listGateways } as never; + const gatewayConfig = { + buildConnectOptions: + buildConnectOptions ?? vi.fn().mockImplementation(async (gw: GatewayInfo) => ({ gateway: gw.endpoint })), + } as never; + return new OpenshellSdkClientManager(openshellCli, gatewayConfig); +} + +describe('OpenshellSdkClientManager', () => { + describe('getClient', () => { + test('connects with options from gateway config', async () => { + const gw = gateway(); + const sdkClient = createSdkClient(async () => [gw]); + + await sdkClient.getClient(); + + expect(mockConnect).toHaveBeenCalledWith({ gateway: 'http://127.0.0.1:17670' }); + }); + + test('delegates connect options assembly to OpenshellGatewayConfig', async () => { + const mockBuild = vi.fn().mockResolvedValue({ + gateway: 'https://gw.example.com', + caCert: Buffer.from('ca'), + clientCert: Buffer.from('cert'), + clientKey: Buffer.from('key'), + }); + const gw = gateway({ name: 'remote', endpoint: 'https://gw.example.com' }); + const sdkClient = createSdkClient(async () => [gw], mockBuild); + + await sdkClient.getClient('remote'); + + expect(mockBuild).toHaveBeenCalledWith(gw); + expect(mockConnect).toHaveBeenCalledWith({ + gateway: 'https://gw.example.com', + caCert: Buffer.from('ca'), + clientCert: Buffer.from('cert'), + clientKey: Buffer.from('key'), + }); + }); + + test('caches client for same gateway name', async () => { + const gw = gateway(); + const sdkClient = createSdkClient(async () => [gw]); + + const first = await sdkClient.getClient(); + const second = await sdkClient.getClient(); + + expect(first).toBe(second); + expect(mockConnect).toHaveBeenCalledTimes(1); + }); + + test('creates separate clients for different gateways', async () => { + const localGw = gateway({ name: 'local', endpoint: 'http://127.0.0.1:17670', active: true }); + const remoteGw = gateway({ name: 'remote', endpoint: 'http://10.0.0.1:17670', active: false }); + const sdkClient = createSdkClient(async () => [localGw, remoteGw]); + + mockConnect.mockResolvedValueOnce({ id: 'client-local' }).mockResolvedValueOnce({ id: 'client-remote' }); + + const first = await sdkClient.getClient('local'); + const second = await sdkClient.getClient('remote'); + + expect(first).not.toBe(second); + expect(mockConnect).toHaveBeenCalledTimes(2); + }); + + test('selects active gateway when no name is provided', async () => { + const inactive = gateway({ name: 'inactive', active: false }); + const active = gateway({ name: 'active-gw', endpoint: 'http://127.0.0.1:17670', active: true }); + const sdkClient = createSdkClient(async () => [inactive, active]); + + await sdkClient.getClient(); + + expect(mockConnect).toHaveBeenCalledWith({ gateway: 'http://127.0.0.1:17670' }); + }); + + test('selects sole gateway when none is active', async () => { + const gw = gateway({ active: false }); + const sdkClient = createSdkClient(async () => [gw]); + + await sdkClient.getClient(); + + expect(mockConnect).toHaveBeenCalledWith({ gateway: 'http://127.0.0.1:17670' }); + }); + + test('throws when named gateway is not found', async () => { + const sdkClient = createSdkClient(async () => [gateway()]); + + await expect(sdkClient.getClient('missing')).rejects.toThrow(/gateway 'missing' not found/i); + }); + + test('throws when no gateways are registered', async () => { + const sdkClient = createSdkClient(async () => []); + + await expect(sdkClient.getClient()).rejects.toThrow(/no openshell gateways registered/i); + }); + + test('throws when multiple gateways exist but none is active', async () => { + const gw1 = gateway({ name: 'gw1', active: false }); + const gw2 = gateway({ name: 'gw2', active: false }); + const sdkClient = createSdkClient(async () => [gw1, gw2]); + + await expect(sdkClient.getClient()).rejects.toThrow(/multiple.*none is active/i); + }); + }); + + describe('invalidate', () => { + test('clears cache for a specific gateway', async () => { + const gw = gateway(); + const sdkClient = createSdkClient(async () => [gw]); + + await sdkClient.getClient(); + sdkClient.invalidate('kaiden-local'); + await sdkClient.getClient(); + + expect(mockConnect).toHaveBeenCalledTimes(2); + }); + + test('clears entire cache when no name is given', async () => { + const gw = gateway(); + const sdkClient = createSdkClient(async () => [gw]); + + await sdkClient.getClient(); + sdkClient.invalidate(); + await sdkClient.getClient(); + + expect(mockConnect).toHaveBeenCalledTimes(2); + }); + }); + + describe('dispose', () => { + test('clears cache on dispose', async () => { + const gw = gateway(); + const sdkClient = createSdkClient(async () => [gw]); + + await sdkClient.getClient(); + sdkClient.dispose(); + await sdkClient.getClient(); + + expect(mockConnect).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/packages/main/src/plugin/openshell-cli/openshell-sdk-client-manager.ts b/packages/main/src/plugin/openshell-cli/openshell-sdk-client-manager.ts new file mode 100644 index 000000000..69533b85f --- /dev/null +++ b/packages/main/src/plugin/openshell-cli/openshell-sdk-client-manager.ts @@ -0,0 +1,93 @@ +/********************************************************************** + * Copyright (C) 2026 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import type { OpenShellClient } from '@nvidia/openshell-sdk'; +import { inject, injectable, preDestroy } from 'inversify'; + +import { OpenshellCli } from '/@/plugin/openshell-cli/openshell-cli.js'; +import { OpenshellGatewayConfig } from '/@/plugin/openshell-cli/openshell-gateway-config.js'; +import type { GatewayInfo } from '/@api/openshell-gateway-info.js'; + +/** + * Cached factory for OpenShell SDK clients. Resolves gateway metadata from + * the CLI (`openshell gateway list`) and delegates connect-option assembly + * to {@link OpenshellGatewayConfig}. + * + * Clients are lazy — no network request is made until the first RPC. + */ +@injectable() +export class OpenshellSdkClientManager { + readonly #cache = new Map(); + + constructor( + @inject(OpenshellCli) + private readonly openshellCli: OpenshellCli, + @inject(OpenshellGatewayConfig) + private readonly gatewayConfig: OpenshellGatewayConfig, + ) {} + + async getClient(gatewayName?: string): Promise { + const gateway = await this.#resolveGateway(gatewayName); + + const cached = this.#cache.get(gateway.name); + if (cached) { + return cached; + } + + const { OpenShellClient: ClientClass } = await import('@nvidia/openshell-sdk'); + const options = await this.gatewayConfig.buildConnectOptions(gateway); + const client = await ClientClass.connect(options); + this.#cache.set(gateway.name, client); + return client; + } + + invalidate(gatewayName?: string): void { + if (gatewayName) { + this.#cache.delete(gatewayName); + } else { + this.#cache.clear(); + } + } + + @preDestroy() + dispose(): void { + this.#cache.clear(); + } + + async #resolveGateway(gatewayName?: string): Promise { + const gateways = await this.openshellCli.listGateways(); + + if (gatewayName) { + const match = gateways.find(g => g.name === gatewayName); + if (!match) { + throw new Error(`OpenShell gateway '${gatewayName}' not found`); + } + return match; + } + + const active = gateways.find(g => g.active); + if (active) return active; + + if (gateways.length === 1) return gateways[0]!; + + if (gateways.length === 0) { + throw new Error('No OpenShell gateways registered'); + } + throw new Error('Multiple OpenShell gateways registered but none is active'); + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 122654433..810c747a7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -54,6 +54,9 @@ importers: '@modelcontextprotocol/sdk': specifier: ^1.29.0 version: 1.29.0(zod@4.3.6) + '@nvidia/openshell-sdk': + specifier: 0.0.106 + version: 0.0.106 '@oslojs/crypto': specifier: ^1.0.1 version: 1.0.1 @@ -1347,6 +1350,9 @@ packages: cpu: [x64] os: [win32] + '@bufbuild/protobuf@2.14.0': + resolution: {integrity: sha512-C3UGsiCwSprE2NKIIFA3hCDlpXTMCAXRZuEVp88L1GY36Y41+rYL5fryE+nOFhp4p4JPQvdV8PQ4DWgHgeTE+w==} + '@colors/colors@1.6.0': resolution: {integrity: sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==} engines: {node: '>=0.1.90'} @@ -1420,6 +1426,18 @@ packages: resolution: {integrity: sha512-aO5l99BQJ0X34ft8b0h7QFkQlqxC6e7ZPVmBKz13xM9O8obDaM1Cld4sQlJDXXU/VFuUzQ30mVtHjVz74TuStw==} engines: {node: '>=v18'} + '@connectrpc/connect-node@2.1.2': + resolution: {integrity: sha512-+i/aAOpsI8sIx1mbYp6d99zvxaUSF6t/jP9Ux9maAmjsZPgmIQ3JuIeYi0zJIP9zlCnBlJjkpPosshCgdRuThQ==} + engines: {node: '>=20'} + peerDependencies: + '@bufbuild/protobuf': ^2.7.0 + '@connectrpc/connect': 2.1.2 + + '@connectrpc/connect@2.1.2': + resolution: {integrity: sha512-MXkBijtcX09R10Eb6sFeIetc6w6746eio6xtfuyVOH7oQAacT1X0GzMIQFux6Qy8cq3W/T5qX5Bei8YbFtmRGA==} + peerDependencies: + '@bufbuild/protobuf': ^2.7.0 + '@csstools/color-helpers@6.0.2': resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==} engines: {node: '>=20.19.0'} @@ -2050,6 +2068,10 @@ packages: engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} deprecated: This functionality has been moved to @npmcli/fs + '@nvidia/openshell-sdk@0.0.106': + resolution: {integrity: sha512-dB4mLex23Pnw61caGMR2CMHQihy9bj7IK2elJJd718k3yevm+fOt/vG6dJg8/5us4la2BwcOdRwLvOia3tdwFw==, tarball: https://npm.pkg.github.com/download/@nvidia/openshell-sdk/0.0.106/dc32180ba1d658fc4ec309bdf89d2b162196928d} + engines: {node: '>=20.3'} + '@open-draft/deferred-promise@2.2.0': resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==} @@ -8265,6 +8287,8 @@ snapshots: '@biomejs/cli-win32-x64@2.4.5': optional: true + '@bufbuild/protobuf@2.14.0': {} + '@colors/colors@1.6.0': {} '@commitlint/cli@20.4.2(@types/node@24.1.0)(typescript@5.9.3)': @@ -8376,6 +8400,15 @@ snapshots: conventional-commits-parser: 6.3.0 picocolors: 1.1.1 + '@connectrpc/connect-node@2.1.2(@bufbuild/protobuf@2.14.0)(@connectrpc/connect@2.1.2(@bufbuild/protobuf@2.14.0))': + dependencies: + '@bufbuild/protobuf': 2.14.0 + '@connectrpc/connect': 2.1.2(@bufbuild/protobuf@2.14.0) + + '@connectrpc/connect@2.1.2(@bufbuild/protobuf@2.14.0)': + dependencies: + '@bufbuild/protobuf': 2.14.0 + '@csstools/color-helpers@6.0.2': {} '@csstools/css-calc@3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': @@ -9136,6 +9169,12 @@ snapshots: mkdirp: 1.0.4 rimraf: 3.0.2 + '@nvidia/openshell-sdk@0.0.106': + dependencies: + '@bufbuild/protobuf': 2.14.0 + '@connectrpc/connect': 2.1.2(@bufbuild/protobuf@2.14.0) + '@connectrpc/connect-node': 2.1.2(@bufbuild/protobuf@2.14.0)(@connectrpc/connect@2.1.2(@bufbuild/protobuf@2.14.0)) + '@open-draft/deferred-promise@2.2.0': {} '@open-draft/logger@0.3.0':