mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
feat(speech): set the OpenAI dictation key on a headless host (#31230)
* feat(speech): set the OpenAI dictation key on a headless host (orca speech openai-key)
Cloud dictation needs an OpenAI key on the host that transcribes, and
desktop Settings was the only way to save one. A headless orca serve or
managed server had none. speech.openAiKey.{status,set,clear} save it
through the host's existing key store and secret sealing; the CLI reads
the key from stdin so it never lands in shell history.
* fix(cli): name the update when an older server lacks the speech key methods; one key length cap; PowerShell example (Opus review)
This commit is contained in:
@@ -156,6 +156,7 @@ export function supportsBrowserPageFlag(commandPath: string[]): boolean {
|
||||
'emulator',
|
||||
'note',
|
||||
'diagnostics',
|
||||
'speech',
|
||||
'jira',
|
||||
'linear',
|
||||
'skills',
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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<Buffer> {
|
||||
for (const part of parts) {
|
||||
yield Buffer.from(part)
|
||||
}
|
||||
}
|
||||
|
||||
function piped(...parts: string[]): AsyncIterable<unknown> & { 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<never> => {
|
||||
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'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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<unknown> & { isTTY?: boolean } = process.stdin
|
||||
): Promise<string> {
|
||||
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:<id>` can reach an older server, whose raw `method_not_found` reads as a bug.
|
||||
export async function callOpenAiKeyMethod(
|
||||
client: Pick<HandlerContext['client'], 'call'>,
|
||||
method: string,
|
||||
params?: { apiKey: string }
|
||||
): Promise<RuntimeRpcSuccess<OpenAiSpeechKeyStatus>> {
|
||||
try {
|
||||
return params === undefined
|
||||
? await client.call<OpenAiSpeechKeyStatus>(method)
|
||||
: await client.call<OpenAiSpeechKeyStatus>(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<string, CommandHandler> = {
|
||||
'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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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']
|
||||
}
|
||||
]
|
||||
@@ -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()
|
||||
})
|
||||
]
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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),
|
||||
|
||||
@@ -64,6 +64,7 @@ export const CLI_COMMAND_NAMES = [
|
||||
'set',
|
||||
'skills',
|
||||
'snapshot',
|
||||
'speech',
|
||||
'status',
|
||||
'storage',
|
||||
'tab',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user