@sidevoice/uplink 0.4.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/connector.mjs CHANGED
@@ -9,6 +9,7 @@ import { mkdirSync, openSync, closeSync, writeFileSync, readFileSync, unlinkSync
9
9
  import { randomUUID } from 'node:crypto';
10
10
  import { capabilityState, SUPPORTED, voiceEnvelope } from './harness-contract.mjs';
11
11
  import { harnessFor } from './harnesses.mjs';
12
+ import { privateNetwork } from './pair.mjs';
12
13
 
13
14
  export const PROTOCOL = 1;
14
15
  const dataDir = process.env.SIDEVOICE_DATA_DIR || path.join(os.homedir(), '.sidevoice');
@@ -27,8 +28,7 @@ function credentials() {
27
28
  const token = process.env.SIDEVOICE_CONNECTOR_TOKEN || saved.token;
28
29
  if (!url || !connector_id || !token) throw new Error(`Not paired with any room (looked in ${credentialsPath}): the conversation's voice_pair, or sidevoice pair <room-url> <code>, with the code the room shows`);
29
30
  const parsed = new URL(url);
30
- const loopback = ['127.0.0.1', 'localhost', '::1'].includes(parsed.hostname);
31
- if (parsed.protocol !== 'wss:' && !loopback) throw new Error('The room URL must be wss:// unless it is loopback');
31
+ if (parsed.protocol !== 'wss:' && !privateNetwork(parsed.hostname)) throw new Error('The room URL must be wss:// unless it stays on this machine or inside its cluster');
32
32
  const room = new URL(url); room.protocol = room.protocol === 'wss:' ? 'https:' : 'http:';
33
33
  return { url, connector_id, token, room: room.origin };
34
34
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sidevoice/uplink",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "Sidevoice client side: the stdio MCP server your agent uses, one outbound uplink per machine to the room, one-time pairing.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/pair.mjs CHANGED
@@ -12,6 +12,15 @@ export function dataDir(env = process.env) {
12
12
  return env.SIDEVOICE_DATA_DIR || path.join(os.homedir(), '.sidevoice');
13
13
  }
14
14
 
15
+ /** Where a plaintext connection is acceptable: the token must never cross a network we do not own. Loopback,
16
+ * and a Kubernetes service name (`<svc>.<ns>.svc`, `<svc>.<ns>.svc.<cluster domain>`), which by construction
17
+ * resolves only inside the cluster and is routed there. Anything else — a private IP included — needs TLS: a
18
+ * host we cannot classify is not a reason to send a credential in clear. */
19
+ export function privateNetwork(hostname) {
20
+ if (['127.0.0.1', 'localhost', '::1', '[::1]'].includes(hostname)) return true;
21
+ return /^[a-z0-9-]+\.[a-z0-9-]+\.svc(\.[a-z0-9.-]+)?$/i.test(hostname);
22
+ }
23
+
15
24
  /** The room's http(s) origin from the socket address the credential stores. */
16
25
  export function roomOrigin(wsUrl) {
17
26
  const url = new URL(wsUrl); url.protocol = url.protocol === 'wss:' ? 'https:' : 'http:'; return url.origin;
@@ -29,6 +38,9 @@ export function pairedRoom(env = process.env) {
29
38
  /** Redeem a code for this host's credential. Returns where it was written. */
30
39
  export async function pair(room, code, env = process.env) {
31
40
  const base = new URL(room);
41
+ if (base.protocol !== 'https:' && !privateNetwork(base.hostname)) {
42
+ throw new Error(`${base.origin} is reached in clear over a network this machine does not own; the room must be https:// there (loopback and Kubernetes service names are the exceptions).`);
43
+ }
32
44
  const response = await fetch(new URL('/api/connectors/pair', base), {
33
45
  method: 'POST', headers: { 'content-type': 'application/json' },
34
46
  body: JSON.stringify({ code, host: os.hostname() }), signal: AbortSignal.timeout(15_000),