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 .github/workflows/daily-testing-build.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ env:

permissions:
contents: read
packages: read

jobs:
tag:
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/next-build.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ env:

permissions:
contents: read
packages: read

jobs:

Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/npmjs-publish.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ on:

permissions:
contents: read
packages: read
Comment thread
coderabbitai[bot] marked this conversation as resolved.

jobs:
prepare-version:
Expand Down Expand Up @@ -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
Comment thread
MarsKubeX marked this conversation as resolved.
id-token: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/pr-check.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ concurrency:

permissions:
contents: read
packages: read
Comment thread
MarsKubeX marked this conversation as resolved.

jobs:
windows:
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ env:

permissions:
contents: read
packages: read

jobs:

Expand Down
1 change: 1 addition & 0 deletions .github/workflows/workspace-e2e.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ jobs:
permissions:
contents: read
checks: write
packages: read
env:
WINDOWS_VERSION: '11'
WINDOWS_FEATUREPACK: '25h2-ent'
Expand Down
3 changes: 2 additions & 1 deletion .npmrc
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
node-linker=hoisted

@nvidia:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=${GITHUB_TOKEN}
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
pnpm install

# Start in watch/development mode
Expand Down
5 changes: 4 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,12 +101,15 @@ git clone https://github.com/<you>/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:
Expand Down
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Comment thread
MarsKubeX marked this conversation as resolved.
### Step 3. Start in watch mode

Run the application in watch mode:
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions packages/main/src/plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -603,6 +605,8 @@ export class PluginSystem {
container.bind<AgentRegistry>(AgentRegistry).toSelf().inSingletonScope();
container.bind<OpenShellRegistry>(OpenShellRegistry).toSelf().inSingletonScope();
container.bind<OpenshellCli>(OpenshellCli).toSelf().inSingletonScope();
container.bind<OpenshellGatewayConfig>(OpenshellGatewayConfig).toSelf();
container.bind<OpenshellSdkClientManager>(OpenshellSdkClientManager).toSelf().inSingletonScope();
container.bind<OpenshellGateway>(OpenshellGateway).toSelf().inSingletonScope();
container.bind<OpenshellGatewayStateManager>(OpenshellGatewayStateManager).toSelf().inSingletonScope();
container.bind<OpenshellImageBuilder>(OpenshellImageBuilder).toSelf().inSingletonScope();
Expand Down
Original file line number Diff line number Diff line change
@@ -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> = {}): 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();
});
});
});
Original file line number Diff line number Diff line change
@@ -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<ConnectOptions> {
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<Buffer | undefined> {
try {
return await readFile(filePath);
} catch (error: unknown) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return undefined;
}
throw error;
}
}
}
Loading
Loading