Skip to content
2 changes: 1 addition & 1 deletion ci/source-architecture-budget.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"src/lib/adapters/openshell/timeouts.ts": 37,
"src/lib/agent/defs.ts": 32,
"src/lib/cli/branding.ts": 87,
"src/lib/cli/nemoclaw-oclif-command.ts": 105,
"src/lib/cli/nemoclaw-oclif-command.ts": 106,
"src/lib/cli/terminal-style.ts": 45,
"src/lib/core/json-types.ts": 37,
"src/lib/core/ports.ts": 88,
Expand Down
78 changes: 77 additions & 1 deletion docs/reference/commands.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -3865,7 +3865,83 @@ For contributor guidance on how these command files are structured, refer to
These commands do not appear in the command-level parity check, which compares
`$$nemoclaw --help` against the public command headings in this reference; hidden
commands are excluded from both. The table above is the canonical reference for
the family.
the script-backed family.
The experimental adapter is documented separately because it has no owning script.

`$$nemoclaw internal voice-gateway serve` is registered for the OpenClaw-only experimental adapter described below.
Hermes and Deep Agents Code do not have an equivalent adapter.

<AgentOnly variant="openclaw">

#### $$nemoclaw internal voice-gateway serve

<Warning>

This hidden command is an experimental implementation detail, not a supported NemoClaw product surface or public protocol.
Do not expose its listener through a port forward, proxy, or public ingress.

</Warning>

This foreground command runs a private HTTP adapter that streams newline-delimited JSON (NDJSON) responses.
It accepts one authenticated runtime deployment and at most one active voice session.
Each voice session accepts one committed text turn.
NemoClaw selects the runtime profile, sandbox, and OpenClaw agent from command-line configuration.
The runtime cannot select an agent, OpenClaw session key, upstream URL, or forwarding destination.

The command requires the exact feature gate `NEMOCLAW_EXPERIMENTAL_VOICE_GATEWAY=1`.
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.
NemoClaw does not send the OpenClaw credential to the runtime.

Start the adapter with trusted operator-selected values:

```bash
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> \
--sandbox <sandbox-name> \
--agent <openclaw-agent-id> \
--listen-port 18800
```

The `--gateway-url` value must use `ws://`, an explicit port, the `/ws` path, and a loopback IP address literal.
The URL must not contain credentials, a query string, or a fragment.
The adapter binds only to `127.0.0.1` and uses port `18800` when you omit `--listen-port`.
The JSON diagnostic with `"event":"voice_gateway"` and `"state":"listening"` confirms that the adapter acquired the configured loopback listener.

The runtime must keep the raw session grant in process memory.
NemoClaw keeps only the digest needed for constant-time grant validation.
When the session closes or expires, the runtime must discard the raw grant, and NemoClaw removes its validation digest from active session state.
The process owns the OpenClaw session binding, turn state, and response correlation.
It clears that state when the session closes or expires and when the foreground process stops.
Normal onboarding and managed startup do not start or supervise this command.

The adapter exchanges committed text and normalized response events only.
It does not implement audio, WebRTC, RTVI, voice activity detection, speech recognition, speech synthesis, playback, or runtime-specific UI behavior.
It also does not establish VoiceClaw, ElevenLabs, Hermes, or general voice support.

</AgentOnly>
<AgentOnly variant="hermes,deepagents">

The experimental voice gateway has no Hermes or Deep Agents Code equivalent.

</AgentOnly>

## Environment Variables

Expand Down
73 changes: 73 additions & 0 deletions src/commands/internal/voice-gateway/serve.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { Flags } from "@oclif/core";

import {
assertVoiceGatewayEnabled,
runVoiceGatewayServe,
} from "../../../lib/actions/voice-gateway/serve";
import { DEFAULT_VOICE_GATEWAY_LISTEN_PORT } from "../../../lib/voice-gateway/contracts";
import { NemoClawCommand } from "../../../lib/cli/nemoclaw-oclif-command";

export default class InternalVoiceGatewayServeCommand extends NemoClawCommand {
static hidden = true;
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.";
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>]",
];
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,
}),
"runtime-identity": Flags.string({
description: "Trusted local runtime deployment identity",
required: true,
}),
"runtime-profile": Flags.string({
description: "Operator-selected runtime profile",
required: true,
}),
sandbox: Flags.string({
description: "Operator-selected sandbox",
required: true,
}),
agent: Flags.string({
description: "Operator-selected OpenClaw agent",
required: true,
}),
"listen-port": Flags.integer({
default: DEFAULT_VOICE_GATEWAY_LISTEN_PORT,
description: "Loopback port for the private runtime adapter",
min: 1024,
max: 65_535,
}),
};

public async run(): Promise<void> {
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"],
sandbox: flags.sandbox,
agent: flags.agent,
listenPort: flags["listen-port"],
});
}
}
131 changes: 131 additions & 0 deletions src/lib/actions/voice-gateway/serve.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { EventEmitter } from "node:events";
import type { Server } from "node:http";

import { describe, expect, it, vi } from "vitest";

import {
assertVoiceGatewayEnabled,
runVoiceGatewayServe,
validateOpenClawGatewayUrl,
} 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",
sandbox: "demo-sandbox",
agent: "main",
};

describe("experimental voice gateway service gate", () => {
it.each([undefined, "", "0", "true", "01"])("rejects feature value %s", (value) => {
expect(() =>
assertVoiceGatewayEnabled({
NEMOCLAW_EXPERIMENTAL_VOICE_GATEWAY: value,
}),
).toThrow("disabled");
});

it("checks the exact feature gate before reading either credential (#8378)", async () => {
const readBearerFile = vi.fn();
const createServer = vi.fn();

await expect(
runVoiceGatewayServe(OPTIONS, {
env: {
NEMOCLAW_EXPERIMENTAL_OTHER_CAPABILITY: "1",
NEMOCLAW_EXPERIMENTAL_VOICE_GATEWAY: "0",
},
readBearerFile,
createServer,
}),
).rejects.toThrow("disabled");
expect(readBearerFile).not.toHaveBeenCalled();
expect(createServer).not.toHaveBeenCalled();
});
});

describe("voice gateway destination validation", () => {
it("accepts only a fixed credential-free loopback WebSocket URL", () => {
expect(validateOpenClawGatewayUrl("ws://127.0.0.1:18789/ws")).toBe("ws://127.0.0.1:18789/ws");
});

it.each([
"wss://127.0.0.1:18789/ws",
"ws://localhost:18789/ws",
"ws://10.0.0.2:18789/ws",
"ws://user:secret@127.0.0.1:18789/ws",
"ws://127.0.0.1:18789/other",
"ws://127.0.0.1:18789/ws?target=other",
"ws://127.0.0.1/ws",
])("rejects untrusted or ambiguous destination %s (#8378)", (value) => {
expect(() => validateOpenClawGatewayUrl(value)).toThrow("must be");
});
});

describe("voice gateway listener lifetime", () => {
it("binds loopback, logs only trusted labels, and closes on SIGTERM (#8378)", async () => {
class FakeServer extends EventEmitter {
listening = false;
listenArgs: unknown[] = [];

listen(...args: unknown[]): this {
this.listenArgs = args.slice(0, 2);
this.listening = true;
const callback = args.at(-1) as () => void;
callback();
return this;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

close(callback?: (error?: Error) => void): this {
this.listening = false;
callback?.();
return this;
}
}

const server = new FakeServer();
const processEvents = new EventEmitter();
const log = vi.fn();
const readBearerFile = vi
.fn()
.mockReturnValueOnce("deployment-secret")
.mockReturnValueOnce("openclaw-secret");
const createServer = vi.fn(() => server as unknown as Server);

const running = runVoiceGatewayServe(OPTIONS, {
env: { NEMOCLAW_EXPERIMENTAL_VOICE_GATEWAY: "1" },
readBearerFile,
createServer,
processEvents,
log,
});
await vi.waitFor(() => expect(log).toHaveBeenCalledTimes(1));

expect(server.listenArgs).toEqual([18800, "127.0.0.1"]);
expect(createServer).toHaveBeenCalledWith({
deploymentCredential: "deployment-secret",
service: expect.anything(),
});
expect(log).toHaveBeenCalledWith({
event: "voice_gateway",
state: "listening",
runtimeIdentity: "voiceclaw-local",
runtimeProfile: "voiceclaw-pinned",
sandbox: "demo-sandbox",
agent: "main",
});

processEvents.emit("SIGTERM");
await running;

expect(server.listening).toBe(false);
expect(log).toHaveBeenLastCalledWith(expect.objectContaining({ state: "stopped" }));
expect(JSON.stringify(log.mock.calls)).not.toContain("secret");
});
});
Loading
Loading