Files
Moltbot/src/gateway/server-methods/chat.abort-authorization.test.ts
Val Alexander c5ea6134d0 feat(ui): add chat infrastructure modules (slice 1/3 of dashboard-v2) (#41497)
* feat(ui): add chat infrastructure modules (slice 1 of dashboard-v2)

New self-contained chat modules extracted from dashboard-v2-structure:

- chat/slash-commands.ts: slash command definitions and completions
- chat/slash-command-executor.ts: execute slash commands via gateway RPC
- chat/slash-command-executor.node.test.ts: test coverage
- chat/speech.ts: speech-to-text (STT) support
- chat/input-history.ts: per-session input history navigation
- chat/pinned-messages.ts: pinned message management
- chat/deleted-messages.ts: deleted message tracking
- chat/export.ts: shared exportChatMarkdown helper
- chat-export.ts: re-export shim for backwards compat

Gateway fix:
- Restore usage/cost stripping in chat.history sanitization
- Add test coverage for sanitization behavior

These modules are additive and tree-shaken — no existing code
imports them yet. They will be wired in subsequent slices.

* Update ui/src/ui/chat/export.ts

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* fix(ui): address review feedback on chat infra slice

- export.ts: handle array content blocks (Claude API format) instead
  of silently exporting empty strings
- slash-command-executor.ts: restrict /kill all to current session's
  subagent subtree instead of all sessions globally
- slash-command-executor.ts: only count truly aborted runs (check
  aborted !== false) in /kill summary

* fix: scope /kill <id> to current session subtree and preserve usage.cost in chat.history

- Restrict /kill <id> matching to only subagents belonging to the current
  session's agent subtree (P1 review feedback)
- Preserve nested usage.cost in chat.history sanitization so cost badges
  remain available (P2 review feedback)

* fix(ui): tighten slash kill scoping

* fix(ui): support legacy slash kill scopes

* fix(ci): repair pr branch checks

* Gateway: harden chat abort and export

* UI: align slash commands with session tree scope

* UI: resolve session aliases for slash command lookups

* Update .gitignore

* Cron: use shared nested lane resolver

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-03-12 03:48:58 -04:00

148 lines
4.5 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import { chatHandlers } from "./chat.js";
function createActiveRun(sessionKey: string, owner?: { connId?: string; deviceId?: string }) {
const now = Date.now();
return {
controller: new AbortController(),
sessionId: `${sessionKey}-session`,
sessionKey,
startedAtMs: now,
expiresAtMs: now + 30_000,
ownerConnId: owner?.connId,
ownerDeviceId: owner?.deviceId,
};
}
function createContext(overrides: Record<string, unknown> = {}) {
return {
chatAbortControllers: new Map(),
chatRunBuffers: new Map(),
chatDeltaSentAt: new Map(),
chatAbortedRuns: new Map<string, number>(),
removeChatRun: vi
.fn()
.mockImplementation((run: string) => ({ sessionKey: "main", clientRunId: run })),
agentRunSeq: new Map<string, number>(),
broadcast: vi.fn(),
nodeSendToSession: vi.fn(),
logGateway: { warn: vi.fn() },
...overrides,
};
}
async function invokeChatAbort(params: {
context: ReturnType<typeof createContext>;
request: { sessionKey: string; runId?: string };
client?: {
connId?: string;
connect?: {
device?: { id?: string };
scopes?: string[];
};
} | null;
}) {
const respond = vi.fn();
await chatHandlers["chat.abort"]({
params: params.request,
respond: respond as never,
context: params.context as never,
req: {} as never,
client: (params.client ?? null) as never,
isWebchatConnect: () => false,
});
return respond;
}
describe("chat.abort authorization", () => {
it("rejects explicit run aborts from other clients", async () => {
const context = createContext({
chatAbortControllers: new Map([
["run-1", createActiveRun("main", { connId: "conn-owner", deviceId: "dev-owner" })],
]),
});
const respond = await invokeChatAbort({
context,
request: { sessionKey: "main", runId: "run-1" },
client: {
connId: "conn-other",
connect: { device: { id: "dev-other" }, scopes: ["operator.write"] },
},
});
const [ok, payload, error] = respond.mock.calls.at(-1) ?? [];
expect(ok).toBe(false);
expect(payload).toBeUndefined();
expect(error).toMatchObject({ code: "INVALID_REQUEST", message: "unauthorized" });
expect(context.chatAbortControllers.has("run-1")).toBe(true);
});
it("allows the same paired device to abort after reconnecting", async () => {
const context = createContext({
chatAbortControllers: new Map([
["run-1", createActiveRun("main", { connId: "conn-old", deviceId: "dev-1" })],
]),
});
const respond = await invokeChatAbort({
context,
request: { sessionKey: "main", runId: "run-1" },
client: {
connId: "conn-new",
connect: { device: { id: "dev-1" }, scopes: ["operator.write"] },
},
});
const [ok, payload] = respond.mock.calls.at(-1) ?? [];
expect(ok).toBe(true);
expect(payload).toMatchObject({ aborted: true, runIds: ["run-1"] });
expect(context.chatAbortControllers.has("run-1")).toBe(false);
});
it("only aborts session-scoped runs owned by the requester", async () => {
const context = createContext({
chatAbortControllers: new Map([
["run-mine", createActiveRun("main", { deviceId: "dev-1" })],
["run-other", createActiveRun("main", { deviceId: "dev-2" })],
]),
});
const respond = await invokeChatAbort({
context,
request: { sessionKey: "main" },
client: {
connId: "conn-1",
connect: { device: { id: "dev-1" }, scopes: ["operator.write"] },
},
});
const [ok, payload] = respond.mock.calls.at(-1) ?? [];
expect(ok).toBe(true);
expect(payload).toMatchObject({ aborted: true, runIds: ["run-mine"] });
expect(context.chatAbortControllers.has("run-mine")).toBe(false);
expect(context.chatAbortControllers.has("run-other")).toBe(true);
});
it("allows operator.admin clients to bypass owner checks", async () => {
const context = createContext({
chatAbortControllers: new Map([
["run-1", createActiveRun("main", { connId: "conn-owner", deviceId: "dev-owner" })],
]),
});
const respond = await invokeChatAbort({
context,
request: { sessionKey: "main", runId: "run-1" },
client: {
connId: "conn-admin",
connect: { device: { id: "dev-admin" }, scopes: ["operator.admin"] },
},
});
const [ok, payload] = respond.mock.calls.at(-1) ?? [];
expect(ok).toBe(true);
expect(payload).toMatchObject({ aborted: true, runIds: ["run-1"] });
});
});