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
19 changes: 2 additions & 17 deletions apps/desktop/electron/main/live-voice/openai-realtime-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,28 +3,13 @@ import type { LiveBinding } from "@pi-desktop/shared";
import { LIVE_WORK_TOOL_NAME, MAX_LIVE_AUDIO_BYTES, MAX_LIVE_JSON_BYTES, parseLiveWorkArguments, parseRealtimeMessage, RealtimeResponseTracker, realtimeAudioMessage, realtimeSessionMatches, realtimeSessionUpdateMessage, realtimeToolReceiptMessage, realtimeTruncateMessages, realtimeWorkFeedbackMessages } from "@pi-desktop/voice-runtime/live";
import type { LiveAdapter, LiveAdapterContext, LivePlaybackCursor, LiveReceiptDelivery } from "./types";
import type { LiveWorkFeedback } from "@pi-desktop/shared";
import { realtimeSocketUrl } from "./websocket-endpoint";
import { openLiveWebSocket } from "./websocket-transport";
import { sendJsonBounded, sendJsonConfirmed, waitForReady, waitForSocketReady, websocketJson } from "./websocket-wire";
import { WebSocket } from "ws";

const MAX_FRAME_BYTES = 24000 * 2 / 10;

function realtimeUrl(baseUrl: string, modelId: string): string {
let base: URL;
try { base = new URL(baseUrl); } catch { throw Object.assign(new Error("Realtime Provider URL is invalid"), { errorCode: "LIVE_PROTOCOL_UNSUPPORTED" }); }
if (base.protocol !== "https:" || base.username || base.password || base.search || base.hash) {
throw Object.assign(new Error("Realtime Provider must use an HTTPS base URL without credentials or query parameters"), { errorCode: "LIVE_PROTOCOL_UNSUPPORTED" });
}
let path = base.pathname.replace(/\/+$/, "");
if (!path.endsWith("/realtime")) path = `${path}/realtime`;
base.pathname = path;
base.protocol = "wss:";
base.search = "";
base.searchParams.set("model", modelId);
base.hash = "";
return base.toString();
}

export function createOpenAIRealtimeAdapter(context: LiveAdapterContext): LiveAdapter {
const binding = context.binding as Extract<LiveBinding, { adapterId: "openai-realtime" }>;
if (context.auth.kind !== "api-key") {
Expand Down Expand Up @@ -172,7 +157,7 @@ export function createOpenAIRealtimeAdapter(context: LiveAdapterContext): LiveAd
mediaKind: "pcm",
async connect() {
if (closed || context.signal.aborted) throw Object.assign(new Error("Live call was cancelled"), { errorCode: "LIVE_STALE_CALL" });
const url = realtimeUrl(baseUrl, binding.modelId);
const url = realtimeSocketUrl(baseUrl, binding.modelId);
const liveSocket = await openLiveWebSocket({ url, headers: { Authorization: `Bearer ${apiKey}`, ...(binding.wireProfile === "realtime-compat-v1" ? { "OpenAI-Beta": "realtime=v1" } : {}) }, signal: context.signal, endpointOrigin: "user" });
socket = liveSocket;
liveSocket.on("message", onSocketMessage);
Expand Down
66 changes: 66 additions & 0 deletions apps/desktop/electron/main/live-voice/websocket-endpoint.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/**
* Scheme rules for Live WebSocket endpoints, kept free of Electron so they can
* be tested directly.
*
* A user-supplied endpoint (an OpenAI Realtime Provider base URL) follows the
* desktop network policy (ADR 0304): plain `ws` is accepted here and the public
* network guard decides, from `networkPolicy.mode`, whether the plaintext hop
* and its address are allowed. Every third-party endpoint keeps `wss` only.
*/

export type LiveEndpointOrigin = "user" | "third-party";

function liveEndpointError(message: string, errorCode: string): Error {
return Object.assign(new Error(message), { errorCode });
}

/**
* Build the Realtime WebSocket URL from a Provider base URL: `https` maps to
* `wss`, `http` maps to `ws`. The scheme is only syntax here; whether `ws` may
* be dialed is decided by the transport's network guard.
*/
export function realtimeSocketUrl(baseUrl: string, modelId: string): string {
let base: URL;
try {
base = new URL(baseUrl);
} catch {
throw liveEndpointError("Realtime Provider URL is invalid", "LIVE_PROTOCOL_UNSUPPORTED");
}
if (
(base.protocol !== "https:" && base.protocol !== "http:") ||
base.username ||
base.password ||
base.search ||
base.hash
) {
throw liveEndpointError(
"Realtime Provider must use an HTTP(S) base URL without credentials or query parameters",
"LIVE_PROTOCOL_UNSUPPORTED",
);
}
let path = base.pathname.replace(/\/+$/, "");
if (!path.endsWith("/realtime")) path = `${path}/realtime`;
base.pathname = path;
base.protocol = base.protocol === "http:" ? "ws:" : "wss:";
base.search = "";
base.searchParams.set("model", modelId);
base.hash = "";
return base.toString();
}

/**
* The HTTP(S) URL the network guard judges for a Live WebSocket URL. Plain
* `ws` is only ever accepted for a user-supplied endpoint.
*/
export function liveSocketGuardUrl(url: string, origin: LiveEndpointOrigin): string {
const parsed = new URL(url);
if (parsed.protocol === "wss:") {
parsed.protocol = "https:";
return parsed.toString();
}
if (parsed.protocol === "ws:" && origin === "user") {
parsed.protocol = "http:";
return parsed.toString();
}
throw liveEndpointError("Live WebSocket endpoints must use TLS", "LIVE_NETWORK_POLICY_UNSUPPORTED");
}
18 changes: 11 additions & 7 deletions apps/desktop/electron/main/live-voice/websocket-transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { net, session } from "electron";
import { WebSocket } from "ws";
import { allowInsecureUserEndpointsEnabled, noteInsecureUserEndpoint, relaxedNetworkPolicyEnabled } from "../endpoint-policy";
import { createPublicHttpsClient } from "../public-https-fetch";
import { liveSocketGuardUrl } from "./websocket-endpoint";
import { responseCodeError } from "./websocket-errors";

const HANDSHAKE_TIMEOUT_MS = 15_000;
Expand Down Expand Up @@ -79,19 +80,22 @@ export async function openLiveWebSocket(input: {
signal: AbortSignal;
endpointOrigin: "user" | "third-party";
}): Promise<WebSocket> {
const parsedUrl = new URL(input.url);
if (parsedUrl.protocol !== "wss:") {
throw Object.assign(new Error("Live WebSocket endpoints must use TLS"), {
errorCode: "LIVE_NETWORK_POLICY_UNSUPPORTED",
});
}
await endpointGuard.assertPublicUrl(input.url.replace(/^wss:/, "https:"), input.endpointOrigin);
const guardUrl = liveSocketGuardUrl(input.url, input.endpointOrigin);
await endpointGuard.assertPublicUrl(guardUrl, input.endpointOrigin);
if (input.signal.aborted) throw input.signal.reason;
const route = await session.defaultSession.resolveProxy(input.url);
if (input.signal.aborted) {
throw Object.assign(new Error("Live provider connection was cancelled"), { errorCode: "LIVE_STALE_CALL" });
}
const proxy = parseResolvedProxy(route);
// The proxy tunnel speaks TLS to the destination. A plaintext user endpoint
// is expected to sit outside the proxy (ADR 0304 bypass list), so a proxied
// `ws` route fails closed instead of being tunneled in the clear.
if (proxy && new URL(guardUrl).protocol === "http:") {
throw Object.assign(new Error("plain ws endpoints cannot be reached through the network proxy"), {
errorCode: "LIVE_NETWORK_POLICY_UNSUPPORTED",
});
}
const agent = proxy ? new ProxyTunnelAgent(proxy) : undefined;

try {
Expand Down
156 changes: 156 additions & 0 deletions apps/desktop/test/live-voice-websocket-endpoint.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import assert from "node:assert/strict";
import { once } from "node:events";
import { createServer as createHttpServer } from "node:http";
import { createRequire } from "node:module";
import test from "node:test";
import { fileURLToPath } from "node:url";
import { createServer } from "vite";

async function loadEndpoint(t) {
const server = await createServer({
root: fileURLToPath(new URL("..", import.meta.url)),
configFile: false,
server: { middlewareMode: true, hmr: false, ws: false },
appType: "custom",
optimizeDeps: { noDiscovery: true, include: [] },
});
t.after(() => server.close());
return server.ssrLoadModule("/electron/main/live-voice/websocket-endpoint.ts");
}

async function loadTransport(t, proxyRoute = "DIRECT") {
const electronStub = {
name: "live-voice-electron-test-stub",
enforce: "pre",
resolveId(id) {
return id === "electron" ? "\0live-voice-electron-test-stub" : null;
},
load(id) {
if (id !== "\0live-voice-electron-test-stub") return null;
return `export const net = { fetch: async () => { throw new Error("unexpected fetch"); } };
export const session = { defaultSession: { resolveProxy: async () => ${JSON.stringify(proxyRoute)} } };`;
},
};
const server = await createServer({
root: fileURLToPath(new URL("..", import.meta.url)),
configFile: false,
plugins: [electronStub],
ssr: { noExternal: ["electron"] },
server: { middlewareMode: true, hmr: false, ws: false },
appType: "custom",
optimizeDeps: { noDiscovery: true, include: [] },
});
t.after(() => server.close());
return server.ssrLoadModule("/electron/main/live-voice/websocket-transport.ts");
}

test("Live transport connects to a user-supplied loopback ws endpoint", async (t) => {
const { openLiveWebSocket } = await loadTransport(t);
const { WebSocketServer } = createRequire(new URL("../package.json", import.meta.url))("ws");
const server = createHttpServer();
const fixture = new WebSocketServer({ server });
let connections = 0;
let request;
fixture.on("connection", (_socket, incoming) => {
connections++;
request = incoming;
});
await new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", resolve);
});
t.after(async () => {
for (const socket of fixture.clients) socket.terminate();
await new Promise((resolve) => fixture.close(resolve));
server.closeAllConnections();
await new Promise((resolve) => server.close(resolve));
});

const client = await openLiveWebSocket({
url: `ws://127.0.0.1:${server.address().port}/v1/realtime?model=fixture`,
headers: { Authorization: "Bearer local-fixture-key" },
signal: new AbortController().signal,
endpointOrigin: "user",
});
assert.equal(connections, 1);
assert.ok(request);
assert.equal(request.url, "/v1/realtime?model=fixture");
assert.equal(request.headers.authorization, "Bearer local-fixture-key");
const closed = once(client, "close");
client.close();
await closed;
});

test("Live transport refuses plain ws when Electron routes it through a proxy", async (t) => {
const { openLiveWebSocket } = await loadTransport(t, "PROXY 127.0.0.1:8080");

await assert.rejects(
openLiveWebSocket({
url: "ws://127.0.0.1:8010/v1/realtime?model=fixture",
signal: new AbortController().signal,
endpointOrigin: "user",
}),
{ errorCode: "LIVE_NETWORK_POLICY_UNSUPPORTED" },
);
});

test("Realtime socket URL keeps TLS for HTTPS providers and maps HTTP providers to ws", async (t) => {
const { realtimeSocketUrl } = await loadEndpoint(t);

assert.equal(
realtimeSocketUrl("https://api.openai.com/v1", "gpt-realtime"),
"wss://api.openai.com/v1/realtime?model=gpt-realtime",
);
assert.equal(
realtimeSocketUrl("https://example.test/v1/realtime/", "m"),
"wss://example.test/v1/realtime?model=m",
);
assert.equal(
realtimeSocketUrl("http://127.0.0.1:8010/v1", "local-model"),
"ws://127.0.0.1:8010/v1/realtime?model=local-model",
);
assert.equal(
realtimeSocketUrl("http://[::1]:8010/v1/", "m"),
"ws://[::1]:8010/v1/realtime?model=m",
);
});

test("Realtime socket URL rejects non-HTTP schemes, credentials, query and fragment", async (t) => {
const { realtimeSocketUrl } = await loadEndpoint(t);

for (const baseUrl of [
"not a url",
"ws://127.0.0.1:8010/v1",
"file:///tmp/realtime",
"http://user:secret@127.0.0.1:8010/v1",
"https://api.example.test/v1?key=1",
"https://api.example.test/v1#frag",
]) {
assert.throws(() => realtimeSocketUrl(baseUrl, "m"), { errorCode: "LIVE_PROTOCOL_UNSUPPORTED" }, baseUrl);
}
});

test("Live socket guard URL allows plain ws only for user-supplied endpoints", async (t) => {
const { liveSocketGuardUrl } = await loadEndpoint(t);

assert.equal(
liveSocketGuardUrl("wss://generativelanguage.googleapis.com/ws?x=1", "third-party"),
"https://generativelanguage.googleapis.com/ws?x=1",
);
assert.equal(
liveSocketGuardUrl("wss://api.openai.com/v1/realtime?model=m", "user"),
"https://api.openai.com/v1/realtime?model=m",
);
assert.equal(
liveSocketGuardUrl("ws://127.0.0.1:8010/v1/realtime?model=m", "user"),
"http://127.0.0.1:8010/v1/realtime?model=m",
);
assert.throws(
() => liveSocketGuardUrl("ws://127.0.0.1:8010/v1/realtime", "third-party"),
{ errorCode: "LIVE_NETWORK_POLICY_UNSUPPORTED" },
);
assert.throws(
() => liveSocketGuardUrl("https://api.openai.com/v1/realtime", "user"),
{ errorCode: "LIVE_NETWORK_POLICY_UNSUPPORTED" },
);
});
8 changes: 8 additions & 0 deletions docs/spec/03-runtime/live-voice.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,14 @@ sent only to the frame that owns the call.
OAuth tokens and API keys remain in Main. SDP, audio and transcript content are
not logged. Main WebSocket endpoints are validated before connection and use
the configured desktop network proxy. Unsupported proxy routes fail closed.
Codex SDP negotiation stays HTTPS-only and the Gemini endpoint is third-party
and requires `wss`. The OpenAI Realtime Provider base URL is user-supplied
(ADR 0304): an `https` base URL connects over `wss`, and an `http` base URL
connects over plain `ws` only when the network guard accepts it under
`networkPolicy.mode` (`relaxed`, the default; `strict` refuses it). A plain `ws` endpoint is never sent through a
proxy tunnel; a proxied route for it fails closed with
`LIVE_NETWORK_POLICY_UNSUPPORTED`. Base URLs with credentials, a query or a
fragment are refused.
The PCM port exists for one prepared call, has bounded frame sizes and credits,
and carries no credentials.

Expand Down
43 changes: 36 additions & 7 deletions docs/spec/06-delivery/04-e2e-test-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,10 @@
### E2E-LIVE-VOICE-public-settings-and-reconnect

- **Preconditions:** A built production Renderer and real Electron/Main/Host,
isolated data/profile/project, developer mode off, a local TLS Realtime
fixture and synthetic microphone. Trust only the fixture CA in the child
process; do not disable TLS, sender, sandbox or microphone checks.
isolated data/profile/project, developer mode off, a local TLS or loopback
HTTP Realtime fixture and synthetic microphone. For TLS, trust only the
fixture CA in the child process; do not disable TLS, sender, sandbox or
microphone checks.
- **Steps:** Open Live from Composer while disabled and follow Open settings.
Find Voice through settings search, bind the fixture account, enable Live,
connect, unmute, receive audio/captions, mute and hang up. Cancel a delayed
Expand All @@ -55,10 +56,14 @@
call. Settings survive restart without reconnecting. Legacy Dictation
settings are unchanged; a voice-only call creates no Agent session.
- **Coverage:** `pnpm test:e2e:live-voice` drives the built app and its concrete
Realtime GA adapter against local WSS; `live-voice-owner.test.mjs` bundles
the production owner module and rejects other files/frames. Fixture audio
is not physical-device or real-provider acceptance. Commands and results
are recorded in `docs/implementation/live-voice-public-readiness.md`.
Realtime GA adapter against local WSS; `pnpm test:e2e:live-voice --
--plain-http` repeats the call flow against a loopback `ws://` endpoint.
`live-voice-websocket-endpoint.test.mjs` connects the production transport to
a local loopback WebSocket and verifies proxy refusal; `live-voice-owner.test.mjs`
bundles the production owner module and rejects other files/frames. Fixture
audio is not physical-device or real-provider acceptance. Commands and
results are recorded in
`docs/implementation/live-voice-public-readiness.md`.
- **Specs:** [Live Voice](../03-runtime/live-voice.md).

### E2E-LIVE-VOICE-provider-call-lifecycle
Expand Down Expand Up @@ -98,6 +103,30 @@
The full Electron flow and real-provider/device compatibility remain
unverified until their respective isolated acceptance environments are run.

### E2E-LIVE-VOICE-realtime-plaintext-user-endpoint

- **Preconditions:** Isolated desktop profile with Live Voice enabled and an
OpenAI-compatible API-key Provider whose base URL is a local plain-HTTP
Realtime fixture such as `http://127.0.0.1:<port>/v1`. Do not use a real
provider account.
- **Steps:** Bind the Realtime adapter to that Provider and start a call with
`networkPolicy.mode` at its default (`relaxed`). End the call, switch the
mode to `strict` and start again. Repeat in `relaxed` with a system proxy
that does not bypass the fixture host.
- **Expected:** In `relaxed` mode the call connects over
`ws://127.0.0.1:<port>/v1/realtime?model=…` and the one-time plaintext
notice is raised. In `strict` mode, and on a proxied route, the call fails
before any socket opens with a network-policy error. An `https` base URL
still connects only over `wss`; Gemini never uses `ws` and Codex SDP stays
HTTPS-only.
- **Specs:** [Live Voice](../03-runtime/live-voice.md),
[ADR 0304](../../adr/0304-user-supplied-endpoint-trust.md).
- **Acceptance:** `apps/desktop/test/live-voice-websocket-endpoint.test.mjs`
covers the scheme mapping, the user/third-party split and the refused base
URL shapes; the network guard's `relaxed`/`strict` verdict is covered by the
existing public-network tests. The full Electron flow remains unverified
until its isolated acceptance environment is run.

### E2E-LIVE-VOICE-four-stage-ui

- **Title:** Disabled, preparation, compact call, and deliberate details.
Expand Down
Loading
Loading