Skip to content
Merged
101 changes: 93 additions & 8 deletions src/lib/voice-gateway/session-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,16 +48,20 @@ function serviceFixture(
sessionLifetimeMs?: number;
turnTimeoutMs?: number;
maxResponseBytes?: number;
runtimeIdentity?: string;
runtimeProfile?: string;
sandbox?: string;
agent?: string;
} = {},
) {
const client = overrides.client ?? new FakeAgentClient();
const diagnostics: unknown[] = [];
const ids = [...(overrides.randomIds ?? ["voice-session", "agent-session", "turn", "response"])];
const ids = [...(overrides.randomIds ?? ["voice-session", "turn", "response"])];
const service = new VoiceSessionService({
runtimeIdentity: "voiceclaw-local",
runtimeProfile: "voiceclaw-pinned",
sandbox: "demo-sandbox",
agent: "main",
runtimeIdentity: overrides.runtimeIdentity ?? "voiceclaw-local",
runtimeProfile: overrides.runtimeProfile ?? "voiceclaw-pinned",
sandbox: overrides.sandbox ?? "demo-sandbox",
agent: overrides.agent ?? "main",
createClient: () => client,
diagnostic: (entry) => diagnostics.push(entry),
randomId: () => ids.shift() ?? "extra-id",
Expand All @@ -71,7 +75,7 @@ function serviceFixture(
}

describe("voice session and committed turn boundary", () => {
it("binds trusted configuration and generates internal agent, turn, and response identities (#8378)", async () => {
it("derives an internal agent session key from the trusted runtime binding (#9411)", async () => {
const { service, client } = serviceFixture();
const created = service.createSession("runtime-conversation");
const events: VoiceResponseEvent[] = [];
Expand All @@ -91,9 +95,10 @@ describe("voice session and committed turn boundary", () => {
{
idempotencyKey: "turn",
message: "repository status",
sessionKey: "agent:main:nemoclaw-voice:agent-session",
sessionKey: expect.stringMatching(/^agent:main:nemoclaw-voice:.+$/u),
},
]);
expect(client.calls[0]?.sessionKey).not.toContain("runtime-conversation");
expect(events).toEqual([
{
type: "response.started",
Expand All @@ -119,6 +124,86 @@ describe("voice session and committed turn boundary", () => {
service.closeAll();
});

it("reuses the derived agent session key across separate admissions for one binding (#9411)", async () => {
const { service, client } = serviceFixture({
randomIds: [
"voice-session-one",
"turn-one",
"response-one",
"voice-session-two",
"turn-two",
"response-two",
],
});

const first = service.createSession("runtime-conversation");
await service.commitTurn({
voiceSessionId: first.voiceSessionId,
grant: first.grant,
commitId: "runtime-commit-one",
text: "first question",
deliver: () => {},
deliveryOpen: () => true,
});
service.closeSession(first.voiceSessionId, first.grant);

const second = service.createSession("runtime-conversation");
await service.commitTurn({
voiceSessionId: second.voiceSessionId,
grant: second.grant,
commitId: "runtime-commit-two",
text: "second question",
deliver: () => {},
deliveryOpen: () => true,
});

expect(client.calls).toHaveLength(2);
expect(client.calls[1]?.sessionKey).toBe(client.calls[0]?.sessionKey);
service.closeAll();
});

it("isolates agent session keys when a runtime binding value changes (#9411)", async () => {
async function sessionKeyFor(options: {
runtimeConversationId?: string;
runtimeIdentity?: string;
runtimeProfile?: string;
sandbox?: string;
agent?: string;
}): Promise<string> {
const { service, client } = serviceFixture({
...(options.runtimeIdentity ? { runtimeIdentity: options.runtimeIdentity } : {}),
...(options.runtimeProfile ? { runtimeProfile: options.runtimeProfile } : {}),
...(options.sandbox ? { sandbox: options.sandbox } : {}),
...(options.agent ? { agent: options.agent } : {}),
});
const created = service.createSession(
options.runtimeConversationId ?? "runtime-conversation",
);
await service.commitTurn({
voiceSessionId: created.voiceSessionId,
grant: created.grant,
commitId: "runtime-commit",
text: "question",
deliver: () => {},
deliveryOpen: () => true,
});
service.closeAll();
return client.calls[0]?.sessionKey ?? "";
}

const keys = await Promise.all([
sessionKeyFor({}),
sessionKeyFor({ runtimeConversationId: "other-conversation" }),
sessionKeyFor({ runtimeIdentity: "voiceclaw-other" }),
sessionKeyFor({ runtimeProfile: "voiceclaw-other" }),
sessionKeyFor({ sandbox: "other-sandbox" }),
sessionKeyFor({ agent: "secondary" }),
]);

expect(keys.every((key) => key.length > 0)).toBe(true);
expect(new Set(keys).size).toBe(keys.length);
});

it("rejects duplicate and overlapping runtime commit IDs without another invocation (#8378)", async () => {
const client = new FakeAgentClient();
let resolveRun: (value: { outcome: "completed" }) => void = () => {};
Expand All @@ -130,7 +215,7 @@ describe("voice session and committed turn boundary", () => {
};
const { service } = serviceFixture({
client,
randomIds: ["voice-session", "agent-session", "turn", "response"],
randomIds: ["voice-session", "turn", "response"],
});
const created = service.createSession("runtime-conversation");
const first = service.commitTurn({
Expand Down
23 changes: 22 additions & 1 deletion src/lib/voice-gateway/session-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,27 @@ function grantMatches(value: string, expectedHash: Buffer): boolean {
return timingSafeEqual(hashBearer(value), expectedHash);
}

function deriveAgentSessionKey(
options: Pick<
VoiceSessionServiceOptions,
"agent" | "runtimeIdentity" | "runtimeProfile" | "sandbox"
>,
runtimeConversationId: string,
): string {
const binding = JSON.stringify([
options.agent,
options.runtimeProfile,
options.runtimeIdentity,
options.sandbox,
runtimeConversationId,
]);
const bindingHash = createHash("sha256")
.update(binding)
.digest("base64url")
.toLowerCase();
return `agent:${options.agent}:nemoclaw-voice:${bindingHash}`;
}

/** Owns one runtime-neutral voice session and its single committed turn. */
export class VoiceSessionService {
private readonly options: Required<
Expand Down Expand Up @@ -113,7 +134,7 @@ export class VoiceSessionService {

const now = this.options.now();
const voiceSessionId = this.options.randomId();
const agentSessionKey = `agent:${this.options.agent}:nemoclaw-voice:${this.options.randomId()}`;
const agentSessionKey = deriveAgentSessionKey(this.options, runtimeConversationId);
const grant = this.options.randomGrant().toString("base64url");
const expiresAt = now + this.options.sessionLifetimeMs;
const session: ActiveSession = {
Expand Down
28 changes: 25 additions & 3 deletions test/fixtures/voice-gateway/pinned-openclaw-gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ interface SentRequest {
readonly params: Record<string, unknown>;
}

/** Emits chat events for pinned OpenClaw v2026.7.1, including a repeated final sequence. */
/** Emits pinned OpenClaw v2026.7.1 chat events for gateway integration tests. */
export class PinnedOpenClawGateway {
onopen: (() => void) | null = null;
onmessage: ((event: { readonly data: unknown }) => void) | null = null;
Expand All @@ -17,7 +17,7 @@ export class PinnedOpenClawGateway {
readonly sent: SentRequest[] = [];
closed = false;

constructor() {
constructor(private readonly conversationContext?: Map<string, string>) {
queueMicrotask(() => this.onopen?.());
}

Expand Down Expand Up @@ -45,7 +45,14 @@ export class PinnedOpenClawGateway {
this.respond(request.id, {});
} else if (request.method === "chat.send") {
this.respond(request.id, { runId: "pinned-openclaw-run" });
queueMicrotask(() => this.emitRecoveredTurn(String(request.params.sessionKey)));
queueMicrotask(() => {
const sessionKey = String(request.params.sessionKey);
if (this.conversationContext) {
this.emitContextTurn(sessionKey, String(request.params.message));
} else {
this.emitRecoveredTurn(sessionKey);
}
});
}
});
}
Expand Down Expand Up @@ -90,6 +97,21 @@ export class PinnedOpenClawGateway {
});
}

private emitContextTurn(sessionKey: string, message: string): void {
let response = this.conversationContext?.get(sessionKey) ?? "I do not know.";
if (message === "My project name is Apollo.") {
response = "I will remember Apollo.";
this.conversationContext?.set(sessionKey, "Apollo");
}
this.chat({
sessionKey,
runId: "pinned-openclaw-run",
seq: 1,
state: "final",
message: this.assistantMessage(response),
});
}

private chat(payload: Record<string, unknown>): void {
this.onmessage?.({
data: JSON.stringify({
Expand Down
121 changes: 109 additions & 12 deletions test/voice-gateway-integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ describe("experimental voice gateway composed boundary", () => {
it("recovers an omitted delta when a final event repeats the last sequence (#9243)", async () => {
let pinnedOpenClaw: PinnedOpenClawGateway | undefined;
const diagnostics: object[] = [];
const ids = ["voice-session", "agent-session", "turn", "response"];
const ids = ["voice-session", "turn", "response"];
const service = new VoiceSessionService({
runtimeIdentity: "voiceclaw-local",
runtimeProfile: "voiceclaw-pinned",
Expand Down Expand Up @@ -234,7 +234,7 @@ describe("experimental voice gateway composed boundary", () => {

it("routes one committed turn into the pinned runtime output without exposing OpenClaw authority (#8378)", async () => {
const fakeOpenClaw = new FakeOpenClawGatewayClient(OPENCLAW_CREDENTIAL);
const ids = ["voice-session", "agent-session", "turn", "response"];
const ids = ["voice-session", "turn", "response"];
const service = new VoiceSessionService({
runtimeIdentity: "voiceclaw-local",
runtimeProfile: "voiceclaw-pinned",
Expand Down Expand Up @@ -263,7 +263,7 @@ describe("experimental voice gateway composed boundary", () => {
{
idempotencyKey: "turn",
message: "repository status",
sessionKey: "agent:main:nemoclaw-voice:agent-session",
sessionKey: expect.stringMatching(/^agent:main:nemoclaw-voice:.+$/u),
credential: OPENCLAW_CREDENTIAL,
},
]);
Expand All @@ -280,7 +280,86 @@ describe("experimental voice gateway composed boundary", () => {
expect(fakeOpenClaw.closed).toBe(true);
});

it("authenticates before admission or turn parsing and rejects runtime-selected authority (#8378)", async () => {
it("preserves agent context across separate admissions for one runtime conversation (#9411)", async () => {
const context = new Map<string, string>();
const pinnedOpenClaws: PinnedOpenClawGateway[] = [];
const ids = [
"voice-session-one",
"turn-one",
"response-one",
"voice-session-two",
"turn-two",
"response-two",
"voice-session-three",
"turn-three",
"response-three",
];
const service = new VoiceSessionService({
runtimeIdentity: "voiceclaw-local",
runtimeProfile: "voiceclaw-pinned",
sandbox: "repository-fixture",
agent: "main",
createClient: () =>
new OpenClawVoiceClient({
gatewayUrl: "ws://127.0.0.1:18789/ws",
credential: OPENCLAW_CREDENTIAL,
webSocketFactory: () => {
const pinnedOpenClaw = new PinnedOpenClawGateway(context);
pinnedOpenClaws.push(pinnedOpenClaw);
return pinnedOpenClaw;
},
}),
randomId: () => ids.shift() ?? "extra",
randomGrant: () => Buffer.alloc(32, 9),
});
const port = await listen(
createVoiceGatewayServer({
deploymentCredential: DEPLOYMENT_BEARER,
service,
}),
);
const output: string[] = [];
const runtime = new PinnedVoiceRuntimeAdapter(port, DEPLOYMENT_BEARER, (text) =>
output.push(text),
);

const first = await runtime.createSession("voice-call-one");
const firstEvents = await runtime.commitTurn(
first,
"runtime-commit-one",
"My project name is Apollo.",
);
await runtime.closeSession(first);
const second = await runtime.createSession("voice-call-one");
const secondEvents = await runtime.commitTurn(
second,
"runtime-commit-two",
"What is my project name?",
);
await runtime.closeSession(second);
const third = await runtime.createSession("voice-call-two");
const thirdEvents = await runtime.commitTurn(
third,
"runtime-commit-three",
"What is my project name?",
);
await runtime.closeSession(third);

expect(output).toEqual(["I will remember Apollo.", "Apollo", "I do not know."]);
const sessionKeys = pinnedOpenClaws.map((gateway) => {
const request = gateway.sent.find((entry) => entry.method === "chat.send");
return String(request?.params.sessionKey ?? "");
});
expect(sessionKeys).toHaveLength(3);
expect(sessionKeys[1]).toBe(sessionKeys[0]);
expect(sessionKeys[2]).not.toBe(sessionKeys[0]);
expect(pinnedOpenClaws.every((gateway) => gateway.closed)).toBe(true);
expect(
JSON.stringify({ first, firstEvents, second, secondEvents, third, thirdEvents }),
).not.toContain("nemoclaw-voice");
});

it("authenticates before admission parsing and rejects invalid or runtime-selected authority (#9411)", async () => {
const fakeOpenClaw = new FakeOpenClawGatewayClient(OPENCLAW_CREDENTIAL);
let clientsCreated = 0;
const service = new VoiceSessionService({
Expand Down Expand Up @@ -312,18 +391,36 @@ describe("experimental voice gateway composed boundary", () => {
body: '{"error":"authentication_failed"}',
});

const override = await requestJson({
port,
method: "POST",
path: "/v1/voice/sessions",
bearer: DEPLOYMENT_BEARER,
body: {
const invalidAdmissions = [
{ runtimeConversationId: "../namespace-escape" },
{ runtimeConversationId: "x".repeat(129) },
{
runtimeConversationId: "runtime-conversation",
sessionKey: "agent:main:nemoclaw-voice:runtime-selected",
},
{
runtimeConversationId: "runtime-conversation",
agent: "runtime-selected",
gatewayUrl: "ws://attacker.invalid/ws",
},
});
expect(override.status).toBe(400);
];
const rejectedAdmissions = await Promise.all(
invalidAdmissions.map((body) =>
requestJson({
port,
method: "POST",
path: "/v1/voice/sessions",
bearer: DEPLOYMENT_BEARER,
body,
}),
),
);
expect(rejectedAdmissions).toEqual([
{ status: 400, body: '{"error":"invalid_request"}' },
{ status: 400, body: '{"error":"invalid_request"}' },
{ status: 400, body: '{"error":"invalid_request"}' },
{ status: 400, body: '{"error":"invalid_request"}' },
]);
expect(clientsCreated).toBe(0);

const runtime = new PinnedVoiceRuntimeAdapter(port, DEPLOYMENT_BEARER, () => {});
Expand Down
Loading