diff --git a/src/cli/args.ts b/src/cli/args.ts index 8dba22629a0..6ac8dd52ce2 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -156,6 +156,7 @@ export function supportsBrowserPageFlag(commandPath: string[]): boolean { 'emulator', 'note', 'diagnostics', + 'speech', 'jira', 'linear', 'skills', diff --git a/src/cli/handler-group-manifest.ts b/src/cli/handler-group-manifest.ts index f9f63d7f04e..63be9a63f7e 100644 --- a/src/cli/handler-group-manifest.ts +++ b/src/cli/handler-group-manifest.ts @@ -222,6 +222,11 @@ export const HANDLER_GROUPS: readonly HandlerGroup[] = [ keys: ['diagnostics memory', 'terminal-daemon status', 'terminal-daemon restart'], load: async () => (await import('./handlers/diagnostics.js')).DIAGNOSTICS_HANDLERS }, + { + name: 'speech', + keys: ['speech openai-key status', 'speech openai-key set', 'speech openai-key clear'], + load: async () => (await import('./handlers/speech.js')).SPEECH_HANDLERS + }, { name: 'introspection', keys: ['agent-context'], diff --git a/src/cli/handlers/speech.test.ts b/src/cli/handlers/speech.test.ts new file mode 100644 index 00000000000..fee33ee91ca --- /dev/null +++ b/src/cli/handlers/speech.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest' +import { RuntimeClientError } from '../runtime-client' +import { callOpenAiKeyMethod, readOpenAiKeyFromStdin } from './speech' + +async function* chunks(...parts: string[]): AsyncGenerator { + for (const part of parts) { + yield Buffer.from(part) + } +} + +function piped(...parts: string[]): AsyncIterable & { isTTY?: boolean } { + return chunks(...parts) +} + +describe('orca speech openai-key set', () => { + it('reads the key from piped stdin and trims the trailing newline', async () => { + await expect(readOpenAiKeyFromStdin(piped('sk-test-', 'abc\n'))).resolves.toBe('sk-test-abc') + }) + + // Why: a key typed as an argument would sit in shell history and process listings. + it('refuses an interactive terminal instead of waiting on it', async () => { + const terminal = Object.assign(chunks(), { isTTY: true }) + await expect(readOpenAiKeyFromStdin(terminal)).rejects.toThrow('Pipe the key on stdin') + }) + + it('refuses empty and oversized input', async () => { + await expect(readOpenAiKeyFromStdin(piped(' \n'))).rejects.toThrow('No key on stdin') + await expect(readOpenAiKeyFromStdin(piped('x'.repeat(1025)))).rejects.toThrow('too long') + }) +}) + +describe('orca speech openai-key against an older server', () => { + it('names the update instead of surfacing method_not_found', async () => { + const client = { + call: async (): Promise => { + throw new RuntimeClientError('method_not_found', 'Unknown method: speech.openAiKey.status') + } + } + await expect(callOpenAiKeyMethod(client, 'speech.openAiKey.status')).rejects.toThrow( + 'Update Orca on the server' + ) + }) +}) diff --git a/src/cli/handlers/speech.ts b/src/cli/handlers/speech.ts new file mode 100644 index 00000000000..89d698aeae0 --- /dev/null +++ b/src/cli/handlers/speech.ts @@ -0,0 +1,82 @@ +import type { CommandHandler, HandlerContext } from '../dispatch' +import { printResult } from '../format' +import { RuntimeClientError, type RuntimeRpcSuccess } from '../runtime-client' +import { OPENAI_SPEECH_KEY_MAX_LENGTH } from '../../shared/rpc-contract/speech-params' + +type OpenAiSpeechKeyStatus = { configured: boolean; protection: string | null } + +function formatKeyStatus(status: OpenAiSpeechKeyStatus): string { + if (!status.configured) { + return 'No OpenAI key for voice dictation on this host.' + } + return status.protection === 'plaintext' + ? 'OpenAI key saved on this host, unencrypted: this host cannot seal secrets.' + : 'OpenAI key saved on this host.' +} + +// Why stdin and never a flag: argv shows up in shell history and process listings. +export async function readOpenAiKeyFromStdin( + stdin: AsyncIterable & { isTTY?: boolean } = process.stdin +): Promise { + if (stdin.isTTY) { + throw new RuntimeClientError( + 'invalid_argument', + 'Pipe the key on stdin, e.g. printf %s "$OPENAI_API_KEY" | orca speech openai-key set' + ) + } + const chunks: Buffer[] = [] + let bytes = 0 + for await (const chunk of stdin) { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)) + bytes += buffer.length + if (bytes > OPENAI_SPEECH_KEY_MAX_LENGTH * 4) { + throw new RuntimeClientError('invalid_argument', 'That is too long to be an OpenAI key') + } + chunks.push(buffer) + } + const key = Buffer.concat(chunks).toString('utf8').trim() + if (!key) { + throw new RuntimeClientError('invalid_argument', 'No key on stdin') + } + if (key.length > OPENAI_SPEECH_KEY_MAX_LENGTH) { + throw new RuntimeClientError('invalid_argument', 'That is too long to be an OpenAI key') + } + return key +} + +// Why: `--host runtime:` can reach an older server, whose raw `method_not_found` reads as a bug. +export async function callOpenAiKeyMethod( + client: Pick, + method: string, + params?: { apiKey: string } +): Promise> { + try { + return params === undefined + ? await client.call(method) + : await client.call(method, params) + } catch (error) { + if (error instanceof RuntimeClientError && error.code === 'method_not_found') { + throw new RuntimeClientError( + 'incompatible_runtime', + 'This Orca server cannot store a voice dictation key yet. Update Orca on the server and try again.' + ) + } + throw error + } +} + +export const SPEECH_HANDLERS: Record = { + 'speech openai-key status': async ({ client, json }) => { + const result = await callOpenAiKeyMethod(client, 'speech.openAiKey.status') + printResult(result, json, formatKeyStatus) + }, + 'speech openai-key set': async ({ client, json }) => { + const apiKey = await readOpenAiKeyFromStdin() + const result = await callOpenAiKeyMethod(client, 'speech.openAiKey.set', { apiKey }) + printResult(result, json, formatKeyStatus) + }, + 'speech openai-key clear': async ({ client, json }) => { + const result = await callOpenAiKeyMethod(client, 'speech.openAiKey.clear') + printResult(result, json, formatKeyStatus) + } +} diff --git a/src/cli/specs/index.ts b/src/cli/specs/index.ts index 680f048ec61..7b66b87a03e 100644 --- a/src/cli/specs/index.ts +++ b/src/cli/specs/index.ts @@ -13,6 +13,7 @@ import { MANAGED_SERVER_COMMAND_SPECS } from './managed-server' import { AGENT_HOOK_COMMAND_SPECS } from './agent-hooks' import { AGENT_LAUNCH_COMMAND_SPECS } from './agent-launch' import { DIAGNOSTICS_COMMAND_SPECS } from './diagnostics' +import { SPEECH_COMMAND_SPECS } from './speech' import { TERMINAL_DAEMON_COMMAND_SPECS } from './terminal-daemon' import { EMULATOR_COMMAND_SPECS } from './emulator' import { INTROSPECTION_COMMAND_SPECS } from './introspection' @@ -40,6 +41,7 @@ export const COMMAND_SPECS: CommandSpec[] = [ ...AGENT_HOOK_COMMAND_SPECS, ...AGENT_LAUNCH_COMMAND_SPECS, ...DIAGNOSTICS_COMMAND_SPECS, + ...SPEECH_COMMAND_SPECS, ...TERMINAL_DAEMON_COMMAND_SPECS, ...INTROSPECTION_COMMAND_SPECS, ...ENVIRONMENT_COMMAND_SPECS, diff --git a/src/cli/specs/speech.ts b/src/cli/specs/speech.ts new file mode 100644 index 00000000000..98915568458 --- /dev/null +++ b/src/cli/specs/speech.ts @@ -0,0 +1,33 @@ +import type { CommandSpec } from '../args' +import { GLOBAL_FLAGS } from '../args' + +export const SPEECH_COMMAND_SPECS: CommandSpec[] = [ + { + path: ['speech', 'openai-key', 'status'], + summary: 'Show whether this host has an OpenAI key for cloud voice dictation', + usage: 'orca speech openai-key status [--json]', + allowedFlags: [...GLOBAL_FLAGS], + examples: ['orca speech openai-key status --json'] + }, + { + path: ['speech', 'openai-key', 'set'], + summary: 'Save the OpenAI key cloud voice dictation uses on this host, read from stdin', + usage: 'orca speech openai-key set [--json] < key-file', + allowedFlags: [...GLOBAL_FLAGS], + notes: [ + 'Reads the key from stdin so it never lands in shell history or a process list.', + 'The host that runs dictation seals it, so a headless `orca serve` host can use it.' + ], + examples: [ + 'printf %s "$OPENAI_API_KEY" | orca speech openai-key set', + '$env:OPENAI_API_KEY | orca speech openai-key set' + ] + }, + { + path: ['speech', 'openai-key', 'clear'], + summary: 'Remove the OpenAI key cloud voice dictation uses on this host', + usage: 'orca speech openai-key clear [--json]', + allowedFlags: [...GLOBAL_FLAGS], + examples: ['orca speech openai-key clear'] + } +] diff --git a/src/main/runtime/rpc/methods/speech.ts b/src/main/runtime/rpc/methods/speech.ts index ff1b6914c40..0d8da9b6de9 100644 --- a/src/main/runtime/rpc/methods/speech.ts +++ b/src/main/runtime/rpc/methods/speech.ts @@ -4,6 +4,7 @@ import { DictationHandle, DictationSetup, DictationStart, + OpenAiSpeechKeySet, SpeechModelAction } from '../../../../shared/rpc-contract/speech-params' @@ -64,5 +65,23 @@ export const SPEECH_METHODS = [ params: DictationHandle, handler: async (params, { runtime, clientId, connectionId }) => runtime.cancelMobileDictation({ ...params, clientId, connectionId }) + }), + defineMethod({ + name: 'speech.openAiKey.status', + permission: 'workspace', + params: null, + handler: async (_params, { runtime }) => runtime.getOpenAiSpeechKeyStatus() + }), + defineMethod({ + name: 'speech.openAiKey.set', + permission: 'settings-write', + params: OpenAiSpeechKeySet, + handler: async (params, { runtime }) => runtime.setOpenAiSpeechKey(params.apiKey) + }), + defineMethod({ + name: 'speech.openAiKey.clear', + permission: 'settings-write', + params: null, + handler: async (_params, { runtime }) => runtime.clearOpenAiSpeechKey() }) ] diff --git a/src/main/runtime/runtime-mobile-speech-catalog.ts b/src/main/runtime/runtime-mobile-speech-catalog.ts index a31875677b7..8a5d234436d 100644 --- a/src/main/runtime/runtime-mobile-speech-catalog.ts +++ b/src/main/runtime/runtime-mobile-speech-catalog.ts @@ -8,6 +8,18 @@ import { getSpeechModelDeletionErrorCode } from '../speech/speech-model-deletion' import type { RuntimeStore } from './runtime-store-contract' +import { + clearOpenAiSpeechApiKey, + getOpenAiSpeechApiKeyProtection, + hasOpenAiSpeechApiKey, + saveOpenAiSpeechApiKey +} from '../speech/openai-api-key-store' +import type { SecretAtRestProtection } from '../../shared/secret-at-rest-protection' + +export type OpenAiSpeechKeyStatus = { + configured: boolean + protection: SecretAtRestProtection | null +} export class RuntimeMobileSpeechCatalog { constructor(private readonly getStore: () => RuntimeStore | null) {} @@ -89,6 +101,24 @@ export class RuntimeMobileSpeechCatalog { return this.list() } + /** + * The cloud models' key, set on the host that transcribes. Why over RPC: desktop Settings is the + * only other way in, and a headless host has no desktop. Sealed by this host's own secret store. + */ + openAiKeyStatus(): OpenAiSpeechKeyStatus { + return { configured: hasOpenAiSpeechApiKey(), protection: getOpenAiSpeechApiKeyProtection() } + } + + setOpenAiKey(apiKey: string): OpenAiSpeechKeyStatus { + saveOpenAiSpeechApiKey(apiKey) + return this.openAiKeyStatus() + } + + clearOpenAiKey(): OpenAiSpeechKeyStatus { + clearOpenAiSpeechApiKey() + return this.openAiKeyStatus() + } + private requireStore(): RuntimeStore { const store = this.getStore() if (!store) { diff --git a/src/main/runtime/runtime-mobile-speech-openai-key.test.ts b/src/main/runtime/runtime-mobile-speech-openai-key.test.ts new file mode 100644 index 00000000000..5c0792c8e78 --- /dev/null +++ b/src/main/runtime/runtime-mobile-speech-openai-key.test.ts @@ -0,0 +1,40 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const keyStore = vi.hoisted(() => { + const store: { key: string | null } = { key: null } + return store +}) + +vi.mock('../speech/openai-api-key-store', () => ({ + hasOpenAiSpeechApiKey: () => keyStore.key !== null, + getOpenAiSpeechApiKeyProtection: () => (keyStore.key === null ? null : 'sealed'), + saveOpenAiSpeechApiKey: (apiKey: string) => { + keyStore.key = apiKey + }, + clearOpenAiSpeechApiKey: () => { + keyStore.key = null + } +})) + +const { RuntimeMobileSpeechCatalog } = await import('./runtime-mobile-speech-catalog') +const { OpenAiSpeechKeySet } = await import('../../shared/rpc-contract/speech-params') + +beforeEach(() => { + keyStore.key = null +}) + +// A headless host has no desktop Settings, so the key for cloud dictation is set over RPC. +describe('the OpenAI speech key on the host that transcribes', () => { + it('saves, reports and clears the key through the host secret store', () => { + const catalog = new RuntimeMobileSpeechCatalog(() => null) + expect(catalog.openAiKeyStatus()).toEqual({ configured: false, protection: null }) + expect(catalog.setOpenAiKey('sk-test')).toEqual({ configured: true, protection: 'sealed' }) + expect(keyStore.key).toBe('sk-test') + expect(catalog.clearOpenAiKey()).toEqual({ configured: false, protection: null }) + }) + + it('accepts a trimmed key and refuses an empty one', () => { + expect(OpenAiSpeechKeySet.parse({ apiKey: ' sk-test\n' })).toEqual({ apiKey: 'sk-test' }) + expect(OpenAiSpeechKeySet.safeParse({ apiKey: ' ' }).success).toBe(false) + }) +}) diff --git a/src/main/runtime/runtime-service-command-surface.ts b/src/main/runtime/runtime-service-command-surface.ts index 8213fe58b72..33bee40e457 100644 --- a/src/main/runtime/runtime-service-command-surface.ts +++ b/src/main/runtime/runtime-service-command-surface.ts @@ -72,6 +72,9 @@ export type RuntimeServiceCommandSurface = { downloadMobileSpeechModel: RuntimeMobileSpeechCatalog['download'] deleteMobileSpeechModel: RuntimeMobileSpeechCatalog['delete'] configureMobileDictation: RuntimeMobileSpeechCatalog['configure'] + getOpenAiSpeechKeyStatus: RuntimeMobileSpeechCatalog['openAiKeyStatus'] + setOpenAiSpeechKey: RuntimeMobileSpeechCatalog['setOpenAiKey'] + clearOpenAiSpeechKey: RuntimeMobileSpeechCatalog['clearOpenAiKey'] startMobileDictation: RuntimeMobileDictationController['start'] feedMobileDictation: RuntimeMobileDictationController['feed'] finishMobileDictation: RuntimeMobileDictationController['finish'] @@ -179,6 +182,9 @@ export function installRuntimeServiceCommandSurface( downloadMobileSpeechModel: speech.download.bind(speech), deleteMobileSpeechModel: speech.delete.bind(speech), configureMobileDictation: speech.configure.bind(speech), + getOpenAiSpeechKeyStatus: speech.openAiKeyStatus.bind(speech), + setOpenAiSpeechKey: speech.setOpenAiKey.bind(speech), + clearOpenAiSpeechKey: speech.clearOpenAiKey.bind(speech), startMobileDictation: dictation.start.bind(dictation), feedMobileDictation: dictation.feed.bind(dictation), finishMobileDictation: dictation.finish.bind(dictation), diff --git a/src/main/startup/cli-command-names.ts b/src/main/startup/cli-command-names.ts index 53031140b1c..8344f08f219 100644 --- a/src/main/startup/cli-command-names.ts +++ b/src/main/startup/cli-command-names.ts @@ -64,6 +64,7 @@ export const CLI_COMMAND_NAMES = [ 'set', 'skills', 'snapshot', + 'speech', 'status', 'storage', 'tab', diff --git a/src/shared/rpc-contract/rpc-params-catalog.generated.ts b/src/shared/rpc-contract/rpc-params-catalog.generated.ts index 777d2610dae..161a45192d0 100644 --- a/src/shared/rpc-contract/rpc-params-catalog.generated.ts +++ b/src/shared/rpc-contract/rpc-params-catalog.generated.ts @@ -521,6 +521,7 @@ import { DictationHandle, DictationSetup, DictationStart, + OpenAiSpeechKeySet, SpeechModelAction } from './speech-params' import { @@ -1317,6 +1318,9 @@ export const RPC_PARAMS_BY_METHOD = { 'speech.models.delete': SpeechModelAction, 'speech.models.download': SpeechModelAction, 'speech.models.list': null, + 'speech.openAiKey.clear': null, + 'speech.openAiKey.set': OpenAiSpeechKeySet, + 'speech.openAiKey.status': null, 'ssh.addTarget': SshTargetAdd, 'ssh.browseDir': SshBrowseDir, 'ssh.connect': SshConnect, diff --git a/src/shared/rpc-contract/speech-params.ts b/src/shared/rpc-contract/speech-params.ts index 8cd0ea00a49..94361c6d18f 100644 --- a/src/shared/rpc-contract/speech-params.ts +++ b/src/shared/rpc-contract/speech-params.ts @@ -52,3 +52,10 @@ export const DictationSetup = z.object({ modelId: OptionalString, dictationMode: z.enum(['toggle', 'hold']).optional() }) + +/** Why the cap: an OpenAI key is ~200 characters; anything far longer is a paste mistake. */ +export const OPENAI_SPEECH_KEY_MAX_LENGTH = 1024 + +export const OpenAiSpeechKeySet = z.object({ + apiKey: z.string().trim().min(1).max(OPENAI_SPEECH_KEY_MAX_LENGTH) +})