diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index c93b53f91b..9e04196480 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -4523,26 +4523,34 @@ The command requires the exact feature gate `NEMOCLAW_EXPERIMENTAL_VOICE_GATEWAY Any other value stops the command before argument parsing, credential reads, or listener creation. Other experimental feature gates do not enable this command. -Before you start the adapter, provision these two credential files outside the repository: - -- `--deployment-credential-file` authenticates the configured runtime deployment during session admission. -- `--openclaw-credential-file` authenticates NemoClaw to the configured OpenClaw agent gateway with `operator.read` and `operator.write` scopes. - -Both paths must be absolute paths to owner-only regular files. -The command rejects symbolic links, access by group or other users, malformed values, and oversized values. -The deployment owns each file location, lifetime, rotation, and removal. -Stopping the command does not remove either file. -The command reads both files only at startup and does not reload them. -To rotate either credential, stop the command, replace the file, and start the command again. -Removing a file does not revoke the credential in a running process. +Before starting the adapter, the bounded voice-gateway launcher opens two owner-only regular files without following symbolic links and maps only these inherited file descriptors into the child process: + +- Descriptor `3` supplies the deployment credential used during session admission. +- Descriptor `4` supplies the OpenClaw credential with `operator.read` and `operator.write` scopes. + +The descriptor numbers are fixed and are not configurable flags. +The command rejects missing, duplicate, non-regular, wrong-owner, group-accessible, malformed, and oversized inputs. +It reads each descriptor once and closes both before accepting traffic. +Both credential values remain in process memory until the voice-gateway process stops. +The launcher does not place credential source paths or values in arguments or environment variables. +It intentionally inherits only credential descriptors `3` and `4` across this exec, then closes its parent copies. +To rotate either credential, stop the command and restart it with newly opened descriptors. NemoClaw does not send the OpenClaw credential to the runtime. +The trusted caller selects each credential source path and invokes the launcher. +That caller must create, replace, and remove each source file and revoke old credential values. +The launcher opens each source file, maps the inherited child descriptors, and closes its parent copies. +The child validates, reads, and closes descriptors `3` and `4` before it accepts traffic. +Closing the descriptors or stopping the gateway does not remove a source file or revoke its credential. + +The launcher is an internal library boundary for trusted external integrations, not another CLI command and not part of normal NemoClaw managed startup. +Callers use the shipped `runVoiceGatewayLaunch()` action with trusted source paths and runtime fields; package-contract coverage launches the real internal command through that production entry point and verifies descriptor cleanup and restart-based credential rotation. +If parent descriptor cleanup fails and bounded termination does not observe child exit, the action throws `VoiceGatewayTerminationUnconfirmedError` with the retained child handle and original cleanup failure. +The trusted caller must recognize that error, terminate and reap its `child`, and confirm exit before starting another gateway. +The launcher emits the following child-process contract with no credential paths or values in its arguments or environment. +Do not run this child command directly because it requires the launcher's descriptor mapping. -Start the adapter with trusted operator-selected values: - -```bash +```text NEMOCLAW_EXPERIMENTAL_VOICE_GATEWAY=1 $$nemoclaw internal voice-gateway serve \ - --deployment-credential-file /absolute/private/path/deployment-bearer \ - --openclaw-credential-file /absolute/private/path/openclaw-bearer \ --gateway-url ws://127.0.0.1:18789/ws \ --runtime-identity \ --runtime-profile \ diff --git a/src/commands/internal/voice-gateway/serve.ts b/src/commands/internal/voice-gateway/serve.ts index 113414531e..f644190962 100644 --- a/src/commands/internal/voice-gateway/serve.ts +++ b/src/commands/internal/voice-gateway/serve.ts @@ -15,19 +15,11 @@ export default class InternalVoiceGatewayServeCommand extends NemoClawCommand { static strict = true; static summary = "Internal: serve the experimental voice gateway"; static description = - "Serve one authenticated runtime session through a private loopback HTTP and NDJSON adapter."; + "Serve one authenticated runtime session through a private loopback HTTP and NDJSON adapter. Reads the deployment credential from descriptor 3 and the OpenClaw credential from descriptor 4."; static usage = [ - "internal voice-gateway serve --deployment-credential-file --openclaw-credential-file --gateway-url --runtime-identity --runtime-profile --sandbox --agent [--listen-port ]", + "internal voice-gateway serve --gateway-url --runtime-identity --runtime-profile --sandbox --agent [--listen-port ]", ]; static flags = { - "deployment-credential-file": Flags.string({ - description: "Absolute path to the owner-only deployment bearer file", - required: true, - }), - "openclaw-credential-file": Flags.string({ - description: "Absolute path to the owner-only OpenClaw credential file", - required: true, - }), "gateway-url": Flags.string({ description: "Fixed loopback OpenClaw WebSocket URL", required: true, @@ -60,8 +52,6 @@ export default class InternalVoiceGatewayServeCommand extends NemoClawCommand { assertVoiceGatewayEnabled(); const { flags } = await this.parse(InternalVoiceGatewayServeCommand); await runVoiceGatewayServe({ - deploymentCredentialFile: flags["deployment-credential-file"], - openClawCredentialFile: flags["openclaw-credential-file"], gatewayUrl: flags["gateway-url"], runtimeIdentity: flags["runtime-identity"], runtimeProfile: flags["runtime-profile"], diff --git a/src/lib/actions/voice-gateway/launch.ts b/src/lib/actions/voice-gateway/launch.ts new file mode 100644 index 0000000000..82f3a7f15d --- /dev/null +++ b/src/lib/actions/voice-gateway/launch.ts @@ -0,0 +1,13 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { ChildProcess } from "node:child_process"; + +import { launchVoiceGateway, type VoiceGatewayLaunchOptions } from "../../voice-gateway/launcher"; + +/** Start the voice gateway for a trusted external integration. */ +export async function runVoiceGatewayLaunch( + options: VoiceGatewayLaunchOptions, +): Promise { + return launchVoiceGateway(options); +} diff --git a/src/lib/actions/voice-gateway/serve.test.ts b/src/lib/actions/voice-gateway/serve.test.ts index fed7a4d88d..65bc00ac3f 100644 --- a/src/lib/actions/voice-gateway/serve.test.ts +++ b/src/lib/actions/voice-gateway/serve.test.ts @@ -13,8 +13,6 @@ import { } from "./serve"; const OPTIONS = { - deploymentCredentialFile: "/run/voice/deployment", - openClawCredentialFile: "/run/voice/openclaw", gatewayUrl: "ws://127.0.0.1:18789/ws", runtimeIdentity: "voiceclaw-local", runtimeProfile: "voiceclaw-pinned", @@ -32,7 +30,7 @@ describe("experimental voice gateway service gate", () => { }); it("checks the exact feature gate before reading either credential (#8378)", async () => { - const readBearerFile = vi.fn(); + const readBearerDescriptors = vi.fn(); const createServer = vi.fn(); await expect( @@ -41,11 +39,11 @@ describe("experimental voice gateway service gate", () => { NEMOCLAW_EXPERIMENTAL_OTHER_CAPABILITY: "1", NEMOCLAW_EXPERIMENTAL_VOICE_GATEWAY: "0", }, - readBearerFile, + readBearerDescriptors, createServer, }), ).rejects.toThrow("disabled"); - expect(readBearerFile).not.toHaveBeenCalled(); + expect(readBearerDescriptors).not.toHaveBeenCalled(); expect(createServer).not.toHaveBeenCalled(); }); }); @@ -92,21 +90,22 @@ describe("voice gateway listener lifetime", () => { const server = new FakeServer(); const processEvents = new EventEmitter(); const log = vi.fn(); - const readBearerFile = vi - .fn() - .mockReturnValueOnce("deployment-secret") - .mockReturnValueOnce("openclaw-secret"); + const readBearerDescriptors = vi.fn(() => ({ + deploymentCredential: "deployment-secret", + openClawCredential: "openclaw-secret", + })); const createServer = vi.fn(() => server as unknown as Server); const running = runVoiceGatewayServe(OPTIONS, { env: { NEMOCLAW_EXPERIMENTAL_VOICE_GATEWAY: "1" }, - readBearerFile, + readBearerDescriptors, createServer, processEvents, log, }); await vi.waitFor(() => expect(log).toHaveBeenCalledTimes(1)); + expect(readBearerDescriptors).toHaveBeenCalledWith({ deployment: 3, openClaw: 4 }); expect(server.listenArgs).toEqual([18800, "127.0.0.1"]); expect(createServer).toHaveBeenCalledWith({ deploymentCredential: "deployment-secret", diff --git a/src/lib/actions/voice-gateway/serve.ts b/src/lib/actions/voice-gateway/serve.ts index a6cdc8c33b..c5660c3461 100644 --- a/src/lib/actions/voice-gateway/serve.ts +++ b/src/lib/actions/voice-gateway/serve.ts @@ -6,11 +6,13 @@ import type { Server } from "node:http"; import { createVoiceGatewayServer } from "../../adapters/http/voice-gateway-server"; import { DEFAULT_VOICE_GATEWAY_LISTEN_PORT, + VOICE_GATEWAY_DEPLOYMENT_CREDENTIAL_FD, VOICE_GATEWAY_FEATURE_ENV, VOICE_GATEWAY_LISTEN_ADDRESS, + VOICE_GATEWAY_OPENCLAW_CREDENTIAL_FD, type VoiceGatewayDiagnostic, } from "../../voice-gateway/contracts"; -import { readPrivateBearerFile } from "../../voice-gateway/credential-file"; +import { readPrivateBearerDescriptors } from "../../voice-gateway/credential-file"; import { OpenClawVoiceClient } from "../../voice-gateway/openclaw-client"; import { VoiceSessionService } from "../../voice-gateway/session-service"; @@ -25,8 +27,6 @@ interface ProcessEvents { } export interface VoiceGatewayServeOptions { - readonly deploymentCredentialFile: string; - readonly openClawCredentialFile: string; readonly gatewayUrl: string; readonly runtimeIdentity: string; readonly runtimeProfile: string; @@ -37,7 +37,7 @@ export interface VoiceGatewayServeOptions { export interface VoiceGatewayServeDeps { readonly env?: NodeJS.ProcessEnv; - readonly readBearerFile?: typeof readPrivateBearerFile; + readonly readBearerDescriptors?: typeof readPrivateBearerDescriptors; readonly createServer?: typeof createVoiceGatewayServer; readonly processEvents?: ProcessEvents; readonly log?: (entry: VoiceGatewayDiagnostic) => void; @@ -119,15 +119,11 @@ export async function runVoiceGatewayServe( validatePort(listenPort); const gatewayUrl = validateOpenClawGatewayUrl(options.gatewayUrl); - const readBearerFile = deps.readBearerFile ?? readPrivateBearerFile; - const deploymentCredential = readBearerFile( - options.deploymentCredentialFile, - "Voice gateway deployment credential", - ); - const openClawCredential = readBearerFile( - options.openClawCredentialFile, - "Voice gateway OpenClaw credential", - ); + const readBearerDescriptors = deps.readBearerDescriptors ?? readPrivateBearerDescriptors; + const { deploymentCredential, openClawCredential } = readBearerDescriptors({ + deployment: VOICE_GATEWAY_DEPLOYMENT_CREDENTIAL_FD, + openClaw: VOICE_GATEWAY_OPENCLAW_CREDENTIAL_FD, + }); const log = deps.log ?? ((entry: VoiceGatewayDiagnostic) => { diff --git a/src/lib/voice-gateway/contracts.ts b/src/lib/voice-gateway/contracts.ts index 6e09c34f33..2325a3289c 100644 --- a/src/lib/voice-gateway/contracts.ts +++ b/src/lib/voice-gateway/contracts.ts @@ -2,6 +2,10 @@ // SPDX-License-Identifier: Apache-2.0 export const VOICE_GATEWAY_FEATURE_ENV = "NEMOCLAW_EXPERIMENTAL_VOICE_GATEWAY"; +/** Fixed inherited descriptor for the runtime deployment bearer. */ +export const VOICE_GATEWAY_DEPLOYMENT_CREDENTIAL_FD = 3; +/** Fixed inherited descriptor for the OpenClaw gateway bearer. */ +export const VOICE_GATEWAY_OPENCLAW_CREDENTIAL_FD = 4; export const VOICE_GATEWAY_LISTEN_ADDRESS = "127.0.0.1"; export const DEFAULT_VOICE_GATEWAY_LISTEN_PORT = 18_800; export const VOICE_GATEWAY_SESSION_LIFETIME_MS = 5 * 60_000; diff --git a/src/lib/voice-gateway/credential-file.test.ts b/src/lib/voice-gateway/credential-file.test.ts index cfdb9b66c6..2269f45af5 100644 --- a/src/lib/voice-gateway/credential-file.test.ts +++ b/src/lib/voice-gateway/credential-file.test.ts @@ -7,9 +7,10 @@ import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { readPrivateBearerFile } from "./credential-file"; +import { readPrivateBearerDescriptors } from "./credential-file"; -const CREDENTIAL = "voice-gateway-test-credential-0123456789"; +const DEPLOYMENT_CREDENTIAL = "voice-gateway-deployment-credential-0123456789"; +const OPENCLAW_CREDENTIAL = "voice-gateway-openclaw-credential-9876543210"; const directories: string[] = []; function temporaryDirectory(): string { @@ -18,92 +19,144 @@ function temporaryDirectory(): string { return directory; } +function credentialDescriptor(value: string, mode = 0o600): number { + const file = path.join(temporaryDirectory(), "credential"); + fs.writeFileSync(file, value, { mode }); + fs.chmodSync(file, mode); + return fs.openSync(file, fs.constants.O_RDONLY); +} + +function readPair(deployment: number, openClaw: number) { + return readPrivateBearerDescriptors({ deployment, openClaw }); +} + afterEach(() => { + vi.restoreAllMocks(); for (const directory of directories.splice(0)) { fs.rmSync(directory, { force: true, recursive: true }); } }); -describe("voice gateway credential file", () => { - it("reads one owner-only regular file and removes one trailing newline (#8378)", () => { - const file = path.join(temporaryDirectory(), "credential"); - fs.writeFileSync(file, `${CREDENTIAL}\n`, { mode: 0o600 }); +describe("voice gateway credential descriptors", () => { + it("reads each owner-only regular descriptor once and closes both (#9235)", () => { + const deployment = credentialDescriptor(`${DEPLOYMENT_CREDENTIAL}\n`); + const openClaw = credentialDescriptor(OPENCLAW_CREDENTIAL); + const read = vi.spyOn(fs, "readSync"); - expect(readPrivateBearerFile(file, "Test credential")).toBe(CREDENTIAL); + expect(readPair(deployment, openClaw)).toEqual({ + deploymentCredential: DEPLOYMENT_CREDENTIAL, + openClawCredential: OPENCLAW_CREDENTIAL, + }); + expect(read).toHaveBeenCalledTimes(2); + expect(() => fs.fstatSync(deployment)).toThrow(); + expect(() => fs.fstatSync(openClaw)).toThrow(); }); - it("treats macOS EMLINK as a rejected symbolic link (#8378)", () => { - const error = Object.assign(new Error("symbolic link"), { code: "EMLINK" }); - vi.spyOn(fs, "openSync").mockImplementationOnce(() => { - throw error; - }); + it("rejects two descriptors for the same credential file and closes both (#9235)", () => { + const deployment = credentialDescriptor(DEPLOYMENT_CREDENTIAL); + const openClaw = credentialDescriptor(OPENCLAW_CREDENTIAL); + const deploymentStat = fs.fstatSync(deployment); + vi.spyOn(fs, "fstatSync") + .mockReturnValueOnce(deploymentStat) + .mockReturnValueOnce(deploymentStat); - expect(() => readPrivateBearerFile("/absolute/credential", "Test credential")).toThrow( - "symbolic-link", - ); + expect(() => readPair(deployment, openClaw)).toThrow("different files"); + expect(() => fs.fstatSync(deployment)).toThrow(); + expect(() => fs.fstatSync(openClaw)).toThrow(); }); - it.each([ - { - name: "relative path", - arrange: () => "credential", - message: "must be absolute", - }, - { - name: "group-readable file", - arrange: () => { - const file = path.join(temporaryDirectory(), "credential"); - fs.writeFileSync(file, CREDENTIAL, { mode: 0o640 }); - fs.chmodSync(file, 0o640); - return file; - }, - message: "must not be accessible by group or others", - }, - { - name: "directory", - arrange: () => temporaryDirectory(), - message: "not a regular file", - }, - { - name: "symbolic link", - arrange: () => { - const directory = temporaryDirectory(); - const target = path.join(directory, "target"); - const link = path.join(directory, "credential"); - fs.writeFileSync(target, CREDENTIAL, { mode: 0o600 }); - fs.symlinkSync(target, link); - return link; - }, - message: "symbolic-link", - }, - { - name: "short value", - arrange: () => { - const file = path.join(temporaryDirectory(), "credential"); - fs.writeFileSync(file, "short", { mode: 0o600 }); - return file; - }, - message: "invalid size", - }, - { - name: "value with whitespace", - arrange: () => { - const file = path.join(temporaryDirectory(), "credential"); - fs.writeFileSync(file, `${CREDENTIAL} extra`, { mode: 0o600 }); - return file; - }, - message: "malformed", - }, - { - name: "oversized value", - arrange: () => { - const file = path.join(temporaryDirectory(), "credential"); - fs.writeFileSync(file, "a".repeat(4098), { mode: 0o600 }); - return file; - }, - message: "invalid size", + it("rejects a missing descriptor without reading the other credential (#9235)", () => { + const deployment = credentialDescriptor(DEPLOYMENT_CREDENTIAL); + const openClaw = credentialDescriptor(OPENCLAW_CREDENTIAL); + fs.closeSync(deployment); + const read = vi.spyOn(fs, "readSync"); + + expect(() => readPair(deployment, openClaw)).toThrow("descriptor is not open"); + expect(read).not.toHaveBeenCalled(); + expect(() => fs.fstatSync(openClaw)).toThrow(); + }); + + it("rejects a non-regular descriptor before reading either credential (#9235)", () => { + const directory = fs.openSync(temporaryDirectory(), fs.constants.O_RDONLY); + const openClaw = credentialDescriptor(OPENCLAW_CREDENTIAL); + const read = vi.spyOn(fs, "readSync"); + + expect(() => readPair(directory, openClaw)).toThrow("not a regular file"); + expect(read).not.toHaveBeenCalled(); + }); + + it.each(["device", "socket", "pipe"])( + "rejects a representative %s descriptor before reading credentials (#9235)", + () => { + const deployment = credentialDescriptor(DEPLOYMENT_CREDENTIAL); + const openClaw = credentialDescriptor(OPENCLAW_CREDENTIAL); + const fstatSync = fs.fstatSync; + vi.spyOn(fs, "fstatSync") + .mockReturnValueOnce({ isFile: () => false } as fs.Stats) + .mockImplementation(fstatSync); + + expect(() => readPair(deployment, openClaw)).toThrow("not a regular file"); }, - ])("rejects a $name before returning credential material (#8378)", ({ arrange, message }) => { - expect(() => readPrivateBearerFile(arrange(), "Test credential")).toThrow(message); + ); + + it("rejects a descriptor not owned by the current user (#9235)", () => { + const deployment = credentialDescriptor(DEPLOYMENT_CREDENTIAL); + const openClaw = credentialDescriptor(OPENCLAW_CREDENTIAL); + const uid = process.getuid?.() ?? 0; + vi.spyOn(process, "getuid").mockReturnValue(uid + 1); + + expect(() => readPair(deployment, openClaw)).toThrow("not owned by the current user"); + }); + + it.each([ + ["group-readable", DEPLOYMENT_CREDENTIAL, 0o640, "group or others"], + ["short", "short", 0o600, "invalid size"], + ["whitespace", `${DEPLOYMENT_CREDENTIAL} extra`, 0o600, "malformed"], + ["oversized", "a".repeat(4098), 0o600, "invalid size"], + ])("rejects a %s deployment credential (#9235)", (_name, value, mode, message) => { + const deployment = credentialDescriptor(value, mode); + const openClaw = credentialDescriptor(OPENCLAW_CREDENTIAL); + + expect(() => readPair(deployment, openClaw)).toThrow(message); + }); + + it("does not include descriptor contents in validation errors (#9235)", () => { + const deploymentSecret = `${DEPLOYMENT_CREDENTIAL} secret`; + const deployment = credentialDescriptor(deploymentSecret); + const openClaw = credentialDescriptor(OPENCLAW_CREDENTIAL); + + expect(() => readPair(deployment, openClaw)).toThrowError( + expect.objectContaining({ message: expect.not.stringContaining(deploymentSecret) }), + ); + }); + + it("preserves a credential error when descriptor cleanup also fails (#9235)", () => { + const deployment = credentialDescriptor("short"); + const openClaw = credentialDescriptor(OPENCLAW_CREDENTIAL); + const closeSync = fs.closeSync; + vi.spyOn(fs, "closeSync") + .mockImplementationOnce((descriptor) => { + closeSync(descriptor); + throw new Error("cleanup failed"); + }) + .mockImplementationOnce(closeSync); + + expect(() => readPair(deployment, openClaw)).toThrow("invalid size"); + expect(() => fs.fstatSync(openClaw)).toThrow(); + }); + + it("reports a cleanup error after successful credential reads (#9235)", () => { + const deployment = credentialDescriptor(DEPLOYMENT_CREDENTIAL); + const openClaw = credentialDescriptor(OPENCLAW_CREDENTIAL); + const closeSync = fs.closeSync; + vi.spyOn(fs, "closeSync") + .mockImplementationOnce((descriptor) => { + closeSync(descriptor); + throw new Error("cleanup failed"); + }) + .mockImplementationOnce(closeSync); + + expect(() => readPair(deployment, openClaw)).toThrow("cleanup failed"); + expect(() => fs.fstatSync(openClaw)).toThrow(); }); }); diff --git a/src/lib/voice-gateway/credential-file.ts b/src/lib/voice-gateway/credential-file.ts index 5f7bdcdabc..9166ebae4a 100644 --- a/src/lib/voice-gateway/credential-file.ts +++ b/src/lib/voice-gateway/credential-file.ts @@ -2,24 +2,25 @@ // SPDX-License-Identifier: Apache-2.0 import fs from "node:fs"; -import path from "node:path"; const MAX_CREDENTIAL_BYTES = 4096; const MIN_CREDENTIAL_BYTES = 32; -function validatePrivateRegularFile(stat: fs.Stats, filePath: string, label: string): void { - if (!stat.isFile()) throw new Error(`${label} path is not a regular file: ${filePath}`); +/** Validate the ownership, mode, type, and size of one credential descriptor. */ +export function validatePrivateCredentialDescriptor(stat: fs.Stats, label: string): void { + if (!stat.isFile()) throw new Error(`${label} descriptor is not a regular file.`); if ((stat.mode & 0o077) !== 0) { - throw new Error(`${label} file must not be accessible by group or others: ${filePath}`); + throw new Error(`${label} descriptor must not be accessible by group or others.`); } if (typeof process.getuid === "function" && stat.uid !== process.getuid()) { - throw new Error(`${label} file is not owned by the current user: ${filePath}`); + throw new Error(`${label} descriptor is not owned by the current user.`); } if (stat.size < MIN_CREDENTIAL_BYTES || stat.size > MAX_CREDENTIAL_BYTES + 1) { - throw new Error(`${label} file has an invalid size: ${filePath}`); + throw new Error(`${label} descriptor has an invalid size.`); } } +/** Validate one bounded printable bearer value without including it in diagnostics. */ function validateBearer(value: string, label: string): void { const bytes = Buffer.byteLength(value); if ( @@ -31,39 +32,88 @@ function validateBearer(value: string, label: string): void { } } -/** Read one owner-only bearer without following the final path component. */ -export function readPrivateBearerFile(filePath: string, label: string): string { - if (!path.isAbsolute(filePath)) throw new Error(`${label} file path must be absolute.`); - if (typeof fs.constants.O_NOFOLLOW !== "number") { - throw new Error("Secure no-follow file opens are unavailable on this platform."); +/** Trusted launcher's fixed bearer-descriptor role mapping. */ +export interface PrivateBearerDescriptors { + readonly deployment: number; + readonly openClaw: number; +} + +/** Read one credential descriptor once and validate its complete contents. */ +function readBearer(descriptor: number, label: string): string { + const buffer = Buffer.alloc(MAX_CREDENTIAL_BYTES + 2); + const bytesRead = fs.readSync(descriptor, buffer, 0, buffer.length, null); + if (bytesRead > MAX_CREDENTIAL_BYTES + 1) { + throw new Error(`${label} descriptor has an invalid size.`); } + const contents = buffer.subarray(0, bytesRead).toString("utf8"); + const value = contents.endsWith("\n") ? contents.slice(0, -1) : contents; + validateBearer(value, label); + return value; +} - let descriptor: number | undefined; +/** Validate one inherited descriptor and return its identity-bearing metadata. */ +function validateDescriptor(descriptor: number, label: string): fs.Stats { + if (!Number.isInteger(descriptor) || descriptor < 0) { + throw new Error(`${label} descriptor is invalid.`); + } try { + const stat = fs.fstatSync(descriptor); + validatePrivateCredentialDescriptor(stat, label); + return stat; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "EBADF") { + throw new Error(`${label} descriptor is not open.`); + } + throw error; + } +} + +/** Read and close the two fixed startup descriptors before the gateway can accept traffic. */ +export function readPrivateBearerDescriptors(descriptors: PrivateBearerDescriptors): { + deploymentCredential: string; + openClawCredential: string; +} { + const uniqueDescriptors = [...new Set([descriptors.deployment, descriptors.openClaw])]; + let result: { deploymentCredential: string; openClawCredential: string } | undefined; + let operationError: { readonly value: unknown } | undefined; + try { + const deploymentStat = validateDescriptor( + descriptors.deployment, + "Voice gateway deployment credential", + ); + const openClawStat = validateDescriptor( + descriptors.openClaw, + "Voice gateway OpenClaw credential", + ); + if ( + descriptors.deployment === descriptors.openClaw || + (deploymentStat.dev === openClawStat.dev && deploymentStat.ino === openClawStat.ino) + ) { + throw new Error("Voice gateway credential descriptors must refer to different files."); + } + result = { + deploymentCredential: readBearer( + descriptors.deployment, + "Voice gateway deployment credential", + ), + openClawCredential: readBearer(descriptors.openClaw, "Voice gateway OpenClaw credential"), + }; + } catch (error) { + operationError = { value: error }; + } + + let cleanupError: unknown; + for (const descriptor of uniqueDescriptors) { try { - descriptor = fs.openSync( - filePath, - fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | (fs.constants.O_NONBLOCK ?? 0), - ); + fs.closeSync(descriptor); } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code === "ELOOP" || code === "EMLINK") { - throw new Error(`Refusing to read a symbolic-link ${label} file: ${filePath}`); + if ((error as NodeJS.ErrnoException).code !== "EBADF" && cleanupError === undefined) { + cleanupError = error; } - throw error; } - - validatePrivateRegularFile(fs.fstatSync(descriptor), filePath, label); - const buffer = Buffer.alloc(MAX_CREDENTIAL_BYTES + 2); - const bytesRead = fs.readSync(descriptor, buffer, 0, buffer.length, 0); - if (bytesRead > MAX_CREDENTIAL_BYTES + 1) { - throw new Error(`${label} file has an invalid size: ${filePath}`); - } - const contents = buffer.subarray(0, bytesRead).toString("utf8"); - const value = contents.endsWith("\n") ? contents.slice(0, -1) : contents; - validateBearer(value, label); - return value; - } finally { - if (descriptor !== undefined) fs.closeSync(descriptor); } + + if (operationError !== undefined) throw operationError.value; + if (cleanupError !== undefined) throw cleanupError; + return result!; } diff --git a/src/lib/voice-gateway/launcher.test.ts b/src/lib/voice-gateway/launcher.test.ts new file mode 100644 index 0000000000..e7bb365cd4 --- /dev/null +++ b/src/lib/voice-gateway/launcher.test.ts @@ -0,0 +1,205 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { EventEmitter } from "node:events"; +import type { ChildProcess } from "node:child_process"; + +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("node:child_process", async (importOriginal) => { + const original = await importOriginal(); + return { ...original, spawn: vi.fn() }; +}); + +import { spawn } from "node:child_process"; + +import { + buildVoiceGatewayLaunchContract, + launchVoiceGateway, + VoiceGatewayTerminationUnconfirmedError, +} from "./launcher"; + +const directories: string[] = []; + +function temporaryDirectory(): string { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-voice-launcher-")); + directories.push(directory); + return directory; +} + +function options() { + const directory = temporaryDirectory(); + return { + deploymentCredentialPath: path.join(directory, "deployment"), + openClawCredentialPath: path.join(directory, "openclaw"), + gatewayUrl: "ws://127.0.0.1:18789/ws", + runtimeIdentity: "voiceclaw-local", + runtimeProfile: "voiceclaw-pinned", + sandbox: "repository-fixture", + agent: "main", + listenPort: 18800, + }; +} + +afterEach(() => { + vi.useRealTimers(); + for (const directory of directories.splice(0)) { + fs.rmSync(directory, { force: true, recursive: true }); + } +}); + +describe("voice gateway launcher", () => { + it("keeps credential source paths and values out of the child contract (#9235)", () => { + const launchOptions = options(); + const contract = buildVoiceGatewayLaunchContract(launchOptions); + + expect(contract.args).not.toContain(launchOptions.deploymentCredentialPath); + expect(contract.args).not.toContain(launchOptions.openClawCredentialPath); + expect(contract.env).toEqual({ NEMOCLAW_EXPERIMENTAL_VOICE_GATEWAY: "1" }); + }); + + it("rejects a symbolic-link credential source before launch (#9235)", async () => { + const launchOptions = options(); + const target = path.join(path.dirname(launchOptions.deploymentCredentialPath), "target"); + fs.writeFileSync(target, "deployment-credential-for-launcher-012345", { mode: 0o600 }); + fs.symlinkSync(target, launchOptions.deploymentCredentialPath); + fs.writeFileSync( + launchOptions.openClawCredentialPath, + "openclaw-credential-for-launcher-01234567", + { mode: 0o600 }, + ); + + await expect(launchVoiceGateway(launchOptions)).rejects.toThrow("symbolic link"); + await expect(launchVoiceGateway(launchOptions)).rejects.toThrowError( + expect.objectContaining({ + message: expect.not.stringContaining(launchOptions.deploymentCredentialPath), + }), + ); + }); + + it("terminates and reaps the child when parent descriptor cleanup fails (#9235)", async () => { + const launchOptions = options(); + fs.writeFileSync( + launchOptions.deploymentCredentialPath, + "deployment-credential-for-launcher-012345", + { mode: 0o600 }, + ); + fs.writeFileSync( + launchOptions.openClawCredentialPath, + "openclaw-credential-for-launcher-01234567", + { mode: 0o600 }, + ); + const child = new EventEmitter() as ChildProcess; + Object.assign(child, { + exitCode: null, + signalCode: null, + kill: vi.fn(() => { + queueMicrotask(() => child.emit("exit", null, "SIGTERM")); + return true; + }), + }); + vi.mocked(spawn).mockReturnValueOnce(child); + const close = fs.closeSync.bind(fs); + vi.spyOn(fs, "closeSync") + .mockImplementationOnce((descriptor) => { + close(descriptor); + throw Object.assign(new Error("close failed"), { code: "EIO" }); + }) + .mockImplementation(close); + + await expect(launchVoiceGateway(launchOptions)).rejects.toThrow("close failed"); + const stdio = vi.mocked(spawn).mock.calls[0]?.[2]?.stdio as number[]; + expect(() => fs.fstatSync(stdio[3]!)).toThrowError(expect.objectContaining({ code: "EBADF" })); + expect(() => fs.fstatSync(stdio[4]!)).toThrowError(expect.objectContaining({ code: "EBADF" })); + expect(child.kill).toHaveBeenCalledWith("SIGTERM"); + expect(child.listenerCount("error")).toBe(0); + expect(child.listenerCount("exit")).toBe(0); + }); + + it("returns the child handle when bounded termination is unconfirmed (#9235)", async () => { + vi.useFakeTimers(); + const launchOptions = options(); + fs.writeFileSync( + launchOptions.deploymentCredentialPath, + "deployment-credential-for-launcher-012345", + { mode: 0o600 }, + ); + fs.writeFileSync( + launchOptions.openClawCredentialPath, + "openclaw-credential-for-launcher-01234567", + { mode: 0o600 }, + ); + const child = new EventEmitter() as ChildProcess; + Object.assign(child, { + exitCode: null, + signalCode: null, + kill: vi.fn(() => true), + }); + vi.mocked(spawn).mockReturnValueOnce(child); + const close = fs.closeSync.bind(fs); + vi.spyOn(fs, "closeSync") + .mockImplementationOnce((descriptor) => { + close(descriptor); + throw Object.assign(new Error("close failed"), { code: "EIO" }); + }) + .mockImplementation(close); + + const launch = launchVoiceGateway(launchOptions).catch((error: unknown) => error); + await vi.advanceTimersByTimeAsync(10_000); + + const error = await launch; + expect(error).toBeInstanceOf(VoiceGatewayTerminationUnconfirmedError); + expect(error).toMatchObject({ + child, + cause: expect.objectContaining({ message: "close failed" }), + }); + expect(child.kill).toHaveBeenNthCalledWith(1, "SIGTERM"); + expect(child.kill).toHaveBeenNthCalledWith(2, "SIGKILL"); + expect(child.listenerCount("error")).toBe(0); + expect(child.listenerCount("exit")).toBe(0); + }); + + it("retains the child handle when process control emits an error (#9235)", async () => { + const launchOptions = options(); + fs.writeFileSync( + launchOptions.deploymentCredentialPath, + "deployment-credential-for-launcher-012345", + { mode: 0o600 }, + ); + fs.writeFileSync( + launchOptions.openClawCredentialPath, + "openclaw-credential-for-launcher-01234567", + { mode: 0o600 }, + ); + const child = new EventEmitter() as ChildProcess; + Object.assign(child, { + exitCode: null, + signalCode: null, + kill: vi.fn(() => { + queueMicrotask(() => child.emit("error", new Error("kill failed"))); + return true; + }), + }); + vi.mocked(spawn).mockReturnValueOnce(child); + const close = fs.closeSync.bind(fs); + vi.spyOn(fs, "closeSync") + .mockImplementationOnce((descriptor) => { + close(descriptor); + throw Object.assign(new Error("close failed"), { code: "EIO" }); + }) + .mockImplementation(close); + + const error = await launchVoiceGateway(launchOptions).catch((cause: unknown) => cause); + + expect(error).toBeInstanceOf(VoiceGatewayTerminationUnconfirmedError); + expect(error).toMatchObject({ + child, + cause: expect.objectContaining({ message: "close failed" }), + }); + expect(child.listenerCount("error")).toBe(0); + expect(child.listenerCount("exit")).toBe(0); + }); +}); diff --git a/src/lib/voice-gateway/launcher.ts b/src/lib/voice-gateway/launcher.ts new file mode 100644 index 0000000000..7a1802820e --- /dev/null +++ b/src/lib/voice-gateway/launcher.ts @@ -0,0 +1,203 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { spawn, type ChildProcess } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; + +import { + VOICE_GATEWAY_DEPLOYMENT_CREDENTIAL_FD, + VOICE_GATEWAY_FEATURE_ENV, + VOICE_GATEWAY_OPENCLAW_CREDENTIAL_FD, +} from "./contracts"; +import { validatePrivateCredentialDescriptor } from "./credential-file"; + +/** Trusted source paths and fixed runtime fields for one voice-gateway launch. */ +export interface VoiceGatewayLaunchOptions { + readonly deploymentCredentialPath: string; + readonly openClawCredentialPath: string; + readonly gatewayUrl: string; + readonly runtimeIdentity: string; + readonly runtimeProfile: string; + readonly sandbox: string; + readonly agent: string; + readonly listenPort?: number; +} + +/** Credential-free process contract emitted by the bounded launcher. */ +export interface VoiceGatewayLaunchContract { + readonly command: string; + readonly args: readonly string[]; + readonly env: Readonly>; +} + +/** Cleanup failure that retains the only handle to a child whose exit was not observed. */ +export class VoiceGatewayTerminationUnconfirmedError extends Error { + readonly child: ChildProcess; + + constructor(cause: unknown, child: ChildProcess) { + super("Voice gateway termination could not be confirmed after credential cleanup failed.", { + cause, + }); + this.name = "VoiceGatewayTerminationUnconfirmedError"; + this.child = child; + } +} + +const CLI = path.resolve(__dirname, "../../../bin/nemoclaw.js"); + +/** Build the path- and value-free child process contract for the voice gateway. */ +export function buildVoiceGatewayLaunchContract( + options: VoiceGatewayLaunchOptions, +): VoiceGatewayLaunchContract { + const args = [ + CLI, + "internal", + "voice-gateway", + "serve", + "--gateway-url", + options.gatewayUrl, + "--runtime-identity", + options.runtimeIdentity, + "--runtime-profile", + options.runtimeProfile, + "--sandbox", + options.sandbox, + "--agent", + options.agent, + ]; + if (options.listenPort !== undefined) args.push("--listen-port", String(options.listenPort)); + return { + command: process.execPath, + args, + env: { [VOICE_GATEWAY_FEATURE_ENV]: "1" }, + }; +} + +/** Open and validate one trusted credential source without exposing its path downstream. */ +function openCredential(pathname: string, label: string): number { + if (!path.isAbsolute(pathname)) throw new Error(`${label} path must be absolute.`); + if (typeof fs.constants.O_NOFOLLOW !== "number") { + throw new Error("Secure no-follow credential opens are unavailable on this platform."); + } + let descriptor: number; + try { + descriptor = fs.openSync( + pathname, + fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | (fs.constants.O_NONBLOCK ?? 0), + ); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ELOOP" || code === "EMLINK") { + throw new Error(`${label} path must not be a symbolic link.`); + } + throw new Error(`${label} source could not be opened.`); + } + try { + validatePrivateCredentialDescriptor(fs.fstatSync(descriptor), label); + return descriptor; + } catch (error) { + try { + fs.closeSync(descriptor); + } catch { + // Preserve the validation failure; the process still owns final descriptor cleanup. + } + throw error; + } +} + +/** Close every owned descriptor while preserving the first cleanup failure. */ +function closeDescriptors(descriptors: readonly number[]): void { + let cleanupError: unknown; + for (const descriptor of descriptors) { + try { + fs.closeSync(descriptor); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "EBADF") continue; + cleanupError ??= error; + } + } + if (cleanupError !== undefined) throw cleanupError; +} + +/** Attempt bounded termination and report whether child exit was observed. */ +async function terminateChild(child: ChildProcess): Promise { + if (child.exitCode !== null || child.signalCode !== null) return true; + return new Promise((resolve) => { + let forceTimer: NodeJS.Timeout | undefined; + let terminalTimer: NodeJS.Timeout | undefined; + const settled = (exitObserved: boolean) => { + if (forceTimer !== undefined) clearTimeout(forceTimer); + if (terminalTimer !== undefined) clearTimeout(terminalTimer); + child.off("error", failed); + child.off("exit", confirmed); + resolve(exitObserved); + }; + const confirmed = () => settled(true); + const failed = () => settled(false); + forceTimer = setTimeout(() => { + if (!child.kill("SIGKILL")) { + settled(false); + return; + } + terminalTimer = setTimeout(() => settled(false), 5_000); + terminalTimer.unref(); + }, 5_000); + forceTimer.unref(); + child.once("error", failed); + child.once("exit", confirmed); + if (!child.kill("SIGTERM")) settled(false); + }); +} + +/** Launch the real gateway with only the two selected credential objects inherited. */ +export async function launchVoiceGateway( + options: VoiceGatewayLaunchOptions, +): Promise { + const descriptors: number[] = []; + let child: ChildProcess | undefined; + let operationError: { readonly value: unknown } | undefined; + try { + const deployment = openCredential( + options.deploymentCredentialPath, + "Voice gateway deployment credential", + ); + descriptors.push(deployment); + const openClaw = openCredential( + options.openClawCredentialPath, + "Voice gateway OpenClaw credential", + ); + descriptors.push(openClaw); + const deploymentStat = fs.fstatSync(deployment); + const openClawStat = fs.fstatSync(openClaw); + if (deploymentStat.dev === openClawStat.dev && deploymentStat.ino === openClawStat.ino) { + throw new Error("Voice gateway credential sources must refer to different files."); + } + + const contract = buildVoiceGatewayLaunchContract(options); + const stdio: Array<"ignore" | "pipe" | number> = ["ignore", "pipe", "pipe"]; + stdio[VOICE_GATEWAY_DEPLOYMENT_CREDENTIAL_FD] = deployment; + stdio[VOICE_GATEWAY_OPENCLAW_CREDENTIAL_FD] = openClaw; + child = spawn(contract.command, contract.args, { + env: contract.env, + stdio, + }); + } catch (error) { + operationError = { value: error }; + } + + let cleanupError: unknown; + try { + closeDescriptors(descriptors); + } catch (error) { + cleanupError = error; + } + if (operationError !== undefined) throw operationError.value; + if (cleanupError !== undefined) { + if (!(await terminateChild(child!))) { + throw new VoiceGatewayTerminationUnconfirmedError(cleanupError, child!); + } + throw cleanupError; + } + return child!; +} diff --git a/test/fixtures/voice-gateway/process-launcher.ts b/test/fixtures/voice-gateway/process-launcher.ts new file mode 100644 index 0000000000..aca5d1442f --- /dev/null +++ b/test/fixtures/voice-gateway/process-launcher.ts @@ -0,0 +1,120 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { ChildProcess } from "node:child_process"; +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import http from "node:http"; +import net from "node:net"; + +/** Reserve and release an ephemeral loopback port for a process-contract test. */ +export async function reserveLoopbackPort(): Promise { + const server = net.createServer(); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address() as net.AddressInfo; + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); + return address.port; +} + +/** Wait until the child reports its listening state or terminates. */ +export async function waitForGatewayListening(child: ChildProcess): Promise { + await new Promise((resolve, reject) => { + let stdout = ""; + const timeout = setTimeout(() => reject(new Error("voice gateway did not start")), 15_000); + const rejectAfterCleanup = (error: Error) => { + clearTimeout(timeout); + reject(error); + }; + child.once("error", rejectAfterCleanup); + child.once("exit", (code) => + rejectAfterCleanup(new Error(`voice gateway exited before startup: ${code}`)), + ); + child.stdout?.on("data", (chunk) => { + stdout += String(chunk); + if (stdout.includes('"state":"listening"')) { + clearTimeout(timeout); + resolve(); + } + }); + }); +} + +/** Return filesystem targets still held open by a running process. */ +export function openFileTargets(pid: number): string[] { + if (process.platform === "linux") { + return fs + .readdirSync(`/proc/${pid}/fd`) + .map((descriptor) => `/proc/${pid}/fd/${descriptor}`) + .flatMap((descriptorPath) => { + try { + return [fs.readlinkSync(descriptorPath)]; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return []; + throw error; + } + }); + } + try { + const output = execFileSync("lsof", ["-a", "-p", String(pid), "-Fn"], { + encoding: "utf8", + }); + return output + .split("\n") + .filter((line) => line.startsWith("n")) + .map((line) => line.slice(1)); + } catch (error) { + if ((error as { status?: number }).status === 1) return []; + throw error; + } +} + +/** Submit one authenticated session request to the process under test. */ +export async function requestSession( + port: number, + bearer: string, +): Promise<{ readonly status: number; readonly body: string }> { + const body = JSON.stringify({ runtimeConversationId: "process-contract" }); + return new Promise((resolve, reject) => { + const request = http.request( + { + host: "127.0.0.1", + port, + method: "POST", + path: "/v1/voice/sessions", + headers: { + authorization: `Bearer ${bearer}`, + "content-length": String(Buffer.byteLength(body)), + "content-type": "application/json", + }, + }, + (response) => { + const chunks: Buffer[] = []; + response.on("data", (chunk) => chunks.push(Buffer.from(chunk))); + response.on("end", () => + resolve({ + status: response.statusCode ?? 0, + body: Buffer.concat(chunks).toString("utf8"), + }), + ); + }, + ); + request.once("error", reject); + request.end(body); + }); +} + +/** Stop a gateway child and wait for process termination. */ +export async function stopGateway(child: ChildProcess): Promise { + if (child.exitCode !== null || child.signalCode !== null) return; + const exited = new Promise((resolve, reject) => { + child.once("error", reject); + child.once("exit", () => resolve()); + }); + child.kill("SIGTERM"); + await exited; +} diff --git a/test/internal-cli.test.ts b/test/internal-cli.test.ts index 14f12e1e56..194f22ab53 100644 --- a/test/internal-cli.test.ts +++ b/test/internal-cli.test.ts @@ -118,7 +118,7 @@ describe("internal oclif namespace", () => { }); }); - it("fails the experimental voice gateway gate before parsing credential flags (#8378)", () => { + it("fails the experimental voice gateway gate before parsing required flags (#8378)", () => { const env = { ...process.env }; delete env.NEMOCLAW_EXPERIMENTAL_VOICE_GATEWAY; @@ -144,8 +144,6 @@ describe("internal oclif namespace", () => { expect(result.status).toBe(0); expect(result.stdout).toContain("Internal: serve the experimental voice gateway"); - expect(result.stdout).toContain("--deployment-credential-file"); - expect(result.stdout).toContain("--openclaw-credential-file"); expect(result.stdout).toContain("--runtime-identity"); }); }); diff --git a/test/package-contract/cli/oclif-metadata.test.ts b/test/package-contract/cli/oclif-metadata.test.ts index e8a47ccdb5..5f0fc5bc73 100644 --- a/test/package-contract/cli/oclif-metadata.test.ts +++ b/test/package-contract/cli/oclif-metadata.test.ts @@ -29,6 +29,24 @@ describe("oclif metadata lookup", () => { ); }); + it("publishes the fixed voice-gateway descriptor contract without path flags (#9235)", () => { + const cli = path.join(process.cwd(), "bin", "nemoclaw.js"); + const result = spawnSync( + process.execPath, + [cli, "internal", "voice-gateway", "serve", "--help"], + { + encoding: "utf-8", + env: { ...process.env, NEMOCLAW_EXPERIMENTAL_VOICE_GATEWAY: "1" }, + }, + ); + + expect(result.status).toBe(0); + expect(result.stdout).not.toContain("--deployment-credential-file"); + expect(result.stdout).not.toContain("--openclaw-credential-file"); + expect(result.stdout).toContain("descriptor 3"); + expect(result.stdout).toContain("descriptor 4"); + }); + it("keeps generated manifest command IDs aligned with oclif Config", async () => { const config = await OclifConfig.load(process.cwd()); const expectedIds = config.commands.map((command) => command.id).sort(); diff --git a/test/package-contract/cli/voice-gateway-launcher.test.ts b/test/package-contract/cli/voice-gateway-launcher.test.ts new file mode 100644 index 0000000000..c146281734 --- /dev/null +++ b/test/package-contract/cli/voice-gateway-launcher.test.ts @@ -0,0 +1,83 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import type { ChildProcess } from "node:child_process"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { buildVoiceGatewayLaunchContract } from "../../../dist/lib/voice-gateway/launcher"; +import { runVoiceGatewayLaunch } from "../../../dist/lib/actions/voice-gateway/launch"; +import { + openFileTargets, + requestSession, + reserveLoopbackPort, + stopGateway, + waitForGatewayListening, +} from "../../fixtures/voice-gateway/process-launcher"; + +const DEPLOYMENT_BEARER = "deployment-bearer-for-process-contract"; +const ROTATED_DEPLOYMENT_BEARER = "rotated-deployment-bearer-for-process-contract"; +const OPENCLAW_CREDENTIAL = "openclaw-bearer-for-process-contract"; +const directories: string[] = []; +const children: ChildProcess[] = []; + +afterEach(async () => { + await Promise.all(children.splice(0).map((child) => stopGateway(child))); + for (const directory of directories.splice(0)) { + fs.rmSync(directory, { force: true, recursive: true }); + } +}); + +describe("voice gateway process launch contract", () => { + it("maps fixed descriptors, closes them before serving, and rotates on restart (#9235)", async () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-voice-process-")); + directories.push(directory); + const deploymentCredentialPath = path.join(directory, "deployment"); + const openClawCredentialPath = path.join(directory, "openclaw"); + fs.writeFileSync(deploymentCredentialPath, DEPLOYMENT_BEARER, { mode: 0o600 }); + fs.writeFileSync(openClawCredentialPath, OPENCLAW_CREDENTIAL, { mode: 0o600 }); + const listenPort = await reserveLoopbackPort(); + const options = { + deploymentCredentialPath, + openClawCredentialPath, + gatewayUrl: "ws://127.0.0.1:18789/ws", + runtimeIdentity: "voiceclaw-local", + runtimeProfile: "voiceclaw-pinned", + sandbox: "repository-fixture", + agent: "main", + listenPort, + }; + const contract = buildVoiceGatewayLaunchContract(options); + + expect(JSON.stringify(contract)).not.toContain(deploymentCredentialPath); + expect(JSON.stringify(contract)).not.toContain(openClawCredentialPath); + expect(JSON.stringify(contract)).not.toContain(DEPLOYMENT_BEARER); + expect(JSON.stringify(contract)).not.toContain(OPENCLAW_CREDENTIAL); + + const first = await runVoiceGatewayLaunch(options); + children.push(first); + await waitForGatewayListening(first); + expect(openFileTargets(first.pid!)).not.toContain(deploymentCredentialPath); + expect(openFileTargets(first.pid!)).not.toContain(openClawCredentialPath); + expect(await requestSession(listenPort, DEPLOYMENT_BEARER)).toMatchObject({ status: 201 }); + await stopGateway(first); + + fs.writeFileSync(deploymentCredentialPath, ROTATED_DEPLOYMENT_BEARER, { mode: 0o600 }); + const second = await runVoiceGatewayLaunch(options); + children.push(second); + await waitForGatewayListening(second); + expect(openFileTargets(second.pid!)).not.toContain(deploymentCredentialPath); + expect(openFileTargets(second.pid!)).not.toContain(openClawCredentialPath); + expect(await requestSession(listenPort, DEPLOYMENT_BEARER)).toEqual({ + status: 401, + body: '{"error":"authentication_failed"}', + }); + expect(await requestSession(listenPort, ROTATED_DEPLOYMENT_BEARER)).toMatchObject({ + status: 201, + }); + await stopGateway(second); + }); +}); diff --git a/test/voice-gateway-integration.test.ts b/test/voice-gateway-integration.test.ts index c2dfa1e83c..1466cc50a1 100644 --- a/test/voice-gateway-integration.test.ts +++ b/test/voice-gateway-integration.test.ts @@ -1,12 +1,16 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import fs from "node:fs"; import http, { type Server } from "node:http"; +import os from "node:os"; +import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { createVoiceGatewayServer } from "../src/lib/adapters/http/voice-gateway-server"; import type { AgentTurnClient, AgentTurnEvent } from "../src/lib/voice-gateway/contracts"; +import { readPrivateBearerDescriptors } from "../src/lib/voice-gateway/credential-file"; import { OpenClawVoiceClient } from "../src/lib/voice-gateway/openclaw-client"; import { VoiceSessionService } from "../src/lib/voice-gateway/session-service"; import { PinnedOpenClawGateway } from "./fixtures/voice-gateway/pinned-openclaw-gateway"; @@ -119,6 +123,58 @@ afterEach(async () => { }); describe("experimental voice gateway composed boundary", () => { + it("fails closed when the launcher swaps the fixed credential roles (#9235)", async () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-voice-swapped-")); + try { + const deploymentPath = path.join(directory, "deployment"); + const openClawPath = path.join(directory, "openclaw"); + fs.writeFileSync(deploymentPath, DEPLOYMENT_BEARER, { mode: 0o600 }); + fs.writeFileSync(openClawPath, OPENCLAW_CREDENTIAL, { mode: 0o600 }); + const credentials = readPrivateBearerDescriptors({ + deployment: fs.openSync( + openClawPath, + fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW, + ), + openClaw: fs.openSync( + deploymentPath, + fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW, + ), + }); + let clientsCreated = 0; + const service = new VoiceSessionService({ + runtimeIdentity: "voiceclaw-local", + runtimeProfile: "voiceclaw-pinned", + sandbox: "repository-fixture", + agent: "main", + createClient: () => { + clientsCreated += 1; + return new FakeOpenClawGatewayClient(credentials.openClawCredential); + }, + }); + const port = await listen( + createVoiceGatewayServer({ + deploymentCredential: credentials.deploymentCredential, + service, + }), + ); + + const response = await requestJson({ + port, + method: "POST", + path: "/v1/voice/sessions", + bearer: DEPLOYMENT_BEARER, + body: { runtimeConversationId: "runtime-conversation" }, + }); + + expect(response).toEqual({ status: 401, body: '{"error":"authentication_failed"}' }); + expect(clientsCreated).toBe(0); + expect(JSON.stringify(response)).not.toContain(DEPLOYMENT_BEARER); + expect(JSON.stringify(response)).not.toContain(OPENCLAW_CREDENTIAL); + } finally { + fs.rmSync(directory, { force: true, recursive: true }); + } + }); + it("recovers an omitted delta when a final event repeats the last sequence (#9243)", async () => { let pinnedOpenClaw: PinnedOpenClawGateway | undefined; const diagnostics: object[] = [];