OpenBMB / OpenBMB/PilotDeck

Bare API /new creates an unreachable session binding

Open
#503 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
TypeScript
Stars
4k
Forks
453
Avg merge
12h 30m
Merged PRs (30d)
46

Description

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.

Contributor guide

No contributing guide indexed for this repository

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Read src/adapters/channel/api-server/ApiServerChannel.ts at lines 238-264, 303-307, and 404-405, along with ApiServerSessionMapper.ts:13-33. Run the supplied in-memory reproduction with pnpm exec tsx repro_api_new_session_header.mts and trace both buffered and streaming POST /v1/chat/completions handling. Done means both successful /new responses expose the allocated X-Hermes-Session-Id, headerless continuation uses that mapper session, and a regression test covers the boundary.

Written by the indexing model from the issue text.

Assessment

Tech stack
typescript
Domain
api, backend
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
68/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.