@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.
- package/dist/chat-state.d.ts +18 -1
- package/dist/chat-state.js +36 -1
- package/dist/commands.js +6 -1
- package/dist/index.js +37 -2
- package/dist/types.d.ts +11 -0
- package/package.json +1 -1
package/dist/chat-state.d.ts
CHANGED
|
@@ -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
|
-
|
|
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;
|
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;
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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