From bdb52506963aa2c828a494516906454dcbe4ddfe Mon Sep 17 00:00:00 2001 From: Jeff MAURY Date: Fri, 3 Jul 2026 18:29:06 +0200 Subject: [PATCH 1/2] fix(openshell): generate JWT signing certs for auto-started gateway The auto-started openshell-gateway binary was missing JWT signing configuration. Before spawning the server, generate certificates via `openshell-gateway generate-certs` using the internal Exec utility, write a gateway.toml config with JWT key paths, and pass `--config` to the gateway process. Falls back gracefully if cert generation fails. Fixes #2369 Signed-off-by: Jeff MAURY Co-Authored-By: Claude Opus 4.6 --- .../openshell-cli/openshell-gateway.spec.ts | 71 +++++++++++++++++-- .../plugin/openshell-cli/openshell-gateway.ts | 26 +++++++ 2 files changed, 93 insertions(+), 4 deletions(-) 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 21b346098..53fe7c9d1 100644 --- a/packages/main/src/plugin/openshell-cli/openshell-gateway.spec.ts +++ b/packages/main/src/plugin/openshell-cli/openshell-gateway.spec.ts @@ -39,6 +39,7 @@ vi.mock(import('node:fs/promises')); vi.mock(import('/@/plugin/util/exec.js')); const { spawn } = await import('node:child_process'); +const { mkdir, writeFile } = await import('node:fs/promises'); const GATEWAY_BINARY = '/usr/local/bin/openshell-gateway'; const KAIDEN_DATA_DIRECTORY = '/home/user/.local/share/kaiden'; @@ -62,6 +63,10 @@ function mockExecResult(stdout = ''): RunResult { let gateway: OpenshellGateway; const exec = new Exec({} as Proxy); +const FAKE_DATA_DIR = '/fake-data-dir'; +const GATEWAY_DIR = join(FAKE_DATA_DIR, 'openshell-gateway'); +const CONFIG_PATH = join(GATEWAY_DIR, 'gateway.toml'); + const cliToolRegistry = { getCliToolInfos: vi.fn(), } as unknown as CliToolRegistry; @@ -74,16 +79,21 @@ const openshellCli = { } as unknown as OpenshellCli; const directories = { - getDataDirectory: vi.fn(), + getDataDirectory: vi.fn().mockReturnValue(FAKE_DATA_DIR), } as unknown as Directories; +const exec = { + exec: vi.fn(), +} as unknown as Exec; + beforeEach(() => { vi.resetAllMocks(); + vi.mocked(directories.getDataDirectory).mockReturnValue(FAKE_DATA_DIR); vi.mocked(cliToolRegistry.getCliToolInfos).mockReturnValue([ { name: 'openshell-gateway', path: GATEWAY_BINARY }, ] as unknown as CliToolInfo[]); - vi.mocked(directories.getDataDirectory).mockReturnValue(KAIDEN_DATA_DIRECTORY); - gateway = new OpenshellGateway(exec, cliToolRegistry, openshellCli, directories); + vi.mocked(exec.exec).mockResolvedValue({ command: '', stdout: '', stderr: '' }); + gateway = new OpenshellGateway(cliToolRegistry, openshellCli, directories, exec); }); describe('init', () => { @@ -255,7 +265,7 @@ describe('getGatewayBinaryPath', () => { }); describe('start', () => { - test('spawns the gateway process with default args', async () => { + test('spawns the gateway process with default args including config', async () => { vi.spyOn(console, 'log').mockImplementation(() => undefined); const proc = createMockChildProcess(); vi.mocked(spawn).mockReturnValue(proc); @@ -363,6 +373,59 @@ describe('start', () => { expect(openshellCli.addGateway).not.toHaveBeenCalled(); }); + test('generates certs by calling the gateway binary with generate-certs', async () => { + vi.spyOn(console, 'log').mockImplementation(() => undefined); + const proc = createMockChildProcess(); + vi.mocked(spawn).mockReturnValue(proc); + vi.mocked(openshellCli.checkEndpointStatus).mockResolvedValue(true); + + await gateway.start(); + + expect(mkdir).toHaveBeenCalledWith(GATEWAY_DIR, { recursive: true }); + expect(exec.exec).toHaveBeenCalledWith(GATEWAY_BINARY, [ + 'generate-certs', + '--server-san', + '127.0.0.1', + '--server-san', + 'localhost', + '--server-san', + 'host.openshell.internal', + '--output-dir', + GATEWAY_DIR, + ]); + }); + + test('writes gateway.toml config with JWT paths', async () => { + vi.spyOn(console, 'log').mockImplementation(() => undefined); + const proc = createMockChildProcess(); + vi.mocked(spawn).mockReturnValue(proc); + vi.mocked(openshellCli.checkEndpointStatus).mockResolvedValue(true); + + await gateway.start(); + + expect(writeFile).toHaveBeenCalledWith(CONFIG_PATH, expect.stringContaining('[openshell.gateway.gateway_jwt]')); + expect(writeFile).toHaveBeenCalledWith(CONFIG_PATH, expect.stringContaining('signing_key_path')); + expect(writeFile).toHaveBeenCalledWith(CONFIG_PATH, expect.stringContaining('public_key_path')); + expect(writeFile).toHaveBeenCalledWith(CONFIG_PATH, expect.stringContaining('kid_path')); + }); + + test('starts gateway without --config when cert generation fails', async () => { + vi.spyOn(console, 'log').mockImplementation(() => undefined); + vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const proc = createMockChildProcess(); + vi.mocked(spawn).mockReturnValue(proc); + vi.mocked(openshellCli.checkEndpointStatus).mockResolvedValue(true); + vi.mocked(exec.exec).mockRejectedValue(new Error('generate-certs failed')); + + await gateway.start(); + + expect(spawn).toHaveBeenCalledWith( + GATEWAY_BINARY, + ['--port', '17670', '--bind-address', '127.0.0.1', '--disable-tls'], + expect.objectContaining({ detached: false }), + ); + }); + test('stops the spawned process when waitForReady fails', async () => { vi.useFakeTimers(); vi.spyOn(console, 'log').mockImplementation(() => undefined); diff --git a/packages/main/src/plugin/openshell-cli/openshell-gateway.ts b/packages/main/src/plugin/openshell-cli/openshell-gateway.ts index 4f93f3d07..cbd8831f5 100644 --- a/packages/main/src/plugin/openshell-cli/openshell-gateway.ts +++ b/packages/main/src/plugin/openshell-cli/openshell-gateway.ts @@ -63,6 +63,8 @@ export class OpenshellGateway implements Disposable { private readonly openshellCli: OpenshellCli, @inject(Directories) private readonly directories: Directories, + @inject(Exec) + private readonly exec: Exec, ) {} async init(): Promise { @@ -220,6 +222,27 @@ export class OpenshellGateway implements Disposable { this.stop().catch((err: unknown) => console.error('[openshell-gateway] failed to stop: ', err)); } + private async generateCerts(binaryPath: string): Promise { + const gatewayDir = join(this.directories.getDataDirectory(), 'openshell-gateway'); + await mkdir(gatewayDir, { recursive: true }); + + console.log('[openshell-gateway] generating certificates'); + await this.exec.exec(binaryPath, [ + 'generate-certs', + '--server-san', + '127.0.0.1', + '--server-san', + 'localhost', + '--server-san', + 'host.openshell.internal', + '--output-dir', + gatewayDir, + ]); + console.log(`[openshell-gateway] certificates generated in ${gatewayDir}`); + + return gatewayDir; + } + private buildArgs(disableTls: boolean, configPath?: string): string[] { const args: string[] = []; if (configPath) { @@ -230,6 +253,9 @@ export class OpenshellGateway implements Disposable { if (disableTls) { args.push('--disable-tls'); } + if (configPath) { + args.push('--config', configPath); + } return args; } From e4a06d24e01fc6b38a0ad7c63957fb69f817d6c4 Mon Sep 17 00:00:00 2001 From: Jeff MAURY Date: Sat, 4 Jul 2026 11:46:48 +0200 Subject: [PATCH 2/2] fix: rebase Signed-off-by: Jeff MAURY --- .../openshell-cli/openshell-gateway.spec.ts | 31 +++++++++---------- .../openshell-gateway.toml.template | 9 ++++++ .../plugin/openshell-cli/openshell-gateway.ts | 17 +++------- 3 files changed, 27 insertions(+), 30 deletions(-) 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 53fe7c9d1..8616009b0 100644 --- a/packages/main/src/plugin/openshell-cli/openshell-gateway.spec.ts +++ b/packages/main/src/plugin/openshell-cli/openshell-gateway.spec.ts @@ -27,8 +27,7 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'; import type { CliToolRegistry } from '/@/plugin/cli-tool-registry.js'; import type { Directories } from '/@/plugin/directories.js'; import type { OpenshellCli } from '/@/plugin/openshell-cli/openshell-cli.js'; -import type { Proxy } from '/@/plugin/proxy.js'; -import { Exec } from '/@/plugin/util/exec.js'; +import type { Exec } from '/@/plugin/util/exec.js'; import type { CliToolInfo } from '/@api/cli-tool-info.js'; import type { GatewayInfo } from '/@api/openshell-gateway-info.js'; @@ -39,7 +38,6 @@ vi.mock(import('node:fs/promises')); vi.mock(import('/@/plugin/util/exec.js')); const { spawn } = await import('node:child_process'); -const { mkdir, writeFile } = await import('node:fs/promises'); const GATEWAY_BINARY = '/usr/local/bin/openshell-gateway'; const KAIDEN_DATA_DIRECTORY = '/home/user/.local/share/kaiden'; @@ -62,11 +60,6 @@ function mockExecResult(stdout = ''): RunResult { let gateway: OpenshellGateway; -const exec = new Exec({} as Proxy); -const FAKE_DATA_DIR = '/fake-data-dir'; -const GATEWAY_DIR = join(FAKE_DATA_DIR, 'openshell-gateway'); -const CONFIG_PATH = join(GATEWAY_DIR, 'gateway.toml'); - const cliToolRegistry = { getCliToolInfos: vi.fn(), } as unknown as CliToolRegistry; @@ -79,7 +72,7 @@ const openshellCli = { } as unknown as OpenshellCli; const directories = { - getDataDirectory: vi.fn().mockReturnValue(FAKE_DATA_DIR), + getDataDirectory: vi.fn().mockReturnValue(KAIDEN_DATA_DIRECTORY), } as unknown as Directories; const exec = { @@ -88,7 +81,7 @@ const exec = { beforeEach(() => { vi.resetAllMocks(); - vi.mocked(directories.getDataDirectory).mockReturnValue(FAKE_DATA_DIR); + vi.mocked(directories.getDataDirectory).mockReturnValue(KAIDEN_DATA_DIRECTORY); vi.mocked(cliToolRegistry.getCliToolInfos).mockReturnValue([ { name: 'openshell-gateway', path: GATEWAY_BINARY }, ] as unknown as CliToolInfo[]); @@ -381,7 +374,7 @@ describe('start', () => { await gateway.start(); - expect(mkdir).toHaveBeenCalledWith(GATEWAY_DIR, { recursive: true }); + expect(mkdir).toHaveBeenCalledWith(GATEWAY_STORAGE_DIRECTORY, { recursive: true }); expect(exec.exec).toHaveBeenCalledWith(GATEWAY_BINARY, [ 'generate-certs', '--server-san', @@ -391,7 +384,7 @@ describe('start', () => { '--server-san', 'host.openshell.internal', '--output-dir', - GATEWAY_DIR, + GATEWAY_STORAGE_DIRECTORY, ]); }); @@ -403,10 +396,14 @@ describe('start', () => { await gateway.start(); - expect(writeFile).toHaveBeenCalledWith(CONFIG_PATH, expect.stringContaining('[openshell.gateway.gateway_jwt]')); - expect(writeFile).toHaveBeenCalledWith(CONFIG_PATH, expect.stringContaining('signing_key_path')); - expect(writeFile).toHaveBeenCalledWith(CONFIG_PATH, expect.stringContaining('public_key_path')); - expect(writeFile).toHaveBeenCalledWith(CONFIG_PATH, expect.stringContaining('kid_path')); + expect(writeFile).toHaveBeenCalledWith( + GATEWAY_CONFIG_PATH, + expect.stringContaining('[openshell.gateway.gateway_jwt]'), + 'utf-8', + ); + expect(writeFile).toHaveBeenCalledWith(GATEWAY_CONFIG_PATH, expect.stringContaining('signing_key_path'), 'utf-8'); + expect(writeFile).toHaveBeenCalledWith(GATEWAY_CONFIG_PATH, expect.stringContaining('public_key_path'), 'utf-8'); + expect(writeFile).toHaveBeenCalledWith(GATEWAY_CONFIG_PATH, expect.stringContaining('kid_path'), 'utf-8'); }); test('starts gateway without --config when cert generation fails', async () => { @@ -582,7 +579,7 @@ describe('gateway config generation', () => { test('still generates config when version detection fails', async () => { vi.spyOn(console, 'warn').mockImplementation(() => undefined); - vi.mocked(exec.exec).mockRejectedValue(new Error('command not found')); + vi.mocked(exec.exec).mockRejectedValueOnce(new Error('command not found')); await gateway.start(); diff --git a/packages/main/src/plugin/openshell-cli/openshell-gateway.toml.template b/packages/main/src/plugin/openshell-cli/openshell-gateway.toml.template index b3a5ff002..bdb2ea236 100644 --- a/packages/main/src/plugin/openshell-cli/openshell-gateway.toml.template +++ b/packages/main/src/plugin/openshell-cli/openshell-gateway.toml.template @@ -5,3 +5,12 @@ version = 1 {{#supervisorImage}} supervisor_image = "{{{supervisorImage}}}" {{/supervisorImage}} + +[openshell.gateway.auth] +allow_unauthenticated_users = true + +[openshell.gateway.gateway_jwt] +signing_key_path = "{{{gatewayDir}}}/jwt/signing.pem" +public_key_path = "{{{gatewayDir}}}/jwt/public.pem" +kid_path = "{{{gatewayDir}}}/jwt/kid" +ttl_secs = 3600 diff --git a/packages/main/src/plugin/openshell-cli/openshell-gateway.ts b/packages/main/src/plugin/openshell-cli/openshell-gateway.ts index cbd8831f5..7bbb46f54 100644 --- a/packages/main/src/plugin/openshell-cli/openshell-gateway.ts +++ b/packages/main/src/plugin/openshell-cli/openshell-gateway.ts @@ -55,8 +55,6 @@ export class OpenshellGateway implements Disposable { #bindAddress: string = DEFAULT_BIND_ADDRESS; constructor( - @inject(Exec) - private readonly exec: Exec, @inject(CliToolRegistry) private readonly cliToolRegistry: CliToolRegistry, @inject(OpenshellCli) @@ -222,11 +220,7 @@ export class OpenshellGateway implements Disposable { this.stop().catch((err: unknown) => console.error('[openshell-gateway] failed to stop: ', err)); } - private async generateCerts(binaryPath: string): Promise { - const gatewayDir = join(this.directories.getDataDirectory(), 'openshell-gateway'); - await mkdir(gatewayDir, { recursive: true }); - - console.log('[openshell-gateway] generating certificates'); + private async generateCerts(binaryPath: string, gatewayDir: string): Promise { await this.exec.exec(binaryPath, [ 'generate-certs', '--server-san', @@ -238,9 +232,6 @@ export class OpenshellGateway implements Disposable { '--output-dir', gatewayDir, ]); - console.log(`[openshell-gateway] certificates generated in ${gatewayDir}`); - - return gatewayDir; } private buildArgs(disableTls: boolean, configPath?: string): string[] { @@ -253,9 +244,6 @@ export class OpenshellGateway implements Disposable { if (disableTls) { args.push('--disable-tls'); } - if (configPath) { - args.push('--config', configPath); - } return args; } @@ -285,8 +273,11 @@ export class OpenshellGateway implements Disposable { const storageDirectory = join(this.directories.getDataDirectory(), 'openshell-gateway'); const configPath = join(storageDirectory, 'gateway.toml'); + await this.generateCerts(binaryPath, storageDirectory); const config = Mustache.render(gatewayConfigTemplate, { supervisorImage: image, + gatewayDir: storageDirectory, + q: '"', }); await mkdir(storageDirectory, { recursive: true });