@mulmobridge/chat-service 0.1.4 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,9 +11,26 @@ export interface ChatStateStore {
11
11
  getChatState(transportId: string, externalChatId: string): Promise<TransportChatState | null>;
12
12
  setChatState(transportId: string, state: TransportChatState): Promise<void>;
13
13
  resetChatState(transportId: string, externalChatId: string, roleId: string): Promise<TransportChatState>;
14
- connectSession(transportId: string, externalChatId: string, chatSessionId: string): Promise<TransportChatState | null>;
14
+ /** Repoint the persisted chat state at another session. `roleId` is
15
+ * optional: when omitted, the existing state's roleId is preserved
16
+ * (the old default, kept for HTTP `/connect` callers that only know
17
+ * the session ID). Callers that DO know the target session's role —
18
+ * notably the `/switch` command, which already has `SessionSummary.roleId`
19
+ * from `/sessions` — MUST pass it, otherwise the file-backed state
20
+ * drifts into a stale-role / new-session pair and the next relay's
21
+ * `startChat` uses the mismatched pair (issue #1888). */
22
+ connectSession(transportId: string, externalChatId: string, chatSessionId: string, roleId?: string): Promise<TransportChatState | null>;
15
23
  generateSessionId(transportId: string, externalChatId: string): string;
16
24
  }
25
+ /** True iff `sessionId` is safe to persist into transport state and later
26
+ * hand to session-metadata / event-log readers on the host side. Adds an
27
+ * explicit `..` rejection on top of `isSafeId` — the safe-id character
28
+ * class alone would accept the literal `..` and let a state file written
29
+ * by `/connect` poison downstream commands (e.g. `/history` reading the
30
+ * poisoned sessionId back through `readSessionJsonl`). Applied at the
31
+ * `/connect` route entry AND inside `connectSession` as defense-in-depth
32
+ * (issue #1896 follow-up to #1888 / #1895). */
33
+ export declare function isSafeSessionId(sessionId: string): boolean;
17
34
  export declare function createChatStateStore(opts: {
18
35
  transportsDir: string;
19
36
  logger: Logger;
@@ -15,6 +15,23 @@ import { writeFileAtomic } from "./atomic-write.js";
15
15
  function isSafeId(id) {
16
16
  return /^[\w.-]+$/.test(id) && id.length > 0 && id.length <= 200;
17
17
  }
18
+ /** True iff `sessionId` is safe to persist into transport state and later
19
+ * hand to session-metadata / event-log readers on the host side. Adds an
20
+ * explicit `..` rejection on top of `isSafeId` — the safe-id character
21
+ * class alone would accept the literal `..` and let a state file written
22
+ * by `/connect` poison downstream commands (e.g. `/history` reading the
23
+ * poisoned sessionId back through `readSessionJsonl`). Applied at the
24
+ * `/connect` route entry AND inside `connectSession` as defense-in-depth
25
+ * (issue #1896 follow-up to #1888 / #1895). */
26
+ export function isSafeSessionId(sessionId) {
27
+ if (typeof sessionId !== "string")
28
+ return false;
29
+ if (!isSafeId(sessionId))
30
+ return false;
31
+ if (sessionId.includes(".."))
32
+ return false;
33
+ return true;
34
+ }
18
35
  // ── Factory ──────────────────────────────────────────────────
19
36
  export function createChatStateStore(opts) {
20
37
  const { transportsDir, logger } = opts;
@@ -57,13 +74,30 @@ export function createChatStateStore(opts) {
57
74
  });
58
75
  return state;
59
76
  };
60
- const connectSession = async (transportId, externalChatId, chatSessionId) => {
77
+ const connectSession = async (transportId, externalChatId, chatSessionId, roleId) => {
78
+ // Defense-in-depth: even though the /connect route validates chatSessionId
79
+ // at entry, refuse to persist an unsafe value here too. Otherwise a caller
80
+ // that bypasses the route (test harness, alternate transport, direct store
81
+ // access) could still write a hostile sessionId into the state file — and
82
+ // downstream commands like /history would later read that back into
83
+ // path-traversing filesystem operations. Return null so the route surfaces
84
+ // it as 404 (same as "no state for this chat"); either way the caller
85
+ // can't succeed with a hostile input. Issue #1896.
86
+ if (!isSafeSessionId(chatSessionId)) {
87
+ logger.warn("chat-state", "refused to connect unsafe sessionId", { transportId, externalChatId });
88
+ return null;
89
+ }
61
90
  const existing = await getChatState(transportId, externalChatId);
62
91
  if (!existing)
63
92
  return null;
64
93
  const updated = {
65
94
  ...existing,
66
95
  sessionId: chatSessionId,
96
+ // A missing `roleId` arg means "preserve the current role" (that's
97
+ // the HTTP `/connect` route, which doesn't know the target's role);
98
+ // when the caller passes one (`/switch`), take theirs so state and
99
+ // downstream `startChat` agree on which role runs the resumed session.
100
+ ...(roleId !== undefined ? { roleId } : {}),
67
101
  updatedAt: new Date().toISOString(),
68
102
  };
69
103
  await setChatState(transportId, updated);
@@ -71,6 +105,7 @@ export function createChatStateStore(opts) {
71
105
  transportId,
72
106
  externalChatId,
73
107
  sessionId: chatSessionId,
108
+ roleId: updated.roleId,
74
109
  });
75
110
  return updated;
76
111
  };
package/dist/commands.js CHANGED
@@ -211,7 +211,12 @@ export function createCommandHandler(opts) {
211
211
  };
212
212
  }
213
213
  }
214
- const updated = await connectSession(transportId, chatState.externalChatId, target.id);
214
+ // Pass `target.roleId` so the persisted state's role tracks the session
215
+ // we just repointed at. Without this, `connectSession` would swap the
216
+ // sessionId but leave the previous role in place, and the next relay's
217
+ // `startChat` would run the resumed session under the wrong role
218
+ // (issue #1888).
219
+ const updated = await connectSession(transportId, chatState.externalChatId, target.id, target.roleId);
215
220
  if (!updated) {
216
221
  return { reply: "Failed to switch session." };
217
222
  }
package/dist/index.js CHANGED
@@ -17,7 +17,7 @@
17
17
  // standalone npm package without internal edits. See #269 / #305.
18
18
  import { Router } from "express";
19
19
  import { CHAT_SERVICE_ROUTES } from "@mulmobridge/protocol";
20
- import { createChatStateStore } from "./chat-state.js";
20
+ import { createChatStateStore, isSafeSessionId } from "./chat-state.js";
21
21
  import { createCommandHandler } from "./commands.js";
22
22
  import { createRelay } from "./relay.js";
23
23
  import { createPushQueue } from "./push-queue.js";
@@ -97,7 +97,42 @@ export function createChatService(deps) {
97
97
  badRequest(res, "chatSessionId is required");
98
98
  return;
99
99
  }
100
- const updated = await store.connectSession(transportId, externalChatId, chatSessionId);
100
+ // Reject hostile / malformed sessionIds at the entry so they can't be
101
+ // persisted into transport state. Without this gate a caller could POST
102
+ // `{"chatSessionId": "../../etc/x"}`, the value would land in the state
103
+ // file, and a later `/history` command would read it back and hand it to
104
+ // `readSessionJsonl` — whose backing reader is documented as "internal
105
+ // fixed paths only, no `..` traversal guard". Also defended inside
106
+ // `connectSession` for defense-in-depth (issue #1896 follow-up to #1895).
107
+ if (!isSafeSessionId(chatSessionId)) {
108
+ badRequest(res, "chatSessionId has an unsafe format");
109
+ return;
110
+ }
111
+ // Resolve the target session's role BEFORE calling connectSession so the
112
+ // persisted state's `roleId` tracks the new session's role — otherwise the
113
+ // next relay's `startChat` would resume the new session under the previous
114
+ // role (#1888 / #1894). Three fallback paths all treated as "preserve
115
+ // existing role":
116
+ // 1. No `getSessionRole` wired at all (backward compat for older hosts).
117
+ // 2. Resolver returns null (unknown / corrupt session metadata).
118
+ // 3. Resolver throws (host bug / timeout / IO error) — catch here so
119
+ // the route can never bubble the failure as a 500 to the API caller
120
+ // (codex review on #1895; the MulmoClaude host's resolver is
121
+ // hardened but the DI contract doesn't require hosts to be).
122
+ let resolvedRole = null;
123
+ if (deps.getSessionRole) {
124
+ try {
125
+ resolvedRole = await deps.getSessionRole(chatSessionId);
126
+ }
127
+ catch (err) {
128
+ logger.warn("chat-service", "getSessionRole threw; falling back to preserving existing role", {
129
+ chatSessionId,
130
+ error: err instanceof Error ? err.message : String(err),
131
+ });
132
+ resolvedRole = null;
133
+ }
134
+ }
135
+ const updated = await store.connectSession(transportId, externalChatId, chatSessionId, resolvedRole ?? undefined);
101
136
  if (!updated) {
102
137
  notFound(res, "No chat state found for this transport");
103
138
  return;
package/dist/types.d.ts CHANGED
@@ -122,6 +122,17 @@ export interface ChatServiceDeps {
122
122
  }>;
123
123
  total: number;
124
124
  }>;
125
+ /**
126
+ * Resolve the roleId a given session was started with. Used by the HTTP
127
+ * `/connect` route so the persisted bridge state's role tracks the target
128
+ * session's role after a repoint — same drift-fix as bridge `/switch`
129
+ * (issue #1888 / #1894), but for API callers that only supply a session ID.
130
+ * Returns null when the session isn't found OR when its role isn't known;
131
+ * on null the route falls back to the previous "preserve current role"
132
+ * behaviour (safe default). Omit this dep entirely to keep the old
133
+ * session-id-only semantics.
134
+ */
135
+ getSessionRole?: (sessionId: string) => Promise<string | null>;
125
136
  /**
126
137
  * Return the skills the bridge command handler should expose. The
127
138
  * handler uses the result for two things:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mulmobridge/chat-service",
3
- "version": "0.1.4",
3
+ "version": "0.1.7",
4
4
  "description": "Server-side chat service for MulmoBridge — socket.io + REST bridge to Claude Code agents",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",