Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions config.toml.example
Original file line number Diff line number Diff line change
Expand Up @@ -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"
7 changes: 4 additions & 3 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 11 additions & 4 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
12 changes: 9 additions & 3 deletions docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
19 changes: 10 additions & 9 deletions docs/services.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -78,8 +79,6 @@ and replace `YOU` with your macOS user name:
<dict>
<key>PATH</key>
<string>/Users/YOU/.local/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
<key>OPENAI_API_KEY</key>
<string>replace-with-your-openai-api-key</string>
</dict>

<key>RunAtLoad</key>
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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:

Expand Down
33 changes: 26 additions & 7 deletions docs/telegram.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
3 changes: 0 additions & 3 deletions examples/launchd/com.owainlewis.push.plist
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,6 @@
<dict>
<key>PATH</key>
<string>/Users/YOU/.local/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
<!-- Optional: uncomment both lines to enable voice messages. -->
<!-- <key>OPENAI_API_KEY</key> -->
<!-- <string>replace-with-your-openai-api-key</string> -->
</dict>

<key>RunAtLoad</key>
Expand Down
31 changes: 30 additions & 1 deletion src/assistant.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(())
}

Expand Down Expand Up @@ -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);
}
}
30 changes: 30 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ pub struct Config {
pub telegram_allow_user_ids: Vec<i64>,
#[serde(default)]
pub telegram_allow_chat_ids: Vec<i64>,
#[serde(default)]
pub voice_openai_api_key: Option<String>,
#[serde(default = "default_agent")]
pub agent: String,
#[serde(default)]
Expand Down Expand Up @@ -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}"))?;
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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 => {
Expand Down Expand Up @@ -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<String> {
let config = std::fs::canonicalize(path).with_context(|| format!("resolve config {path}"))?;
Expand Down Expand Up @@ -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(),
Expand Down
Loading
Loading