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:
Neil
2026-10-20 15:12:28 -07:00
committed by GitHub
parent c335070de8
commit d563f8e0e0
13 changed files with 273 additions and 0 deletions
+1
View File
@@ -156,6 +156,7 @@ export function supportsBrowserPageFlag(commandPath: string[]): boolean {
'emulator',
'note',
'diagnostics',
'speech',
'jira',
'linear',
'skills',
+5
View File
@@ -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'],
+43
View File
@@ -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'
)
})
})
+82
View File
@@ -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)
}
}
+2
View File
@@ -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,
+33
View File
@@ -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']
}
]
+19
View File
@@ -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),
+1
View File
@@ -64,6 +64,7 @@ export const CLI_COMMAND_NAMES = [
'set',
'skills',
'snapshot',
'speech',
'status',
'storage',
'tab',
+4
View File
@@ -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,
+7
View File
@@ -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)
})