Skip to content
Draft
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
5 changes: 5 additions & 0 deletions docs-site/src/content/docs/guides/codex-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,11 @@ current bearer, so the key only keeps the join on the proxy path. It is written
`openai_base_url` form, is removed together with it, and a user-owned
`experimental_realtime_ws_base_url` is never overwritten.

Authenticated remote clients use a different routing form: the provider-table admission header
does not automatically accompany Codex's dedicated voice transports. Pointing the root voice
URLs directly at a protected hub is therefore not equivalent to the loopback setup above.
See [Remote client voice](/guides/remote-hub/#remote-client-voice) for the opt-in local relay.

### Voice transport and task handoffs

Codex owns the microphone and speaker, WebRTC media negotiation, captions, mute controls, and
Expand Down
49 changes: 49 additions & 0 deletions docs-site/src/content/docs/guides/remote-hub.md
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,55 @@ both named volumes and a synthetic catalog survive replacement. This check does
real provider account, OAuth callback, custom mount migration, or every CPU architecture; perform
the authenticated routed-response check above for your deployment.

## Remote client voice

Codex owns microphone capture, playback, and WebRTC media. The hub forwards call creation and
the realtime control WebSocket, but a remote provider's `x-opencodex-api-key` header is not
automatically included in those dedicated voice transports. The loopback injection described in
[Codex integration](/guides/codex-integration/#config-injection) does not solve remote admission.

On an already connected client, the opt-in `ocx voice-relay` command provides a local voice-only
transport using that client's existing hub data credential. It is a foreground command, not an
automatically installed service. It does not modify Codex configuration or normal model routing.
Start it with `ocx voice-relay --port 10111` and keep the process running while using voice.
The default port is `10111`; it binds only `127.0.0.1` and does not fall back to another port.
By default it accepts call creation at `POST /v1/live` and `POST /v1/realtime/calls`, followed
by a call-ID WebSocket join. Add `--allow-standalone` for a client that opens a realtime WebSocket
without first creating a WebRTC call. Unrelated API routes and browser-origin requests are not
general-purpose forwarding surfaces.

The listener trusts local processes on the client machine. While it is running, another local
process can use the allowed voice routes through the connected hub credential, although the
credential itself is never returned to the caller. Stop the relay when remote voice is not in use.

The relay exits if its saved connection or credential changes. After disconnect or key rotation,
restore the voice settings or restart the relay against the intended connection. It never repairs
pairing or rotates keys itself.

Back up the **user-level** Codex config (`$CODEX_HOME/config.toml`, normally
`~/.codex/config.toml`) and set these root keys **before the first TOML table**, using the relay's
reported port. This example assumes port `10111`:

```toml
experimental_realtime_webrtc_call_base_url = "http://127.0.0.1:10111/v1"
experimental_realtime_ws_base_url = "http://127.0.0.1:10111/v1"
```

Do not change `openai_base_url`, `model_provider`, or the generated provider table for this
workaround. Do not put the hub credential in a URL or copy it into these keys. These experimental
settings require a Codex version that supports them; the WebRTC key affects call creation only,
and the WebSocket key affects realtime control only. See the
[upstream config definitions](https://github.com/openai/codex/blob/main/codex-rs/config/src/config_toml.rs).

Fully quit and reopen Codex when no active work would be interrupted. A listening relay or a
successful hub health check does not prove voice works: start a voice conversation, confirm
microphone input and spoken output, and verify a voice task handoff separately. The relay does
not add voice support to clients that lack it or replace the hub's upstream voice authentication.

To roll back, restore only the previous values of these two keys (remove them if previously
absent), quit and reopen Codex safely, then stop the foreground relay. Preserve unrelated edits
made since the backup; normal provider routing and hub pairing do not need to be removed.

## Rollback

Inspect existing Serve mappings before changing them. `tailscale serve reset` removes every mapping
Expand Down
1 change: 1 addition & 0 deletions scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,7 @@
"client-export-modality-enum.test.ts": "clients",
"client-fingerprint.test.ts": "clients",
"client-hub-relay.test.ts": "clients",
"client-voice-relay.test.ts": "clients",
"client-injection-guard.test.ts": "codex-integration",
"client-lifecycle-lock.test.ts": "clients",
"client-machine-listener.test.ts": "clients",
Expand Down
18 changes: 17 additions & 1 deletion skills/ocx/references/01_management_surface.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,22 @@ JSON mode: `envelope`.

- Reads /healthz plus local config; drives no management API route.

### `ocx voice-relay`

Run a loopback-only foreground relay for a connected remote hub's realtime voice routes.

Drives no management route.

| Flag | Value | Meaning |
|---|---|---|
| `--port` | number | Loopback port; defaults to 10111. |
| `--allow-standalone` | boolean | Also admit bare /v1/live and /v1/realtime WebSocket sessions. |

JSON mode: `none`.

- Requires an intact ocx connect record and owner-matching data credential.
- Does not write Codex configuration or install a service; exits when connection ownership changes.

### `ocx capabilities`

List the declared CLI capabilities and the management routes they drive.
Expand Down Expand Up @@ -687,6 +703,6 @@ JSON mode: `payload`.

## Counts

- declared capabilities: 37
- declared capabilities: 38
- of those, state-changing: 16
- head-resolved invocations: 2
15 changes: 15 additions & 0 deletions src/cli/capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,21 @@ export const CAPABILITIES: readonly Capability[] = [
"A rotation left pending by a crash is resumed here — startup and status stop rather than guess which key generation is live.",
],
},
{
command: ["voice-relay"],
summary: "Run a loopback-only foreground relay for a connected remote hub's realtime voice routes.",
routes: [],
flags: [
{ name: "--port", value: "number", summary: "Loopback port; defaults to 10111." },
{ name: "--allow-standalone", value: "boolean", summary: "Also admit bare /v1/live and /v1/realtime WebSocket sessions." },
],
mutates: false,
json: "none",
details: [
"Requires an intact ocx connect record and owner-matching data credential.",
"Does not write Codex configuration or install a service; exits when connection ownership changes.",
],
},
{
command: ["capabilities"],
summary: "List the declared CLI capabilities and the management routes they drive.",
Expand Down
4 changes: 4 additions & 0 deletions src/cli/dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,10 @@ const commandRunners: Record<string, CommandRunner> = {
const { handleDisconnectCommand } = await import("./connect");
return await handleDisconnectCommand(deps.args.slice(1));
},
"voice-relay": async deps => {
const { handleVoiceRelayCommand } = await import("./voice-relay");
return await handleVoiceRelayCommand(deps.args.slice(1));
},
"sync-cache": async deps => {
const cacheArgs = deps.args.slice(1);
const restartCodex = cacheArgs.includes("--restart-codex");
Expand Down
1 change: 1 addition & 0 deletions src/cli/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ Usage:
ocx ensure Ensure the proxy is running and Codex config/cache are current
ocx connect <url> Connect this machine to a remote OpenCodex hub (credential via stdin)
ocx disconnect Restore local state and clear the hub connection
ocx voice-relay [flags] Foreground loopback relay for connected remote-hub voice
ocx sync [--restart-codex] Fetch models from providers and inject into Codex config
ocx sync-cache [--restart-codex]
Refresh Codex's model cache from the active catalog
Expand Down
9 changes: 9 additions & 0 deletions src/cli/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,15 @@ export const CLI_COMMANDS: CliCommandEntry[] = [
usage: "ocx disconnect [--keep-catalog] [--json]",
summary: "Restore local client state offline and clear the remote-hub connection.",
},
{
name: "voice-relay",
usage: "ocx voice-relay [--port <port>] [--allow-standalone]",
summary: "Relay Codex realtime voice to the connected remote hub from a loopback-only foreground listener.",
details: [
"Defaults to 127.0.0.1:10111 and exits when the connected hub credential changes.",
"No Codex config is changed; --allow-standalone additionally admits bare realtime WebSocket sessions.",
],
},
{
name: "sync",
usage: "ocx sync [--restart-codex] [--restart-desktop-app]",
Expand Down
33 changes: 33 additions & 0 deletions src/cli/voice-relay.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { startVoiceRelay, VOICE_RELAY_DEFAULT_PORT } from "../client/voice-relay";
import { CliUsageError, rejectArgs, runCliAction, takeFlag, takeIntegerOption } from "./runtime-api";

export const VOICE_RELAY_USAGE = `Usage:
ocx voice-relay [--port <port>] [--allow-standalone]`;

export async function handleVoiceRelayCommand(argv: string[]): Promise<number> {
return runCliAction(async () => {
const args = [...argv];
const allowStandalone = takeFlag(args, "--allow-standalone");
const port = takeIntegerOption(args, "--port", { min: 1 });
if (port !== undefined && port > 65_535) throw new CliUsageError("--port must be between 1 and 65535", VOICE_RELAY_USAGE);
rejectArgs(args, VOICE_RELAY_USAGE, { redactValues: true });
const relay = startVoiceRelay({ port: port ?? VOICE_RELAY_DEFAULT_PORT, allowStandalone });
console.log(`Voice relay listening on ${relay.origin}/v1 (Ctrl-C to stop).`);
console.log(`Codex config: experimental_realtime_webrtc_call_base_url = "${relay.origin}/v1"`);
console.log(`Codex config: experimental_realtime_ws_base_url = "${relay.origin}/v1"`);
let stopping = false;
const stop = () => { if (!stopping) { stopping = true; relay.stop(); } };
process.once("SIGINT", stop);
process.once("SIGTERM", stop);
if (process.platform !== "win32") process.once("SIGHUP", stop);
try {
const reason = await relay.done;
if (reason === "connection_changed") throw new Error("connected hub or credential changed; voice relay stopped");
} finally {
process.removeListener("SIGINT", stop);
process.removeListener("SIGTERM", stop);
if (process.platform !== "win32") process.removeListener("SIGHUP", stop);
relay.stop();
}
});
}
Loading
Loading