@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.
- package/dist/atomic-write.js +14 -4
- package/dist/chat-state.d.ts +9 -0
- package/dist/chat-state.js +29 -0
- package/dist/commands.d.ts +3 -18
- package/dist/index.d.ts +1 -1
- package/dist/index.js +12 -1
- package/dist/types.d.ts +28 -39
- package/dist/types.js +4 -1
- package/package.json +2 -2
package/dist/atomic-write.js
CHANGED
|
@@ -1,9 +1,19 @@
|
|
|
1
1
|
// Lightweight atomic write — write to a sibling tmp file, then rename.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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";
|
package/dist/chat-state.d.ts
CHANGED
|
@@ -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;
|
package/dist/chat-state.js
CHANGED
|
@@ -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;
|
package/dist/commands.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { BridgeSkillSummary,
|
|
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?:
|
|
45
|
-
|
|
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?:
|
|
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?:
|
|
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.
|
|
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.
|
|
30
|
+
"@mulmobridge/protocol": "^1.0.0"
|
|
31
31
|
},
|
|
32
32
|
"peerDependencies": {
|
|
33
33
|
"express": "^5.0.0",
|