@mulmobridge/chat-service 0.1.3 → 0.1.6

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,7 +11,15 @@ 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
  }
17
25
  export declare function createChatStateStore(opts: {
@@ -57,13 +57,18 @@ export function createChatStateStore(opts) {
57
57
  });
58
58
  return state;
59
59
  };
60
- const connectSession = async (transportId, externalChatId, chatSessionId) => {
60
+ const connectSession = async (transportId, externalChatId, chatSessionId, roleId) => {
61
61
  const existing = await getChatState(transportId, externalChatId);
62
62
  if (!existing)
63
63
  return null;
64
64
  const updated = {
65
65
  ...existing,
66
66
  sessionId: chatSessionId,
67
+ // A missing `roleId` arg means "preserve the current role" (that's
68
+ // the HTTP `/connect` route, which doesn't know the target's role);
69
+ // when the caller passes one (`/switch`), take theirs so state and
70
+ // downstream `startChat` agree on which role runs the resumed session.
71
+ ...(roleId !== undefined ? { roleId } : {}),
67
72
  updatedAt: new Date().toISOString(),
68
73
  };
69
74
  await setChatState(transportId, updated);
@@ -71,6 +76,7 @@ export function createChatStateStore(opts) {
71
76
  transportId,
72
77
  externalChatId,
73
78
  sessionId: chatSessionId,
79
+ roleId: updated.roleId,
74
80
  });
75
81
  return updated;
76
82
  };
@@ -10,6 +10,32 @@ export interface CommandResult {
10
10
  forwardAs?: string;
11
11
  }
12
12
  export type CommandHandler = (text: string, transportId: string, chatState: TransportChatState) => Promise<CommandResult | null>;
13
+ /**
14
+ * Split a `//{skill} [args...]` shortcut into the skill name and the
15
+ * verbatim argument text.
16
+ *
17
+ * The separator between the skill name and args is the FIRST run of
18
+ * whitespace (a single space, multiple spaces, a tab, a CRLF — the
19
+ * bridge can't disambiguate "long separator" from "short separator
20
+ * plus leading whitespace in args", so the simple choice is to drop
21
+ * the whole leading run as one logical separator). Whitespace INSIDE
22
+ * the args (after the first run) is preserved verbatim, so doubled
23
+ * spaces, tabs, and newlines pasted into a multi-line prompt all
24
+ * survive.
25
+ *
26
+ * Exported for unit tests; callers in the handler use it directly.
27
+ *
28
+ * Examples:
29
+ * "//mag2" → { skillName: "mag2", argsVerbatim: "" }
30
+ * "//mag2 url" → { skillName: "mag2", argsVerbatim: "url" }
31
+ * "//mag2 url" → { skillName: "mag2", argsVerbatim: "url" }
32
+ * "//mag2\r\nurl" → { skillName: "mag2", argsVerbatim: "url" }
33
+ * "//mag2 a b\tc" → { skillName: "mag2", argsVerbatim: "a b\tc" }
34
+ */
35
+ export declare function parseSkillShortcut(text: string): {
36
+ skillName: string;
37
+ argsVerbatim: string;
38
+ };
13
39
  export declare function createCommandHandler(opts: {
14
40
  loadAllRoles: () => Role[];
15
41
  getRole: (roleId: string) => Role;
package/dist/commands.js CHANGED
@@ -22,6 +22,40 @@ function formatRelativeTime(isoDate) {
22
22
  const days = Math.floor(diffMs / ONE_DAY_MS);
23
23
  return `${days}d ago`;
24
24
  }
25
+ /**
26
+ * Split a `//{skill} [args...]` shortcut into the skill name and the
27
+ * verbatim argument text.
28
+ *
29
+ * The separator between the skill name and args is the FIRST run of
30
+ * whitespace (a single space, multiple spaces, a tab, a CRLF — the
31
+ * bridge can't disambiguate "long separator" from "short separator
32
+ * plus leading whitespace in args", so the simple choice is to drop
33
+ * the whole leading run as one logical separator). Whitespace INSIDE
34
+ * the args (after the first run) is preserved verbatim, so doubled
35
+ * spaces, tabs, and newlines pasted into a multi-line prompt all
36
+ * survive.
37
+ *
38
+ * Exported for unit tests; callers in the handler use it directly.
39
+ *
40
+ * Examples:
41
+ * "//mag2" → { skillName: "mag2", argsVerbatim: "" }
42
+ * "//mag2 url" → { skillName: "mag2", argsVerbatim: "url" }
43
+ * "//mag2 url" → { skillName: "mag2", argsVerbatim: "url" }
44
+ * "//mag2\r\nurl" → { skillName: "mag2", argsVerbatim: "url" }
45
+ * "//mag2 a b\tc" → { skillName: "mag2", argsVerbatim: "a b\tc" }
46
+ */
47
+ export function parseSkillShortcut(text) {
48
+ const sepIdx = text.search(/\s/);
49
+ if (sepIdx < 0)
50
+ return { skillName: text.slice(2), argsVerbatim: "" };
51
+ const head = text.slice(0, sepIdx);
52
+ // Strip the full leading whitespace run — \s in JS already matches
53
+ // \r, \n, \t, \v, \f, NBSP (U+00A0), and the rest of Unicode whitespace,
54
+ // so CRLF separators collapse to nothing instead of leaving an orphan
55
+ // \n in argsVerbatim (Codex review iter-1).
56
+ const argsVerbatim = text.slice(sepIdx).replace(/^\s+/, "");
57
+ return { skillName: head.slice(2), argsVerbatim };
58
+ }
25
59
  // ── Factory ──────────────────────────────────────────────────
26
60
  export function createCommandHandler(opts) {
27
61
  const { loadAllRoles, getRole, resetChatState, connectSession, listSessions, getSessionHistory, listRegisteredSkills } = opts;
@@ -177,7 +211,12 @@ export function createCommandHandler(opts) {
177
211
  };
178
212
  }
179
213
  }
180
- 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);
181
220
  if (!updated) {
182
221
  return { reply: "Failed to switch session." };
183
222
  }
@@ -224,15 +263,16 @@ export function createCommandHandler(opts) {
224
263
  return null;
225
264
  // `//{skill} [args...]` shortcut — start a new session AND run
226
265
  // the skill in one bridge turn. Args after the skill name are
227
- // forwarded verbatim, so `//mag2 https://x.com/post` resets and
228
- // runs `/mag2 https://x.com/post`.
266
+ // forwarded verbatim — split at the FIRST whitespace character
267
+ // and preserve everything after it (including doubled spaces /
268
+ // tabs) so payloads like a URL with internal whitespace, or
269
+ // multi-paragraph prompts, aren't silently rewritten.
229
270
  if (text.startsWith("//")) {
230
271
  const skills = await fetchSkills();
231
- const [head, ...rest] = text.split(/\s+/);
232
- const skillName = head.slice(2);
272
+ const { skillName, argsVerbatim } = parseSkillShortcut(text);
233
273
  if (skillName && skills.some((s) => s.name === skillName)) {
234
274
  const nextState = await resetChatState(transportId, chatState.externalChatId, chatState.roleId);
235
- const forwardAs = rest.length > 0 ? `/${skillName} ${rest.join(" ")}` : `/${skillName}`;
275
+ const forwardAs = argsVerbatim.length > 0 ? `/${skillName} ${argsVerbatim}` : `/${skillName}`;
236
276
  return {
237
277
  reply: `Session reset. Running ${forwardAs}`,
238
278
  nextState,
package/dist/index.js CHANGED
@@ -97,7 +97,31 @@ 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
+ // Resolve the target session's role BEFORE calling connectSession so the
101
+ // persisted state's `roleId` tracks the new session's role — otherwise the
102
+ // next relay's `startChat` would resume the new session under the previous
103
+ // role (#1888 / #1894). Three fallback paths all treated as "preserve
104
+ // existing role":
105
+ // 1. No `getSessionRole` wired at all (backward compat for older hosts).
106
+ // 2. Resolver returns null (unknown / corrupt session metadata).
107
+ // 3. Resolver throws (host bug / timeout / IO error) — catch here so
108
+ // the route can never bubble the failure as a 500 to the API caller
109
+ // (codex review on #1895; the MulmoClaude host's resolver is
110
+ // hardened but the DI contract doesn't require hosts to be).
111
+ let resolvedRole = null;
112
+ if (deps.getSessionRole) {
113
+ try {
114
+ resolvedRole = await deps.getSessionRole(chatSessionId);
115
+ }
116
+ catch (err) {
117
+ logger.warn("chat-service", "getSessionRole threw; falling back to preserving existing role", {
118
+ chatSessionId,
119
+ error: err instanceof Error ? err.message : String(err),
120
+ });
121
+ resolvedRole = null;
122
+ }
123
+ }
124
+ const updated = await store.connectSession(transportId, externalChatId, chatSessionId, resolvedRole ?? undefined);
101
125
  if (!updated) {
102
126
  notFound(res, "No chat state found for this transport");
103
127
  return;
@@ -0,0 +1,4 @@
1
+ export interface KeyedSerializer {
2
+ run<T>(key: string, task: () => Promise<T>): Promise<T>;
3
+ }
4
+ export declare function createKeyedSerializer(): KeyedSerializer;
@@ -0,0 +1,33 @@
1
+ // @package-contract — see ./types.ts
2
+ //
3
+ // Per-key serialization primitive. `run(key, task)` guarantees that
4
+ // for any single key, tasks execute one-at-a-time in call order;
5
+ // tasks under different keys run concurrently. The bridge relay uses
6
+ // it to serialize all turns for one external chat so two concurrent
7
+ // first messages can't each create a separate session (#1878).
8
+ //
9
+ // In-memory and DI-free, matching push-queue.ts: a single server
10
+ // process owns every relay turn. Each per-key chain entry is dropped
11
+ // once its tail settles, so the map doesn't grow without bound.
12
+ export function createKeyedSerializer() {
13
+ const tails = new Map();
14
+ return {
15
+ run(key, task) {
16
+ // `prev` is a swallowed tail that never rejects, so the next
17
+ // task always starts regardless of how the previous one settled.
18
+ const prev = tails.get(key) ?? Promise.resolve();
19
+ const result = prev.then(() => task());
20
+ // Swallow the outcome (so the chain never breaks), then drop the
21
+ // map entry — but only when no later task has chained onto this
22
+ // tail, so unrelated keys never leave stale entries behind.
23
+ const tail = result
24
+ .then(() => { }, () => { })
25
+ .then(() => {
26
+ if (tails.get(key) === tail)
27
+ tails.delete(key);
28
+ });
29
+ tails.set(key, tail);
30
+ return result;
31
+ },
32
+ };
33
+ }
package/dist/relay.js CHANGED
@@ -7,104 +7,114 @@
7
7
  // `createRelay(deps)` so the module has no direct imports from the
8
8
  // host.
9
9
  import { EVENT_TYPES } from "@mulmobridge/protocol";
10
+ import { createKeyedSerializer } from "./keyed-serializer.js";
10
11
  // ── Constants ────────────────────────────────────────────────
11
12
  const REPLY_TIMEOUT_MS = 5 * 60 * 1000;
12
13
  // ── Factory ──────────────────────────────────────────────────
13
14
  export function createRelay(deps) {
15
+ const serialize = createKeyedSerializer();
16
+ return function relayMessage(params) {
17
+ // Serialize every turn for one external chat. Without this, two
18
+ // concurrent first messages each read "no state", create separate
19
+ // sessions, and split the conversation across them (#1878). The
20
+ // JSON pair is an unambiguous composite key for the two ids.
21
+ const key = JSON.stringify([params.transportId, params.externalChatId]);
22
+ return serialize.run(key, () => processRelayMessage(deps, params));
23
+ };
24
+ }
25
+ async function processRelayMessage(deps, params) {
14
26
  const { store, handleCommand, startChat, onSessionEvent, getRole, defaultRoleId, logger } = deps;
15
- return async function relayMessage(params) {
16
- const { transportId, externalChatId, attachments, bridgeOptions } = params;
17
- let { text } = params;
18
- // Log attachment summary (count + mimeTypes) — NEVER log raw
19
- // base64 data (performance, log size, information leak risk).
20
- const attachmentSummary = attachments
21
- ? {
22
- count: attachments.length,
23
- mimeTypes: attachments.map((a) => a.mimeType),
24
- }
25
- : undefined;
26
- logger.info("chat-service", "message received", {
27
- transportId,
28
- externalChatId,
29
- textLength: text.length,
30
- ...(attachmentSummary ? { attachments: attachmentSummary } : {}),
31
- });
32
- let chatState = await store.getChatState(transportId, externalChatId);
33
- if (!chatState) {
34
- // Only on FIRST contact do we honour `bridgeOptions.defaultRole`
35
- // — once the session exists, whatever role the user / command
36
- // handler settled on is the source of truth. An unknown role
37
- // id silently falls back to the host-app default (we log it so
38
- // a typo in the bridge's env var is discoverable).
39
- const resolved = resolveDefaultRole(bridgeOptions, getRole, defaultRoleId, logger, transportId);
40
- chatState = await store.resetChatState(transportId, externalChatId, resolved);
41
- }
42
- const commandResult = await handleCommand(text, transportId, chatState);
43
- if (commandResult) {
44
- // `forwardAs` means "reset/mutate state AND continue into the
45
- // agent with rewritten text" (see //{skill} shortcut). Without
46
- // it, short-circuit with the canned reply.
47
- if (!commandResult.forwardAs) {
48
- return { kind: "ok", reply: commandResult.reply };
49
- }
50
- if (commandResult.nextState)
51
- chatState = commandResult.nextState;
52
- text = commandResult.forwardAs;
27
+ const { transportId, externalChatId, attachments, bridgeOptions } = params;
28
+ let { text } = params;
29
+ // Log attachment summary (count + mimeTypes) — NEVER log raw
30
+ // base64 data (performance, log size, information leak risk).
31
+ const attachmentSummary = attachments
32
+ ? {
33
+ count: attachments.length,
34
+ mimeTypes: attachments.map((a) => a.mimeType),
53
35
  }
54
- const result = await startChat({
55
- message: text,
56
- roleId: chatState.roleId,
57
- chatSessionId: chatState.sessionId,
58
- attachments,
59
- origin: "bridge",
60
- // Host app may use other keys (e.g. a future `defaultModel`);
61
- // we forward the whole bag untouched.
62
- bridgeOptions,
63
- });
64
- if (result.kind === "error") {
65
- const status = result.status ?? 500;
66
- if (status === 409) {
67
- // Session busy — tell the bridge to retry. Keep the HTTP
68
- // response shape the old handler returned (status 409 on
69
- // the HTTP side, "ok" reply text on the socket side — both
70
- // layers decide how to serialise).
71
- return {
72
- kind: "ok",
73
- reply: "A previous message is still being processed. Please wait.",
74
- };
75
- }
76
- logger.error("chat-service", "startChat failed", {
77
- transportId,
78
- externalChatId,
79
- error: result.error,
80
- });
81
- return {
82
- kind: "error",
83
- status,
84
- message: `Error: ${result.error}`,
85
- };
86
- }
87
- try {
88
- const reply = await collectAgentReply(onSessionEvent, chatState.sessionId, params.onChunk);
89
- await store.setChatState(transportId, {
90
- ...chatState,
91
- updatedAt: new Date().toISOString(),
92
- });
93
- return { kind: "ok", reply };
36
+ : undefined;
37
+ logger.info("chat-service", "message received", {
38
+ transportId,
39
+ externalChatId,
40
+ textLength: text.length,
41
+ ...(attachmentSummary ? { attachments: attachmentSummary } : {}),
42
+ });
43
+ let chatState = await store.getChatState(transportId, externalChatId);
44
+ if (!chatState) {
45
+ // Only on FIRST contact do we honour `bridgeOptions.defaultRole`
46
+ // — once the session exists, whatever role the user / command
47
+ // handler settled on is the source of truth. An unknown role
48
+ // id silently falls back to the host-app default (we log it so
49
+ // a typo in the bridge's env var is discoverable).
50
+ const resolved = resolveDefaultRole(bridgeOptions, getRole, defaultRoleId, logger, transportId);
51
+ chatState = await store.resetChatState(transportId, externalChatId, resolved);
52
+ }
53
+ const commandResult = await handleCommand(text, transportId, chatState);
54
+ if (commandResult) {
55
+ // `forwardAs` means "reset/mutate state AND continue into the
56
+ // agent with rewritten text" (see //{skill} shortcut). Without
57
+ // it, short-circuit with the canned reply.
58
+ if (!commandResult.forwardAs) {
59
+ return { kind: "ok", reply: commandResult.reply };
94
60
  }
95
- catch (err) {
96
- logger.error("chat-service", "reply collection failed", {
97
- transportId,
98
- externalChatId,
99
- error: String(err),
100
- });
61
+ if (commandResult.nextState)
62
+ chatState = commandResult.nextState;
63
+ text = commandResult.forwardAs;
64
+ }
65
+ const result = await startChat({
66
+ message: text,
67
+ roleId: chatState.roleId,
68
+ chatSessionId: chatState.sessionId,
69
+ attachments,
70
+ origin: "bridge",
71
+ // Host app may use other keys (e.g. a future `defaultModel`);
72
+ // we forward the whole bag untouched.
73
+ bridgeOptions,
74
+ });
75
+ if (result.kind === "error") {
76
+ const status = result.status ?? 500;
77
+ if (status === 409) {
78
+ // Session busy — tell the bridge to retry. Keep the HTTP
79
+ // response shape the old handler returned (status 409 on
80
+ // the HTTP side, "ok" reply text on the socket side — both
81
+ // layers decide how to serialise).
101
82
  return {
102
- kind: "error",
103
- status: 500,
104
- message: "Error: failed to collect agent reply",
83
+ kind: "ok",
84
+ reply: "A previous message is still being processed. Please wait.",
105
85
  };
106
86
  }
107
- };
87
+ logger.error("chat-service", "startChat failed", {
88
+ transportId,
89
+ externalChatId,
90
+ error: result.error,
91
+ });
92
+ return {
93
+ kind: "error",
94
+ status,
95
+ message: `Error: ${result.error}`,
96
+ };
97
+ }
98
+ try {
99
+ const reply = await collectAgentReply(onSessionEvent, chatState.sessionId, params.onChunk);
100
+ await store.setChatState(transportId, {
101
+ ...chatState,
102
+ updatedAt: new Date().toISOString(),
103
+ });
104
+ return { kind: "ok", reply };
105
+ }
106
+ catch (err) {
107
+ logger.error("chat-service", "reply collection failed", {
108
+ transportId,
109
+ externalChatId,
110
+ error: String(err),
111
+ });
112
+ return {
113
+ kind: "error",
114
+ status: 500,
115
+ message: "Error: failed to collect agent reply",
116
+ };
117
+ }
108
118
  }
109
119
  // ── Internals ────────────────────────────────────────────────
110
120
  // Resolve the role id to seed a NEW bridge chat state with. Prefers
package/dist/socket.d.ts CHANGED
@@ -2,7 +2,7 @@ import type http from "http";
2
2
  import { Server as SocketServer } from "socket.io";
3
3
  import type { RelayFn } from "./relay.js";
4
4
  import type { PushQueue } from "./push-queue.js";
5
- import type { Logger } from "./types.js";
5
+ import type { Attachment, Logger } from "./types.js";
6
6
  export declare const CHAT_SOCKET_PATH = "/ws/chat";
7
7
  /**
8
8
  * Custom socket.io events the chat transport defines. Keys mirror
@@ -43,4 +43,6 @@ type BridgeOptions = Readonly<Record<string, string | number | boolean>>;
43
43
  export declare function bridgeRoom(transportId: string): string;
44
44
  export declare function attachChatSocket(server: http.Server, deps: ChatSocketDeps): ChatSocketHandle;
45
45
  export declare function sanitiseOptions(raw: unknown): BridgeOptions;
46
+ export declare function parseAttachments(raw: unknown): Attachment[] | undefined;
47
+ export declare function parseOneAttachment(item: unknown): Attachment | null;
46
48
  export {};
package/dist/socket.js CHANGED
@@ -237,7 +237,7 @@ function parseMessagePayload(payload) {
237
237
  // is the outer gate; these are tighter, attachment-specific caps.
238
238
  const MAX_ATTACHMENT_COUNT = 10;
239
239
  const MAX_ATTACHMENT_TOTAL_BYTES = 20 * 1024 * 1024; // 20 MB base64
240
- function parseAttachments(raw) {
240
+ export function parseAttachments(raw) {
241
241
  if (!Array.isArray(raw) || raw.length === 0)
242
242
  return undefined;
243
243
  const valid = [];
@@ -245,23 +245,61 @@ function parseAttachments(raw) {
245
245
  for (const item of raw) {
246
246
  if (valid.length >= MAX_ATTACHMENT_COUNT)
247
247
  break;
248
- if (item &&
249
- typeof item === "object" &&
250
- typeof item.mimeType === "string" &&
251
- typeof item.data === "string") {
252
- const data = item.data;
253
- totalBytes += data.length;
248
+ const entry = parseOneAttachment(item);
249
+ if (!entry)
250
+ continue;
251
+ if (entry.data) {
252
+ totalBytes += entry.data.length;
254
253
  if (totalBytes > MAX_ATTACHMENT_TOTAL_BYTES)
255
254
  break;
256
- const entry = {
257
- mimeType: item.mimeType,
258
- data,
259
- };
260
- const fn = item.filename;
261
- if (typeof fn === "string" && fn.length > 0)
262
- entry.filename = fn;
263
- valid.push(entry);
264
255
  }
256
+ valid.push(entry);
265
257
  }
266
258
  return valid.length > 0 ? valid : undefined;
267
259
  }
260
+ // Accept either the inline `{ data, mimeType }` shape (bridges
261
+ // shipping raw bytes) or the path-only `{ path }` shape (Vue UI and
262
+ // any bridge that uploads to `data/attachments/` first). Either is
263
+ // valid per the exported `Attachment` type. The earlier parser only
264
+ // recognised the first shape, so a typed `{ path }` payload from a
265
+ // socket client passed type-check but got silently dropped at the
266
+ // wire boundary (#1050 review).
267
+ //
268
+ // Wire-boundary path guard: a malicious bridge can ship arbitrary
269
+ // strings as `path`. The downstream `isAttachmentPath` enforcement
270
+ // in the server is the authoritative gate, but defence-in-depth at
271
+ // each layer is required for external code (#1099 review). Reject
272
+ // absolute paths and any traversal segment up front so a payload
273
+ // like `path: "../../etc/passwd"` never reaches the relay.
274
+ // Windows-style drive-letter absolute path (`C:\…` or `C:/…`).
275
+ // `isSafeAttachmentPath` rejects these too — a malicious bridge
276
+ // can't ship a host-absolute path under any platform convention
277
+ // (#1099 review iter-2).
278
+ const WINDOWS_DRIVE_PATH_RE = /^[A-Za-z]:[\\/]/;
279
+ function isSafeAttachmentPath(value) {
280
+ if (value.length === 0)
281
+ return false;
282
+ if (value.startsWith("/") || value.startsWith("\\"))
283
+ return false;
284
+ if (WINDOWS_DRIVE_PATH_RE.test(value))
285
+ return false;
286
+ for (const segment of value.split(/[/\\]/)) {
287
+ if (segment === "..")
288
+ return false;
289
+ }
290
+ return true;
291
+ }
292
+ export function parseOneAttachment(item) {
293
+ if (!item || typeof item !== "object")
294
+ return null;
295
+ const record = item;
296
+ const filename = typeof record.filename === "string" && record.filename.length > 0 ? record.filename : undefined;
297
+ if (typeof record.mimeType === "string" && typeof record.data === "string") {
298
+ return { mimeType: record.mimeType, data: record.data, ...(filename ? { filename } : {}) };
299
+ }
300
+ if (typeof record.path === "string" && isSafeAttachmentPath(record.path)) {
301
+ const mimeType = typeof record.mimeType === "string" ? record.mimeType : undefined;
302
+ return { path: record.path, ...(mimeType ? { mimeType } : {}), ...(filename ? { filename } : {}) };
303
+ }
304
+ return null;
305
+ }
package/dist/types.d.ts CHANGED
@@ -8,19 +8,42 @@ export interface Logger {
8
8
  info(prefix: string, message: string, data?: Record<string, unknown>): void;
9
9
  debug(prefix: string, message: string, data?: Record<string, unknown>): void;
10
10
  }
11
- /** A file attached to a bridge message. Generic enough for images,
12
- * PDFs, documents, videos, etc. The server decides what to do with
13
- * each based on mimeType — images become vision content blocks,
14
- * unsupported types are ignored with a log. */
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). */
15
27
  export interface Attachment {
16
- mimeType: string;
17
- data: string;
28
+ mimeType?: string;
29
+ data?: string;
30
+ path?: string;
18
31
  filename?: string;
19
32
  }
20
33
  export interface StartChatParams {
21
34
  message: string;
22
35
  roleId: string;
23
36
  chatSessionId: string;
37
+ /** Bridge-only legacy carrier for "the user picked this image".
38
+ * No in-tree bridge populates this today; the field stays on the
39
+ * type so external bridge clients on older protocol versions still
40
+ * type-check. Only workspace paths are accepted — `data:` URLs
41
+ * are no longer supported and the host app drops them with a
42
+ * warn. Bridges that need to ship raw bytes should use the
43
+ * modern `attachments[]` field with `{ mimeType, data }` entries;
44
+ * the host app persists those to `data/attachments/YYYY/MM/`
45
+ * server-side and rewrites them as path-bearing attachments
46
+ * before any other processing. */
24
47
  selectedImageData?: string;
25
48
  attachments?: Attachment[];
26
49
  /** Session origin — application-defined (e.g. "human", "bridge") */
@@ -99,6 +122,17 @@ export interface ChatServiceDeps {
99
122
  }>;
100
123
  total: number;
101
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>;
102
136
  /**
103
137
  * Return the skills the bridge command handler should expose. The
104
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.3",
3
+ "version": "0.1.6",
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",