@mulmobridge/chat-service 0.1.6 → 1.0.0

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.
@@ -1,9 +1,19 @@
1
1
  // Lightweight atomic write — write to a sibling tmp file, then rename.
2
2
  //
3
- // Inlined in this package (not imported from the host app) so
4
- // @mulmobridge/chat-service stays dependency-free beyond protocol.
5
- // Same contract as the host's writeFileAtomic: readers always see
6
- // either the old file or the new file — never a half-written one.
3
+ // A DELIBERATE second implementation of `writeFileAtomic`, catalogued as such
4
+ // in `docs/shared-utils.md`. The canonical one is
5
+ // `@mulmoclaude/core/files` — but this package sits in the bridge tier, below
6
+ // core, so importing it would be an uphill edge; and the helper needs
7
+ // `node:fs`, which rules out the browser-safe `@mulmoclaude/common` leaf that
8
+ // `errorMessage` / `escapeHtml` live in. @mulmobridge/chat-service therefore
9
+ // stays dependency-free beyond protocol.
10
+ //
11
+ // This is a SMALLER contract, not a drifted one: no `mode`, no `uniqueTmp`.
12
+ // Same core guarantee as the canonical — readers always see either the old
13
+ // file or the new file, never a half-written one — but without the canonical's
14
+ // Windows AV/Search-Indexer rename-retry loop, so a Windows-hosted bridge can
15
+ // still surface an EPERM here. Fix bugs in the canonical first, then decide
16
+ // whether this copy needs the same change.
7
17
  import { writeFile, rename, unlink, mkdir } from "fs/promises";
8
18
  import path from "path";
9
19
  import { randomUUID } from "crypto";
@@ -22,6 +22,15 @@ export interface ChatStateStore {
22
22
  connectSession(transportId: string, externalChatId: string, chatSessionId: string, roleId?: string): Promise<TransportChatState | null>;
23
23
  generateSessionId(transportId: string, externalChatId: string): string;
24
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;
25
34
  export declare function createChatStateStore(opts: {
26
35
  transportsDir: string;
27
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;
@@ -58,6 +75,18 @@ export function createChatStateStore(opts) {
58
75
  return state;
59
76
  };
60
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;
@@ -1,4 +1,4 @@
1
- import type { BridgeSkillSummary, Role, SessionSummary } from "./types.js";
1
+ import type { BridgeSkillSummary, GetSessionHistoryFn, ListSessionsFn, Role } from "./types.js";
2
2
  import type { ChatStateStore, TransportChatState } from "./chat-state.js";
3
3
  export interface CommandResult {
4
4
  reply: string;
@@ -41,23 +41,8 @@ export declare function createCommandHandler(opts: {
41
41
  getRole: (roleId: string) => Role;
42
42
  resetChatState: ChatStateStore["resetChatState"];
43
43
  connectSession: ChatStateStore["connectSession"];
44
- listSessions?: (opts: {
45
- limit: number;
46
- offset: number;
47
- }) => Promise<{
48
- sessions: SessionSummary[];
49
- total: number;
50
- }>;
51
- getSessionHistory?: (sessionId: string, opts: {
52
- limit: number;
53
- offset: number;
54
- }) => Promise<{
55
- messages: Array<{
56
- source: string;
57
- text: string;
58
- }>;
59
- total: number;
60
- }>;
44
+ listSessions?: ListSessionsFn;
45
+ getSessionHistory?: GetSessionHistoryFn;
61
46
  /** Lists the skills the bridge command handler should expose.
62
47
  * Drives both the slash-command allowlist (only matching names
63
48
  * are forwarded to the agent) and the "Skills:" section in the
package/dist/index.d.ts CHANGED
@@ -16,5 +16,5 @@ export interface ChatService {
16
16
  pushToBridge: PushFn;
17
17
  }
18
18
  export declare function createChatService(deps: ChatServiceDeps): ChatService;
19
- export type { ChatServiceDeps, StartChatFn, OnSessionEventFn } from "./types.js";
19
+ export type { Attachment, ChatServiceDeps, StartChatFn, StartChatParams, OnSessionEventFn } from "./types.js";
20
20
  export { writeFileAtomic } from "./atomic-write.js";
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,6 +97,17 @@ export function createChatService(deps) {
97
97
  badRequest(res, "chatSessionId is required");
98
98
  return;
99
99
  }
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
+ }
100
111
  // Resolve the target session's role BEFORE calling connectSession so the
101
112
  // persisted state's `roleId` tracks the new session's role — otherwise the
102
113
  // next relay's `startChat` would resume the new session under the previous
package/dist/types.d.ts CHANGED
@@ -1,35 +1,20 @@
1
+ import type { Attachment } from "@mulmobridge/protocol";
2
+ export type { Attachment };
1
3
  export interface Role {
2
4
  id: string;
3
5
  name: string;
4
6
  }
7
+ /** Structurally identical to `StructuredLogger` in `@mulmoclaude/common`, where
8
+ * the rest of that family was folded (#2486). This one stays local: rule 2 only
9
+ * exempts a DECLARED package dependency, and this package's sole dependency is
10
+ * `@mulmobridge/protocol` — adding common would give a published
11
+ * `@mulmobridge/*` package its first `@mulmoclaude/*` edge, for a type. */
5
12
  export interface Logger {
6
13
  error(prefix: string, message: string, data?: Record<string, unknown>): void;
7
14
  warn(prefix: string, message: string, data?: Record<string, unknown>): void;
8
15
  info(prefix: string, message: string, data?: Record<string, unknown>): void;
9
16
  debug(prefix: string, message: string, data?: Record<string, unknown>): void;
10
17
  }
11
- /** A file attached to a bridge or UI message. Generic enough for
12
- * images, PDFs, documents, videos, etc. The server decides what to
13
- * do with each based on mimeType — images become vision content
14
- * blocks, unsupported types are ignored with a log.
15
- *
16
- * Either `data` (inline base64 bytes) or `path` (workspace-relative
17
- * path the server can read) MUST be set:
18
- *
19
- * - Bridges over the socket transport ship raw bytes, so they
20
- * populate `data` (and usually `mimeType`).
21
- * - The Vue UI uploads paste/drop and sidebar-pick files to disk
22
- * before sending and populates `path`; the server reads bytes
23
- * from disk and infers `mimeType` from the extension.
24
- *
25
- * Mirrors `@mulmobridge/protocol`'s `Attachment` (kept structurally
26
- * duplicated here per the package-contract rules in this file). */
27
- export interface Attachment {
28
- mimeType?: string;
29
- data?: string;
30
- path?: string;
31
- filename?: string;
32
- }
33
18
  export interface StartChatParams {
34
19
  message: string;
35
20
  roleId: string;
@@ -75,6 +60,25 @@ export interface SessionSummary {
75
60
  preview: string;
76
61
  updatedAt: string;
77
62
  }
63
+ /** List recent sessions (paged). Backs the `/sessions` bridge command. */
64
+ export type ListSessionsFn = (opts: {
65
+ limit: number;
66
+ offset: number;
67
+ }) => Promise<{
68
+ sessions: SessionSummary[];
69
+ total: number;
70
+ }>;
71
+ /** Recent messages in a session (paged, newest-first). Backs `/history`. */
72
+ export type GetSessionHistoryFn = (sessionId: string, opts: {
73
+ limit: number;
74
+ offset: number;
75
+ }) => Promise<{
76
+ messages: Array<{
77
+ source: string;
78
+ text: string;
79
+ }>;
80
+ total: number;
81
+ }>;
78
82
  export interface ChatServiceDeps {
79
83
  /** Relay a user turn into the agent loop. */
80
84
  startChat: StartChatFn;
@@ -101,27 +105,12 @@ export interface ChatServiceDeps {
101
105
  * Omit if session listing is not available (command will reply
102
106
  * "not available").
103
107
  */
104
- listSessions?: (opts: {
105
- limit: number;
106
- offset: number;
107
- }) => Promise<{
108
- sessions: SessionSummary[];
109
- total: number;
110
- }>;
108
+ listSessions?: ListSessionsFn;
111
109
  /**
112
110
  * Get recent messages from a session. Used by /history command.
113
111
  * Returns newest-first array of {source, text} pairs.
114
112
  */
115
- getSessionHistory?: (sessionId: string, opts: {
116
- limit: number;
117
- offset: number;
118
- }) => Promise<{
119
- messages: Array<{
120
- source: string;
121
- text: string;
122
- }>;
123
- total: number;
124
- }>;
113
+ getSessionHistory?: GetSessionHistoryFn;
125
114
  /**
126
115
  * Resolve the roleId a given session was started with. Used by the HTTP
127
116
  * `/connect` route so the persisted bridge state's role tracks the target
package/dist/types.js CHANGED
@@ -10,7 +10,10 @@
10
10
  // host app uses. They look like the real `Role` / `Logger` /
11
11
  // `StartChatParams` types so the same functions plug in, but
12
12
  // they are defined here so the package has no compile-time
13
- // link back to the host.
13
+ // link back to the host. Importing from a declared package
14
+ // dependency is fine — `Attachment` comes from
15
+ // `@mulmobridge/protocol` (already a runtime dep) rather than
16
+ // being redeclared here (#2488).
14
17
  // 3. When you add a new dependency, extend `ChatServiceDeps` and
15
18
  // thread it through the factory functions — do NOT reach out
16
19
  // to a module import. See #269 / #305 for the rationale.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mulmobridge/chat-service",
3
- "version": "0.1.6",
3
+ "version": "1.0.0",
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",
@@ -27,7 +27,7 @@
27
27
  "license": "MIT",
28
28
  "author": "Receptron Team",
29
29
  "dependencies": {
30
- "@mulmobridge/protocol": "^0.1.4"
30
+ "@mulmobridge/protocol": "^1.0.0"
31
31
  },
32
32
  "peerDependencies": {
33
33
  "express": "^5.0.0",