Skip to content

Bare API /new creates an unreachable session binding #503

Description

@MicroMilo

Summary

Both transports create an s_ binding but return no X-Hermes-Session-Id value. Exact and wildcard CORS expose the header name but not a value; a headerless continuation generates a different chat ID and uses :general.

Expected behavior

A successful headerless buffered or streaming /new acknowledgement should expose the chat ID used for the new mapper binding so a continuation can reach that session.

Actual behavior

Both transports create an s_ binding but return no X-Hermes-Session-Id value. Exact and wildcard CORS expose the header name but not a value; a headerless continuation generates a different chat ID and uses :general.

Impact

Callers cannot continue the session that the successful /new response created.

Reproduction

POST a bare /new command without X-Hermes-Session-Id in both buffered and streaming modes. Record the newly allocated mapper chat ID, response headers, and the chat ID used by a headerless continuation. A successful reset must expose the allocated identifier; the observed result is an s_ binding with no header value, followed by a continuation on a different chat ID and :general session.

Minimal reproduction script

From the repository root, save this as repro_api_new_session_header.mts and run:

pnpm install --frozen-lockfile
pnpm exec tsx repro_api_new_session_header.mts
import { Readable } from "node:stream";
import { ApiServerChannel } from "./src/adapters/channel/api-server/ApiServerChannel.js";
import type { Gateway, GatewayEvent, GatewaySubmitTurnInput } from "./src/gateway/index.js";

type Call = Pick<GatewaySubmitTurnInput, "sessionKey" | "channelKey" | "message">;

const calls: Call[] = [];

const gateway = {
  submitTurn(input: GatewaySubmitTurnInput): AsyncIterable<GatewayEvent> {
    calls.push({
      sessionKey: input.sessionKey,
      channelKey: input.channelKey,
      message: input.message,
    });
    return (async function* (): AsyncGenerator<GatewayEvent> {
      yield { type: "assistant_text_delta", text: "fixture reply" };
      yield {
        type: "turn_completed",
        usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 },
        finishReason: "completed",
      };
    })();
  },
} as unknown as Gateway;

function redact(value: string): string {
  return value
    .replace(/api-[0-9a-f-]{36}/g, "api-<uuid>")
    .replace(/s_[0-9a-f-]{36}/g, "s_<uuid>");
}

class FakeResponse {
  statusCode = 200;
  readonly headers = new Map<string, string>();
  private readonly chunks: string[] = [];

  setHeader(name: string, value: string): void {
    this.headers.set(name.toLowerCase(), String(value));
  }

  write(chunk: string): boolean {
    this.chunks.push(String(chunk));
    return true;
  }

  flushHeaders(): void {}

  end(chunk?: string): void {
    if (chunk !== undefined) this.chunks.push(String(chunk));
  }

  get body(): string {
    return this.chunks.join("");
  }
}

function summarizeJsonCompletion(body: string): Record<string, unknown> {
  const parsed = JSON.parse(body) as {
    object?: unknown;
    model?: unknown;
    choices?: Array<{ message?: { role?: unknown; content?: unknown }; finish_reason?: unknown }>;
  };
  return {
    object: parsed.object,
    model: parsed.model,
    assistantRole: parsed.choices?.[0]?.message?.role,
    assistantContent: parsed.choices?.[0]?.message?.content,
    finishReason: parsed.choices?.[0]?.finish_reason,
  };
}

const channel = new ApiServerChannel({ host: "127.0.0.1", port: 0, modelName: "fixture-model" });
(channel as unknown as { gateway: Gateway }).gateway = gateway;

async function post(content: string, sessionId?: string, streaming = false) {
  const headers: Record<string, string> = {
    host: "fixture.invalid",
    "content-type": "application/json",
  };
  if (sessionId) headers["x-hermes-session-id"] = sessionId;

  // Socket binding is unavailable in the sandbox (listen returns EPERM), so this
  // replays the exact /v1/chat/completions route with stream-compatible fakes.
  const request = Readable.from([Buffer.from(JSON.stringify({
    model: "fixture-model",
    messages: [{ role: "user", content }],
    stream: streaming,
  }))]) as Readable & { method?: string; url?: string; headers: Record<string, string> };
  request.method = "POST";
  request.url = "/v1/chat/completions";
  request.headers = headers;

  const response = new FakeResponse();
  await (channel as unknown as {
    handleRequest(req: unknown, res: FakeResponse): Promise<void>;
  }).handleRequest(request, response);
  return {
    status: response.statusCode,
    sessionHeader: response.headers.get("x-hermes-session-id") ?? null,
    body: response.body,
  };
}

const newAck = await post("/new");
const stateAfterNew = (channel as unknown as {
  mapper: { snapshot(): { activeByChatId: Record<string, string> } };
}).mapper.snapshot();
const continuation = await post("continuation without returned session header");
const stateAfterContinuation = (channel as unknown as {
  mapper: { snapshot(): { activeByChatId: Record<string, string> } };
}).mapper.snapshot();
const streamingNewAck = await post("/new", undefined, true);
const stateAfterStreamingNew = (channel as unknown as {
  mapper: { snapshot(): { activeByChatId: Record<string, string> } };
}).mapper.snapshot();

const mappedEntries = Object.entries(stateAfterNew.activeByChatId);
const continuationCall = calls.find((call) => call.message === "continuation without returned session header");
const mappedSessionKey = mappedEntries[0]?.[1] ?? "";
const continuationSessionKey = continuationCall?.sessionKey ?? "";

console.log(JSON.stringify({
  fixture: "api-server-route-handler-new-session-header",
  transport: "in-memory IncomingMessage/ServerResponse equivalent",
  endpoint: "POST /v1/chat/completions",
  input: {
    firstMessage: "/new",
    secondMessage: "continuation without returned session header",
    firstRequestSessionHeader: null,
    secondRequestSessionHeader: null,
  },
  newAck: {
    status: newAck.status,
    hasSessionHeader: newAck.sessionHeader !== null,
    bodySummary: summarizeJsonCompletion(newAck.body),
  },
  streamingNewAck: {
    status: streamingNewAck.status,
    hasSessionHeader: streamingNewAck.sessionHeader !== null,
    containsAck: streamingNewAck.body.includes("已创建新会话。"),
    containsDone: streamingNewAck.body.includes("data: [DONE]"),
  },
  continuation: {
    status: continuation.status,
    hasSessionHeader: continuation.sessionHeader !== null,
    bodySummary: summarizeJsonCompletion(continuation.body),
  },
  mapper: {
    entriesAfterNew: mappedEntries.length,
    entriesAfterContinuation: Object.keys(stateAfterContinuation.activeByChatId).length,
    entriesAfterStreamingNew: Object.keys(stateAfterStreamingNew.activeByChatId).length,
    newSessionKeyCreated: /^api_server:chat=api-.*:s_/.test(mappedSessionKey),
    continuationUsedMappedSession: continuationSessionKey === mappedSessionKey,
    continuationSessionKey: redact(continuationSessionKey),
    mappedSessionKey: redact(mappedSessionKey),
  },
  gatewayCalls: calls.map((call) => ({
    ...call,
    sessionKey: redact(call.sessionKey),
  })),
}));

Relevant source locations

  • src/adapters/channel/api-server/ApiServerChannel.ts:238-264
  • src/adapters/channel/api-server/ApiServerChannel.ts:303-307
  • src/adapters/channel/api-server/ApiServerChannel.ts:404-405
  • src/adapters/channel/api-server/ApiServerSessionMapper.ts:13-33

Suggested direction

Make the external-input path establish one durable, identity-bound state/receipt before returning success; propagate explicit terminal outcomes to every channel and client; and add a regression test for the reproduced boundary.

This report is about functional behavior, not security. The reproduction uses deterministic in-memory or isolated fixtures and contains no credentials or private data.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions