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
42 changes: 25 additions & 17 deletions docs/reference/commands.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <deployment-identity> \
--runtime-profile <runtime-profile-id> \
Expand Down
14 changes: 2 additions & 12 deletions src/commands/internal/voice-gateway/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <path> --openclaw-credential-file <path> --gateway-url <url> --runtime-identity <id> --runtime-profile <id> --sandbox <name> --agent <id> [--listen-port <port>]",
"internal voice-gateway serve --gateway-url <url> --runtime-identity <id> --runtime-profile <id> --sandbox <name> --agent <id> [--listen-port <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,
Expand Down Expand Up @@ -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"],
Expand Down
13 changes: 13 additions & 0 deletions src/lib/actions/voice-gateway/launch.ts
Original file line number Diff line number Diff line change
@@ -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<ChildProcess> {
return launchVoiceGateway(options);
}
19 changes: 9 additions & 10 deletions src/lib/actions/voice-gateway/serve.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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(
Expand All @@ -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();
});
});
Expand Down Expand Up @@ -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",
Expand Down
22 changes: 9 additions & 13 deletions src/lib/actions/voice-gateway/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -25,8 +27,6 @@ interface ProcessEvents {
}

export interface VoiceGatewayServeOptions {
readonly deploymentCredentialFile: string;
readonly openClawCredentialFile: string;
readonly gatewayUrl: string;
readonly runtimeIdentity: string;
readonly runtimeProfile: string;
Expand All @@ -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;
Expand Down Expand Up @@ -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) => {
Expand Down
4 changes: 4 additions & 0 deletions src/lib/voice-gateway/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading