fix(pet): each user's pet tells only of their own sessions
An admin/analyst's pet counted every user's sessions (live, waiting, errors) because their WebSocket carries everyone's. Server mode now marks their session/agent/event frames `mine: true|false` (scope.broadcastAudience owners, worked out once per broadcast), and GET /api/sessions?mine=1 lists only the caller's own sessions (ownSessionWhere). The pet seeds from mine=1 and skips frames marked mine: false. Developers and local mode are unchanged.
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* @file websocket-mine.test.js
|
||||
* @description Server mode broadcasts (server/websocket.js `broadcast`): an
|
||||
* admin/analyst's session, agent and event frames say whether the session is
|
||||
* their OWN (`mine`), a developer's and local mode's never carry it, and
|
||||
* `GET /api/sessions?mine=1` (server/routes/sessions.js) lists only the
|
||||
* caller's own sessions whatever their role — the pet counts only those.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, before, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const path = require("node:path");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
|
||||
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "ws-mine-test-"));
|
||||
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
||||
|
||||
const { db } = require("../db");
|
||||
const ws = require("../websocket");
|
||||
|
||||
const prevMode = process.env.AGENTHUB_MODE;
|
||||
before(() => {
|
||||
const lane = db.prepare("INSERT INTO lanes (cwd, tool, owner_id) VALUES (?, 'claude', ?)");
|
||||
lane.run("/w/boss", 99); // id 1, the admin's
|
||||
lane.run("/w/dev", 2); // id 2, a developer's
|
||||
const s = db.prepare(
|
||||
"INSERT INTO sessions (id, name, status, cwd, lane_id) VALUES (?, ?, 'active', '/w', ?)"
|
||||
);
|
||||
s.run("s-boss", "boss", 1);
|
||||
s.run("s-dev", "dev", 2);
|
||||
});
|
||||
|
||||
after(() => {
|
||||
ws._setClientsForTest(null);
|
||||
if (prevMode === undefined) delete process.env.AGENTHUB_MODE;
|
||||
else process.env.AGENTHUB_MODE = prevMode;
|
||||
fs.rmSync(TMP, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function client(user) {
|
||||
const got = [];
|
||||
return { readyState: 1, user, got, send: (m) => got.push(JSON.parse(m)) };
|
||||
}
|
||||
|
||||
describe("broadcast `mine`", () => {
|
||||
it("marks an admin's frames by whose session it is; a developer gets only theirs, unmarked", () => {
|
||||
process.env.AGENTHUB_MODE = "server";
|
||||
const boss = client({ id: 99, username: "boss", role: "admin" });
|
||||
const dev = client({ id: 2, username: "dev", role: "developer" });
|
||||
ws._setClientsForTest(new Set([boss, dev]));
|
||||
ws.broadcast("session_updated", { id: "s-boss", status: "active" });
|
||||
ws.broadcast("new_event", { session_id: "s-dev", event_type: "PostToolUse" });
|
||||
ws.broadcast("lane_update", { lane: { id: 2 } });
|
||||
assert.deepEqual(
|
||||
boss.got.map((m) => [m.type, m.mine]),
|
||||
[
|
||||
["session_updated", true],
|
||||
["new_event", false],
|
||||
["lane_update", undefined],
|
||||
]
|
||||
);
|
||||
assert.deepEqual(
|
||||
dev.got.map((m) => [m.type, m.mine]),
|
||||
[
|
||||
["new_event", undefined],
|
||||
["lane_update", undefined],
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
it("adds nothing in local mode", () => {
|
||||
delete process.env.AGENTHUB_MODE;
|
||||
const me = client(null);
|
||||
ws._setClientsForTest(new Set([me]));
|
||||
ws.broadcast("session_updated", { id: "s-dev" });
|
||||
assert.equal(me.got[0].mine, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/sessions?mine=1", () => {
|
||||
it("lists only the caller's own sessions, an admin's too", async () => {
|
||||
const express = require("express");
|
||||
const app = express();
|
||||
app.use((req, _res, next) => {
|
||||
req.user = { id: 99, username: "boss", role: "admin" };
|
||||
next();
|
||||
});
|
||||
app.use("/api/sessions", require("../routes/sessions"));
|
||||
const srv = app.listen(0);
|
||||
try {
|
||||
const base = `http://127.0.0.1:${srv.address().port}/api/sessions`;
|
||||
const ids = async (qs) =>
|
||||
(await (await fetch(base + qs)).json()).sessions.map((s) => s.id).sort();
|
||||
assert.deepEqual(await ids("?mine=1"), ["s-boss"]);
|
||||
assert.deepEqual(await ids(""), ["s-boss", "s-dev"]);
|
||||
} finally {
|
||||
srv.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -154,6 +154,14 @@ const paths = {
|
||||
"`1` = only sessions blocked on the user (`awaiting_input_since IS NOT NULL AND status = 'active'`), the UI's Waiting filter. Applied before `limit`/`offset`, so `total` stays exact.",
|
||||
example: "1",
|
||||
},
|
||||
{
|
||||
name: "mine",
|
||||
in: "query",
|
||||
schema: { type: "string", enum: ["1"] },
|
||||
description:
|
||||
"Server mode: `1` = only the caller's OWN sessions, even for an admin or analyst (the pet's count). No-op in local mode.",
|
||||
example: "1",
|
||||
},
|
||||
{
|
||||
name: "from",
|
||||
in: "query",
|
||||
|
||||
@@ -160,6 +160,13 @@ router.get("/", (req, res) => {
|
||||
);
|
||||
params.push(Number(lane), Number(lane));
|
||||
}
|
||||
// `mine=1` = the requesting user's OWN sessions whatever their role (the
|
||||
// pet's count): an admin/analyst otherwise sees every user's. "1" locally.
|
||||
if (req.query.mine === "1") {
|
||||
const o = require("../lib/scope").ownSessionWhere(req, "s.id", "s.lane_id");
|
||||
where.push(o.where);
|
||||
params.push(...o.params);
|
||||
}
|
||||
// `awaiting=1` = only sessions blocked on the user (an active session with
|
||||
// `awaiting_input_since`), the UI's "Waiting" filter, done before the limit.
|
||||
if (req.query.awaiting === "1") {
|
||||
|
||||
+39
-2
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* @file WebSocket functionalities for real-time communication with clients, including connection management, heartbeat for detecting dead connections, and broadcasting messages to all connected clients.
|
||||
* @file WebSocket functionalities for real-time communication with clients, including connection management, heartbeat for detecting dead connections, and broadcasting messages to all connected clients (server mode: each user only what they may see, and an admin/analyst's session frames marked `mine` when the session is their own).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -9,6 +9,14 @@ const { isWebSocketLoggedIn, isServerMode, userFromRequest } = require("./lib/au
|
||||
const { broadcastAudience, exactAudience, audienceIncludes, canSeeRun } = require("./lib/scope");
|
||||
|
||||
let wss = null;
|
||||
// Session-scoped broadcasts an admin/analyst gets with `mine` (see broadcast).
|
||||
const MINE_TYPES = new Set([
|
||||
"session_created",
|
||||
"session_updated",
|
||||
"agent_created",
|
||||
"agent_updated",
|
||||
"new_event",
|
||||
]);
|
||||
// How often an open terminal's login is re-checked in server mode.
|
||||
const PTY_RECHECK_MS = Number(process.env.AGENTHUB_PTY_RECHECK_MS) || 10000;
|
||||
|
||||
@@ -230,7 +238,8 @@ function initPtyWebSocket(server) {
|
||||
|
||||
function broadcast(type, data) {
|
||||
if (!wss) return;
|
||||
const message = JSON.stringify({ type, data, timestamp: new Date().toISOString() });
|
||||
const timestamp = new Date().toISOString();
|
||||
const message = JSON.stringify({ type, data, timestamp });
|
||||
// Server mode: worked out once, and only when a member is connected.
|
||||
let audience;
|
||||
// A message for exactly these users (a member's own config change): no
|
||||
@@ -255,6 +264,24 @@ function broadcast(type, data) {
|
||||
return audienceIncludes(audience, client.user);
|
||||
};
|
||||
let stripped;
|
||||
// Server mode: an admin/analyst gets every user's session news, so a
|
||||
// session-scoped frame tells them whether it is their OWN (`mine`) — the
|
||||
// pet counts only those. Worked out once per broadcast, on first need.
|
||||
const owned = MINE_TYPES.has(type) && isServerMode();
|
||||
let mineMsg;
|
||||
let otherMsg;
|
||||
const forStaff = (client) => {
|
||||
if (audience === undefined) {
|
||||
try {
|
||||
audience = broadcastAudience(type, data);
|
||||
} catch {
|
||||
audience = { staffOnly: true };
|
||||
}
|
||||
}
|
||||
const mine = Boolean(audience && audience.owners && audience.owners.has(client.user.id));
|
||||
if (mine) return (mineMsg ??= JSON.stringify({ type, data, timestamp, mine: true }));
|
||||
return (otherMsg ??= JSON.stringify({ type, data, timestamp, mine: false }));
|
||||
};
|
||||
wss.clients.forEach((client) => {
|
||||
if (client.readyState === 1 && reaches(client)) {
|
||||
try {
|
||||
@@ -264,6 +291,10 @@ function broadcast(type, data) {
|
||||
client.send(stripped);
|
||||
return;
|
||||
}
|
||||
if (owned && !only && client.user && !member) {
|
||||
client.send(forStaff(client));
|
||||
return;
|
||||
}
|
||||
client.send(message);
|
||||
} catch {
|
||||
// Client closed between readyState check and send — safe to ignore
|
||||
@@ -331,7 +362,13 @@ function closeWebSocket() {
|
||||
}
|
||||
}
|
||||
|
||||
/** Tests only: broadcast to these fake clients (`{readyState, user, send}`). */
|
||||
function _setClientsForTest(clients) {
|
||||
wss = clients ? { clients } : null;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
_setClientsForTest,
|
||||
initWebSocket,
|
||||
initPtyWebSocket,
|
||||
broadcast,
|
||||
|
||||
Reference in New Issue
Block a user