openPlayer checked `findCliamp()` before `resolveInHome(files)`, so a path escaping the owner's home was answered "cliamp not found on host" on any machine without the binary — telling the caller about the host in response to input that should simply have been refused. It also made the guarantee depend on an unrelated binary being installed, which is why cliamp-ws.test.ts's "rejects a path that escapes the owner home" failed on this machine (and every other one without cliamp) since the tests started running from plugins/ at all. It was being carried as a known-failing baseline rather than read. Validate the request, then inspect the host. No behavioural change on a machine that has cliamp. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
240 lines
8.1 KiB
TypeScript
240 lines
8.1 KiB
TypeScript
import type { ServerWebSocket } from 'bun';
|
|
import { spawn, type Subprocess } from 'bun';
|
|
import { homedir } from 'node:os';
|
|
import { join, normalize, resolve, sep } from 'node:path';
|
|
import { VIRTUAL_SINK } from './pulse-audio';
|
|
|
|
// Local playback, both halves of it, owned by the process that owns the audio pipeline:
|
|
//
|
|
// /cliamp/ws — runs the `cliamp` TUI player against a file and pipes its terminal both ways
|
|
// /cliamp/audio/ws — captures what the sink hears and streams it to the browser as raw PCM
|
|
//
|
|
// Officer relays these two sockets and nothing else: it authenticates the browser and forwards frames.
|
|
// Every fact below — where the binary is, what a legal path is, which sink to play into, the ALSA config,
|
|
// the capture format — is pipeline knowledge and stays here. The frame shapes are the browser's contract
|
|
// ({type:'output'|'exit'} / {type:'input'} as JSON text, PCM as binary), so they are unchanged by the move.
|
|
//
|
|
// Both sockets are loopback-only, like the rest of this server: officer is the only client.
|
|
|
|
const ASOUNDRC_PATH = join(import.meta.dir, 'asoundrc');
|
|
|
|
// Single super user, so the owner's home is the root every path is resolved against — same convention as
|
|
// stream-audio.ts and the indexer.
|
|
const ROOT_DIR = homedir();
|
|
|
|
// parec's output format IS the contract with the browser's AudioWorklet: signed 16-bit LE, 44.1kHz, stereo.
|
|
const CAPTURE_ARGS = ['--format=s16le', '--rate=44100', '--channels=2', '-d', `${VIRTUAL_SINK}.monitor`];
|
|
|
|
export type MusicWSData = { kind: 'player'; files: string } | { kind: 'capture' };
|
|
|
|
type Session = {
|
|
proc: Subprocess;
|
|
closed: boolean;
|
|
};
|
|
|
|
const sessions = new Map<ServerWebSocket<MusicWSData>, Session>();
|
|
|
|
const sendOutput = (ws: ServerWebSocket<MusicWSData>, data: string) => {
|
|
try {
|
|
ws.send(JSON.stringify({ type: 'output', data }));
|
|
} catch {
|
|
/* ws already closed */
|
|
}
|
|
};
|
|
|
|
const sendExit = (ws: ServerWebSocket<MusicWSData>) => {
|
|
try {
|
|
ws.send(JSON.stringify({ type: 'exit' }));
|
|
} catch {
|
|
/* ws already closed */
|
|
}
|
|
};
|
|
|
|
// Home-relative or leading-slash paths both mean "under the owner's home"; anything that escapes it after
|
|
// normalisation is rejected. The trailing separator matters: without it a sibling directory whose name
|
|
// merely starts with the home path would pass.
|
|
const resolveInHome = (file: string): string | null => {
|
|
const abs = normalize(resolve(ROOT_DIR, file.startsWith('/') ? `.${file}` : file));
|
|
return abs === ROOT_DIR || abs.startsWith(ROOT_DIR + sep) ? abs : null;
|
|
};
|
|
|
|
const findCliamp = (): string | null => {
|
|
const which = Bun.which('cliamp');
|
|
if (which) return which;
|
|
const candidates = [
|
|
process.env.GOPATH ? `${process.env.GOPATH}/bin/cliamp` : null,
|
|
`${ROOT_DIR}/.local/go-path/bin/cliamp`,
|
|
`${ROOT_DIR}/go/bin/cliamp`,
|
|
];
|
|
for (const bin of candidates) {
|
|
if (!bin) continue;
|
|
try {
|
|
const stat = Bun.spawnSync({ cmd: ['test', '-x', bin], stdout: 'ignore', stderr: 'ignore' });
|
|
if (stat.exitCode === 0) return bin;
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const shellEscape = (s: string) => `'${s.replace(/'/g, "'\\''")}'`;
|
|
|
|
// Pump a byte stream into the socket until it ends; `frame` decides how it lands on the wire.
|
|
function pump(
|
|
ws: ServerWebSocket<MusicWSData>,
|
|
session: Session,
|
|
stream: ReadableStream<Uint8Array>,
|
|
frame: (ws: ServerWebSocket<MusicWSData>, chunk: Uint8Array) => void,
|
|
onEnd?: () => void,
|
|
): void {
|
|
const reader = stream.getReader();
|
|
void (async () => {
|
|
try {
|
|
while (!session.closed) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
if (value && !session.closed) frame(ws, value);
|
|
}
|
|
} catch {
|
|
/* stream ended */
|
|
} finally {
|
|
onEnd?.();
|
|
}
|
|
})();
|
|
}
|
|
|
|
function openPlayer(ws: ServerWebSocket<MusicWSData>, files: string): void {
|
|
if (!files) return sendOutput(ws, '\r\n[Error] No files specified.\r\n');
|
|
|
|
// Validate the REQUEST before inspecting the HOST, and in that order deliberately.
|
|
//
|
|
// It was the other way round until 2026-08-18, which had two costs. A path escaping the owner's home
|
|
// was answered "cliamp not found on host" on a machine without the binary — telling the caller
|
|
// something about the host in response to input that should simply have been refused. And the
|
|
// behaviour this pair is supposed to guarantee became dependent on whether an unrelated binary happens
|
|
// to be installed, which is what made `cliamp-ws.test.ts` fail on every machine that does not have it.
|
|
const target = resolveInHome(files);
|
|
if (!target) return sendOutput(ws, '\r\n[Error] Invalid file path.\r\n');
|
|
|
|
const cliampPath = findCliamp();
|
|
if (!cliampPath) return sendOutput(ws, '\r\n[Error] cliamp not found on host.\r\n');
|
|
|
|
// `script` fakes a PTY for cliamp, which avoids a node-pty native dependency here.
|
|
const cliampCmd = `${shellEscape(cliampPath)} ${shellEscape(target)}`;
|
|
console.log(`[music] cliamp spawning: ${cliampCmd}`);
|
|
let proc: Subprocess<'pipe', 'pipe', 'pipe'>;
|
|
try {
|
|
proc = spawn({
|
|
cmd: ['script', '-qfc', cliampCmd, '/dev/null'],
|
|
stdin: 'pipe',
|
|
stdout: 'pipe',
|
|
stderr: 'pipe',
|
|
cwd: ROOT_DIR,
|
|
env: { ...process.env, TERM: 'xterm-256color', PULSE_SINK: VIRTUAL_SINK, ALSA_CONFIG_PATH: ASOUNDRC_PATH },
|
|
});
|
|
} catch (err) {
|
|
return sendOutput(ws, `\r\n[Error] ${err instanceof Error ? err.message : 'Failed to start cliamp'}\r\n`);
|
|
}
|
|
|
|
const session: Session = { proc, closed: false };
|
|
sessions.set(ws, session);
|
|
|
|
const decoder = new TextDecoder();
|
|
const asText = (sock: ServerWebSocket<MusicWSData>, chunk: Uint8Array) => sendOutput(sock, decoder.decode(chunk));
|
|
const end = () => {
|
|
if (session.closed) return;
|
|
session.closed = true;
|
|
sendExit(ws);
|
|
};
|
|
pump(ws, session, proc.stdout, asText, end);
|
|
pump(ws, session, proc.stderr, asText); // cliamp writes some output there
|
|
|
|
void proc.exited.then((code) => {
|
|
console.log(`[music] cliamp exited code=${code}`);
|
|
end();
|
|
sessions.delete(ws);
|
|
});
|
|
}
|
|
|
|
function openCapture(ws: ServerWebSocket<MusicWSData>): void {
|
|
const parecPath = Bun.which('parec');
|
|
if (!parecPath) {
|
|
ws.close(4000, 'parec not found on host');
|
|
return;
|
|
}
|
|
|
|
let proc: Subprocess<'ignore', 'pipe', 'ignore'>;
|
|
try {
|
|
proc = spawn({ cmd: [parecPath, ...CAPTURE_ARGS], stdin: 'ignore', stdout: 'pipe', stderr: 'ignore' });
|
|
} catch {
|
|
ws.close(4000, 'Failed to start audio capture');
|
|
return;
|
|
}
|
|
|
|
const session: Session = { proc, closed: false };
|
|
sessions.set(ws, session);
|
|
console.log('[music] parec started, streaming PCM to the relay');
|
|
|
|
pump(
|
|
ws,
|
|
session,
|
|
proc.stdout,
|
|
(sock, chunk) => {
|
|
try {
|
|
sock.sendBinary(chunk);
|
|
} catch {
|
|
session.closed = true;
|
|
}
|
|
},
|
|
() => {
|
|
if (session.closed) return;
|
|
session.closed = true;
|
|
try {
|
|
ws.close();
|
|
} catch {
|
|
/* already closed */
|
|
}
|
|
},
|
|
);
|
|
}
|
|
|
|
export const musicWebsocket = {
|
|
open(ws: ServerWebSocket<MusicWSData>) {
|
|
if (ws.data.kind === 'player') openPlayer(ws, ws.data.files);
|
|
else openCapture(ws);
|
|
},
|
|
|
|
message(ws: ServerWebSocket<MusicWSData>, raw: string | Buffer) {
|
|
const session = sessions.get(ws);
|
|
if (!session || session.closed || ws.data.kind !== 'player') return; // capture is one-way
|
|
try {
|
|
const msg = JSON.parse(typeof raw === 'string' ? raw : raw.toString());
|
|
if (msg.type === 'input' && msg.data) (session.proc as Subprocess<'pipe'>).stdin.write(msg.data);
|
|
} catch {
|
|
/* not a frame we understand */
|
|
}
|
|
},
|
|
|
|
close(ws: ServerWebSocket<MusicWSData>) {
|
|
const session = sessions.get(ws);
|
|
if (!session) return;
|
|
session.closed = true;
|
|
try {
|
|
session.proc.kill();
|
|
} catch {
|
|
/* already gone */
|
|
}
|
|
sessions.delete(ws);
|
|
},
|
|
|
|
drain() {},
|
|
};
|
|
|
|
/** Upgrade one of the two cliamp sockets, or return null if this request is not for them. */
|
|
export function cliampUpgradeData(pathname: string, search: URLSearchParams): MusicWSData | null {
|
|
if (pathname === '/cliamp/ws') return { kind: 'player', files: search.get('files') ?? '' };
|
|
if (pathname === '/cliamp/audio/ws') return { kind: 'capture' };
|
|
return null;
|
|
}
|