diff --git a/config.toml.example b/config.toml.example index 59c1c66..edbec89 100644 --- a/config.toml.example +++ b/config.toml.example @@ -12,3 +12,7 @@ assistant_root = "~/Code/assistant" bot_token = "replace-with-the-token-from-BotFather" # Replace this once with your numeric Telegram user ID. allow_user_ids = [123456789] + +[voice] +# Optional. Uncomment to enable voice transcription and spoken replies. +# openai_api_key = "replace-with-your-openai-api-key" diff --git a/docs/architecture.md b/docs/architecture.md index 67f7520..746a661 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -233,10 +233,11 @@ generic audio clip, so it has no Telegram identifiers or Bot API behavior. Download and transcription run in the accepted message's per-thread worker, so slow audio cannot pause polling or work in another conversation. -The first provider uses `OPENAI_API_KEY` for both `gpt-4o-transcribe` and +The first provider uses `voice.openai_api_key`, with `OPENAI_API_KEY` as a +higher-priority environment override, for both `gpt-4o-transcribe` and `gpt-4o-mini-tts`. The first channel implementation is Telegram. A future -channel needs only voice download and upload support. It does not need to -change gateway routing, agent requests, or the OpenAI client. +channel needs only voice download and upload support. It does not need to change +gateway routing, agent requests, or the OpenAI client. Voice is an optional enhancement. Missing credentials stop voice processing for that message with a text explanation, while ordinary text traffic remains diff --git a/docs/configuration.md b/docs/configuration.md index 1ca6c73..f9c8d6f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -91,10 +91,17 @@ private. `push init` creates new config files with mode `0600` on Unix. The `bot_token_env` setting remains available when an environment variable is a better fit. See the [Telegram guide](telegram.md). -Telegram voice notes are optional and need no TOML settings. Set -`OPENAI_API_KEY` in the gateway process environment to enable both -transcription and speech replies. Without it, text remains fully available and -voice notes get a helpful fallback. See [Voice Messages](telegram.md#voice-messages). +Telegram voice notes are optional. Configure the shared voice provider with: + +```toml +[voice] +openai_api_key = "your-api-key" +``` + +`OPENAI_API_KEY` remains available as a higher-priority override for CI and +service secret injection. Without either value, text remains fully available +and voice notes get a helpful fallback. See +[Voice Messages](telegram.md#voice-messages). ### Run both providers diff --git a/docs/security.md b/docs/security.md index 5616cc5..0290741 100644 --- a/docs/security.md +++ b/docs/security.md @@ -60,8 +60,13 @@ Keep them on local durable storage with permissions restricted to the service user. Keep the assistant directory in its own private Git repository. Never put real config secrets, state, audit logs, or databases in that repository. An explicit `assistant_root` config stored inside it cannot -contain an inline Telegram token; use `telegram.bot_token_env` or move the -config outside. +contain an inline Telegram token or OpenAI API key; use the matching environment +variable or move the config outside. When `voice.openai_api_key` is configured, +`push doctor` requires the config file to be private on Unix: + +```sh +chmod 600 ~/.push/config.toml +``` ## Network exposure @@ -95,7 +100,8 @@ IDs, file paths, and backend errors. Protect and rotate it like a service log. - allow only identities you control - configure the selected agent for unattended use - run `push doctor` as the service user -- keep backend credentials out of TOML and logs +- keep agent credentials out of TOML and all credentials out of logs +- protect config credentials with mode `0600` on Unix - use absolute config paths in service files - keep Push state and job work directories separate - inspect agent-authored jobs before approving them diff --git a/docs/services.md b/docs/services.md index 3befc86..82d5b9d 100644 --- a/docs/services.md +++ b/docs/services.md @@ -31,8 +31,9 @@ Use absolute paths in service files. The service user needs: - for iMessage on macOS, Full Disk Access and `osascript` - for Telegram, a token in the private config and network access to `api.telegram.org` -- for optional voice messages, `OPENAI_API_KEY` in the service environment and - network access to `api.openai.com` +- for optional voice messages, `voice.openai_api_key` in the private config or + `OPENAI_API_KEY` in the service environment, plus network access to + `api.openai.com` `state_path` stores independent cursors for each channel and backend session ids. `database_path` stores the canonical conversation journal. Chat agents run @@ -78,8 +79,6 @@ and replace `YOU` with your macOS user name: PATH /Users/YOU/.local/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin - OPENAI_API_KEY - replace-with-your-openai-api-key RunAtLoad @@ -113,9 +112,9 @@ launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.owainlewis.push.plis launchctl kickstart -k gui/$(id -u)/com.owainlewis.push ``` -The plist contains the optional OpenAI key, so protect it with -`chmod 600 ~/Library/LaunchAgents/com.owainlewis.push.plist`. Omit the key when -you do not want voice support. +For voice support, prefer `voice.openai_api_key` in the private Push config. An +`OPENAI_API_KEY` entry in `EnvironmentVariables` remains available as an +override when service-level secret injection is preferred. ## Linux systemd @@ -159,7 +158,8 @@ systemctl --user status push.service journalctl --user -u push.service -f ``` -For voice support, create the optional private environment file: +For voice support, prefer `voice.openai_api_key` in `~/.push/config.toml`. As an +alternative, create the optional private environment file: ```sh printf 'OPENAI_API_KEY=replace-with-your-openai-api-key\n' > ~/.config/push/env @@ -168,7 +168,8 @@ systemctl --user restart push.service ``` Keep `~/.push/config.toml` at mode `0600` because it contains the Telegram bot -token. Do not commit this file or print it in service logs. +token and may contain the OpenAI API key. Do not commit this file or print it in +service logs. For a user service that survives logout, enable lingering: diff --git a/docs/telegram.md b/docs/telegram.md index 2254c0b..3587361 100644 --- a/docs/telegram.md +++ b/docs/telegram.md @@ -52,24 +52,43 @@ Telegram-only preflight does not open `chat.db` and does not require macOS or ## Voice Messages -Set one optional environment variable to enable both incoming voice -transcription and spoken replies: +Add the optional OpenAI API key to `~/.push/config.toml` to enable both incoming +voice transcription and spoken replies: + +```toml +[voice] +openai_api_key = "your-api-key" +``` + +Validate the config, then restart the one existing Push process: ```sh -export OPENAI_API_KEY="your-api-key" push doctor -push ``` +For a foreground process, stop it and run `push` again. For launchd, use: + +```sh +launchctl kickstart -k gui/$(id -u)/com.owainlewis.push +``` + +See [Running Push as a Service](services.md) for the full launchd and systemd +restart commands. Do not start a foreground gateway while the service is +running because Telegram permits only one poller for a bot token. + +`OPENAI_API_KEY` remains available for CI and service secret injection. A +non-empty environment value overrides the configured key. + Send the bot a Telegram voice note. Push downloads it only after the sender passes the normal allowlist, transcribes it with `gpt-4o-transcribe`, sends the transcript through the selected coding agent, then returns the answer as both text and an Opus voice note generated by `gpt-4o-mini-tts`. There is no local Whisper, FFmpeg, or other audio dependency. Audio stays in -memory and is limited to 20 MB. If `OPENAI_API_KEY` is absent, normal text -messages keep working and voice notes receive an actionable text reply. API or -speech generation errors also fall back to text without stopping the gateway. +memory and is limited to 20 MB. If neither `voice.openai_api_key` nor +`OPENAI_API_KEY` is set, normal text messages keep working and voice notes +receive an actionable text reply. API or speech generation errors also fall +back to text without stopping the gateway. The spoken reply is AI-generated. Voice-note audio is sent to OpenAI for processing, so review OpenAI's data controls before enabling this feature. diff --git a/examples/launchd/com.owainlewis.push.plist b/examples/launchd/com.owainlewis.push.plist index 8bb0919..121abfe 100644 --- a/examples/launchd/com.owainlewis.push.plist +++ b/examples/launchd/com.owainlewis.push.plist @@ -20,9 +20,6 @@ PATH /Users/YOU/.local/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin - - - RunAtLoad diff --git a/src/assistant.rs b/src/assistant.rs index d3792b7..44ba85e 100644 --- a/src/assistant.rs +++ b/src/assistant.rs @@ -7,7 +7,7 @@ use std::process::Command; use anyhow::{bail, Context, Result}; -use crate::config::validate_inline_token_location; +use crate::config::{validate_inline_token_location, validate_inline_voice_key_location}; use crate::util::expand_home; const SOUL: &str = r#"# SOUL @@ -158,6 +158,17 @@ fn validate_config_secrets(config_path: &Path, target: &Path, config: &toml::Tab for token in [flat_token, nested_token] { validate_inline_token_location(&config_path, &assistant, token)?; } + let flat_voice_key = config + .get("voice_openai_api_key") + .and_then(toml::Value::as_str); + let nested_voice_key = config + .get("voice") + .and_then(toml::Value::as_table) + .and_then(|voice| voice.get("openai_api_key")) + .and_then(toml::Value::as_str); + for key in [flat_voice_key, nested_voice_key] { + validate_inline_voice_key_location(&config_path, &assistant, key)?; + } Ok(()) } @@ -759,4 +770,22 @@ mod tests { ); let _ = fs::remove_dir_all(target); } + + #[test] + fn refuses_an_inline_voice_key_in_a_config_inside_the_assistant() { + let target = temp_path("assistant-voice-secret-config"); + fs::create_dir_all(&target).unwrap(); + let config = target.join("config.toml"); + fs::write(&config, "[voice]\nopenai_api_key = 'secret'\n").unwrap(); + + let error = init(target.to_str().unwrap(), config.to_str().unwrap()).unwrap_err(); + + assert!(error.to_string().contains("inline OpenAI API key")); + assert!(!target.join("SOUL.md").exists()); + assert_eq!( + fs::read_to_string(&config).unwrap(), + "[voice]\nopenai_api_key = 'secret'\n" + ); + let _ = fs::remove_dir_all(target); + } } diff --git a/src/config.rs b/src/config.rs index 771fce4..2e703eb 100644 --- a/src/config.rs +++ b/src/config.rs @@ -35,6 +35,8 @@ pub struct Config { pub telegram_allow_user_ids: Vec, #[serde(default)] pub telegram_allow_chat_ids: Vec, + #[serde(default)] + pub voice_openai_api_key: Option, #[serde(default = "default_agent")] pub agent: String, #[serde(default)] @@ -170,6 +172,7 @@ impl Config { ("allow_chat_ids", "telegram_allow_chat_ids"), ], )?; + flatten_provider_section(root, "voice", &[("openai_api_key", "voice_openai_api_key")])?; let mut c: Config = value.try_into().context("parse TOML config")?; let config_path = std::fs::canonicalize(&expanded_path) .with_context(|| format!("resolve config {expanded_path}"))?; @@ -199,6 +202,11 @@ impl Config { &assistant_root, c.telegram_bot_token.as_deref(), )?; + validate_inline_voice_key_location( + &config_path, + &assistant_root, + c.voice_openai_api_key.as_deref(), + )?; } c.assistant_root = assistant_root.to_string_lossy().to_string(); c.assistant_dir = c.assistant_root.clone(); @@ -396,6 +404,13 @@ impl Config { } fn validate(&self) -> Result<()> { + if self + .voice_openai_api_key + .as_deref() + .is_some_and(|value| value.trim().is_empty()) + { + bail!("voice.openai_api_key cannot be empty"); + } for channel in self.enabled_channel_kinds()? { match channel { ChannelKind::IMessage => { @@ -539,6 +554,20 @@ pub(crate) fn validate_inline_token_location( Ok(()) } +pub(crate) fn validate_inline_voice_key_location( + config_path: &Path, + assistant_root: &Path, + key: Option<&str>, +) -> Result<()> { + if key.is_some_and(|key| !key.trim().is_empty()) && config_path.starts_with(assistant_root) { + bail!( + "config {} contains an inline OpenAI API key inside the Git-versioned assistant repository. Move the config outside the assistant or use OPENAI_API_KEY.", + config_path.display() + ); + } + Ok(()) +} + #[cfg(test)] fn validate_config_path(path: &str, cfg: &Config) -> Result { let config = std::fs::canonicalize(path).with_context(|| format!("resolve config {path}"))?; @@ -828,6 +857,7 @@ mod tests { telegram_bot_token_env: "TELEGRAM_BOT_TOKEN".to_string(), telegram_allow_user_ids: Vec::new(), telegram_allow_chat_ids: Vec::new(), + voice_openai_api_key: None, agent: "codex".to_string(), routes: Vec::new(), assistant_root: root.to_string_lossy().to_string(), diff --git a/src/doctor.rs b/src/doctor.rs index fe1544a..7533b18 100644 --- a/src/doctor.rs +++ b/src/doctor.rs @@ -49,6 +49,7 @@ pub fn doctor(config_path: &str) -> Result<()> { fn run_checks(cfg: &config::Config) -> CheckReport { let mut checks = Vec::new(); check_config(cfg, &mut checks); + check_voice_config_permissions(cfg, &mut checks); check_parent_dir( "state directory", "state_path", @@ -75,22 +76,63 @@ fn run_checks(cfg: &config::Config) -> CheckReport { Err(e) => checks.push(Check::fail("channels", e.to_string())), } check_bins(cfg, &mut checks); - check_voice(&mut checks); + check_voice(cfg, &mut checks); CheckReport { checks } } -fn check_voice(checks: &mut Vec) { - if std::env::var(crate::voice::OPENAI_API_KEY_ENV).is_ok_and(|value| !value.trim().is_empty()) { - checks.push(Check::pass( - "voice messages", - "OPENAI_API_KEY is set; transcription and speech replies are enabled", - )); - } else { - checks.push(Check::pass( - "voice messages", - "OPENAI_API_KEY is not set; text messaging works and voice requests get a helpful fallback", - )); +fn check_voice(cfg: &config::Config, checks: &mut Vec) { + let message = voice_check_message(crate::voice::Voice::credential_source(cfg)); + checks.push(Check::pass("voice messages", message)); +} + +fn voice_check_message(source: Option) -> &'static str { + match source { + Some(crate::voice::VoiceCredentialSource::Environment) => { + "OPENAI_API_KEY is set; transcription and speech replies are enabled" + } + Some(crate::voice::VoiceCredentialSource::Config) => { + "voice.openai_api_key is set; transcription and speech replies are enabled" + } + None => { + "no OpenAI API key is configured; text messaging works and voice requests get a helpful fallback" + } + } +} + +fn check_voice_config_permissions(cfg: &config::Config, checks: &mut Vec) { + if cfg + .voice_openai_api_key + .as_deref() + .is_none_or(|key| key.trim().is_empty()) + { + return; + } + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + match std::fs::metadata(&cfg.config_path) { + Ok(metadata) if metadata.permissions().mode() & 0o077 == 0 => checks.push(Check::pass( + "config permissions", + format!("{} is private", cfg.config_path), + )), + Ok(_) => checks.push(Check::fail( + "config permissions", + format!( + "{} contains voice.openai_api_key and is accessible by group or others. Run chmod 600 {}.", + cfg.config_path, cfg.config_path + ), + )), + Err(error) => checks.push(Check::fail( + "config permissions", + format!("cannot inspect {}: {error}", cfg.config_path), + )), + } } + + #[cfg(not(unix))] + let _ = checks; } fn check_config(cfg: &config::Config, checks: &mut Vec) { @@ -354,6 +396,40 @@ mod tests { use super::*; use crate::test_support::{temp_dir, temp_path, test_config}; + #[test] + fn voice_check_reports_config_source_without_a_secret() { + let message = voice_check_message(Some(crate::voice::VoiceCredentialSource::Config)); + + assert!(message.contains("voice.openai_api_key is set")); + assert!(!message.contains("secret")); + } + + #[cfg(unix)] + #[test] + fn inline_voice_key_requires_a_private_config_file() { + use std::os::unix::fs::PermissionsExt; + + let path = temp_path("voice-config-permissions"); + std::fs::write(&path, "[voice]\nopenai_api_key = 'secret'\n").unwrap(); + let mut cfg = test_config(); + cfg.config_path = path.to_string_lossy().to_string(); + cfg.voice_openai_api_key = Some("secret".to_string()); + + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap(); + let mut checks = Vec::new(); + check_voice_config_permissions(&cfg, &mut checks); + assert!(matches!(checks[0].status, CheckStatus::Fail)); + assert!(checks[0].message.contains("chmod 600")); + assert!(!checks[0].message.contains("'secret'")); + + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap(); + checks.clear(); + check_voice_config_permissions(&cfg, &mut checks); + assert!(matches!(checks[0].status, CheckStatus::Pass)); + + let _ = std::fs::remove_file(path); + } + #[test] fn doctor_reports_config_load_failure() { let path = temp_path("missing-config"); diff --git a/src/drafts.rs b/src/drafts.rs index f4456c4..03219e7 100644 --- a/src/drafts.rs +++ b/src/drafts.rs @@ -433,6 +433,7 @@ mod tests { telegram_bot_token_env: "TELEGRAM_BOT_TOKEN".to_string(), telegram_allow_user_ids: vec![7], telegram_allow_chat_ids: Vec::new(), + voice_openai_api_key: None, agent: "codex".to_string(), routes: Vec::new(), assistant_root: root.to_string_lossy().to_string(), diff --git a/src/gateway/mod.rs b/src/gateway/mod.rs index da2154c..5bc0d6c 100644 --- a/src/gateway/mod.rs +++ b/src/gateway/mod.rs @@ -351,7 +351,7 @@ impl Gateway { assistant_dir: cfg.assistant_dir.clone(), audit, #[cfg(not(test))] - voice: Voice::from_env(), + voice: Voice::from_config(&cfg), #[cfg(test)] voice: None, #[cfg(test)] diff --git a/src/gateway/tests.rs b/src/gateway/tests.rs index ef5e23c..61137de 100644 --- a/src/gateway/tests.rs +++ b/src/gateway/tests.rs @@ -1617,9 +1617,10 @@ async fn telegram_voice_without_openai_key_falls_back_without_running_agent() { run_messages(&mut gateway, vec![telegram_voice_message(1, 7, 7)]).await; assert!(calls.lock().unwrap().is_empty()); - assert!(gateway.ctx.sent_replies.lock().unwrap()[0] - .1 - .contains("OPENAI_API_KEY")); + let replies = gateway.ctx.sent_replies.lock().unwrap(); + let reply = &replies[0].1; + assert!(reply.contains("voice.openai_api_key")); + assert!(reply.contains("OPENAI_API_KEY")); assert_eq!(gateway.store.lock().unwrap().cursor("telegram"), 1); let _ = std::fs::remove_file(&state_path); @@ -2220,6 +2221,7 @@ fn test_config(state_path: &str, sessions_dir: &str, assistant_dir: &str) -> Con telegram_bot_token_env: "TELEGRAM_BOT_TOKEN".to_string(), telegram_allow_user_ids: Vec::new(), telegram_allow_chat_ids: Vec::new(), + voice_openai_api_key: None, agent: "codex".to_string(), routes: Vec::new(), assistant_root: assistant_dir.to_string(), diff --git a/src/gateway/worker.rs b/src/gateway/worker.rs index fb388bc..0f9051b 100644 --- a/src/gateway/worker.rs +++ b/src/gateway/worker.rs @@ -540,8 +540,8 @@ async fn prepare_voice(ctx: &Ctx, job: &Job) -> std::result::Result crate::config::Config { telegram_bot_token_env: "TELEGRAM_BOT_TOKEN".to_string(), telegram_allow_user_ids: Vec::new(), telegram_allow_chat_ids: Vec::new(), + voice_openai_api_key: None, agent: "codex".to_string(), routes: Vec::new(), assistant_root: "/fake/assistant".to_string(), diff --git a/src/voice.rs b/src/voice.rs index e8608dc..097ec91 100644 --- a/src/voice.rs +++ b/src/voice.rs @@ -11,6 +11,8 @@ use reqwest::multipart::{Form, Part}; use serde::Deserialize; use serde_json::json; +use crate::config::Config; + pub const OPENAI_API_KEY_ENV: &str = "OPENAI_API_KEY"; pub const TRANSCRIPTION_MODEL: &str = "gpt-4o-transcribe"; pub const SPEECH_MODEL: &str = "gpt-4o-mini-tts"; @@ -19,6 +21,12 @@ pub const MAX_AUDIO_BYTES: usize = 20 * 1024 * 1024; const MAX_SPEECH_CHARS: usize = 4096; const REQUEST_TIMEOUT: Duration = Duration::from_secs(90); +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum VoiceCredentialSource { + Environment, + Config, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct AudioClip { pub bytes: Vec, @@ -39,17 +47,30 @@ pub struct Voice { } impl Voice { - pub fn from_env() -> Option { - let key = std::env::var(OPENAI_API_KEY_ENV).ok()?; - let key = key.trim(); - if key.is_empty() { - return None; - } + pub fn from_config(config: &Config) -> Option { + let environment = std::env::var(OPENAI_API_KEY_ENV).ok(); + Self::from_sources( + environment.as_deref(), + config.voice_openai_api_key.as_deref(), + ) + } + + fn from_sources(environment: Option<&str>, configured: Option<&str>) -> Option { + let (key, _) = resolve_openai_api_key(environment, configured)?; Some(Self { provider: Arc::new(OpenAiVoice::new(key.to_string())), }) } + pub(crate) fn credential_source(config: &Config) -> Option { + let environment = std::env::var(OPENAI_API_KEY_ENV).ok(); + resolve_openai_api_key( + environment.as_deref(), + config.voice_openai_api_key.as_deref(), + ) + .map(|(_, source)| source) + } + #[cfg(test)] pub fn with_provider(provider: Arc) -> Self { Self { provider } @@ -88,6 +109,22 @@ impl Voice { } } +fn resolve_openai_api_key<'a>( + environment: Option<&'a str>, + configured: Option<&'a str>, +) -> Option<(&'a str, VoiceCredentialSource)> { + environment + .map(str::trim) + .filter(|key| !key.is_empty()) + .map(|key| (key, VoiceCredentialSource::Environment)) + .or_else(|| { + configured + .map(str::trim) + .filter(|key| !key.is_empty()) + .map(|key| (key, VoiceCredentialSource::Config)) + }) +} + struct OpenAiVoice { api_key: String, client: reqwest::Client, @@ -212,6 +249,21 @@ mod tests { struct FakeProvider; + #[test] + fn environment_key_overrides_config_and_empty_values_are_ignored() { + assert_eq!( + resolve_openai_api_key(Some(" env-key "), Some("config-key")), + Some(("env-key", VoiceCredentialSource::Environment)) + ); + assert_eq!( + resolve_openai_api_key(Some(" "), Some(" config-key ")), + Some(("config-key", VoiceCredentialSource::Config)) + ); + assert_eq!(resolve_openai_api_key(Some(""), Some(" ")), None); + assert!(Voice::from_sources(None, Some("config-key")).is_some()); + assert!(Voice::from_sources(None, None).is_none()); + } + impl VoiceProvider for FakeProvider { fn transcribe<'a>(&'a self, _clip: AudioClip) -> VoiceFuture<'a, String> { Box::pin(async { Ok(" remind me tomorrow ".to_string()) })