Skip to content

Commit 3292c88

Browse files
committed
fix(gateway): isolate data by user id
Signed-off-by: 李冠辰 <liguanchen@xiaomi.com>
1 parent cd12b9e commit 3292c88

3 files changed

Lines changed: 139 additions & 7 deletions

File tree

src/gateway/server.ts

Lines changed: 100 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@
1414
* Designed to run as a managed sidecar alongside Hermes.
1515
*/
1616

17+
import { createHash } from "node:crypto";
1718
import http from "node:http";
19+
import path from "node:path";
1820
import { URL } from "node:url";
1921
import { TdaiCore } from "../core/tdai-core.js";
2022
import { StandaloneHostAdapter } from "../adapters/standalone/host-adapter.js";
@@ -46,6 +48,43 @@ import type { SeedProgress } from "../core/seed/types.js";
4648
const TAG = "[tdai-gateway]";
4749
const VERSION = "0.1.0";
4850

51+
// ============================
52+
// User data scope
53+
// ============================
54+
55+
export interface GatewayUserScope {
56+
cacheKey: string;
57+
dataDir: string;
58+
isolated: boolean;
59+
}
60+
61+
function safePathSegment(value: string): string {
62+
const normalized = value.normalize("NFKC").trim();
63+
const slug = normalized
64+
.replace(/[^A-Za-z0-9_-]+/g, "-")
65+
.replace(/^-+|-+$/g, "")
66+
.slice(0, 48) || "user";
67+
const digest = createHash("sha256").update(normalized).digest("hex").slice(0, 12);
68+
return `${slug}-${digest}`;
69+
}
70+
71+
export function resolveGatewayUserScope(baseDir: string, userId?: string): GatewayUserScope {
72+
const identity = userId?.trim();
73+
if (!identity) {
74+
return {
75+
cacheKey: "legacy",
76+
dataDir: baseDir,
77+
isolated: false,
78+
};
79+
}
80+
81+
return {
82+
cacheKey: `user:${identity}`,
83+
dataDir: path.join(baseDir, "users", safePathSegment(identity)),
84+
isolated: true,
85+
};
86+
}
87+
4988
// ============================
5089
// Console logger (for standalone gateway — no OpenClaw logger available)
5190
// ============================
@@ -100,6 +139,7 @@ export class TdaiGateway {
100139
private config: GatewayConfig;
101140
private logger: Logger;
102141
private core: TdaiCore;
142+
private readonly scopedCores = new Map<string, Promise<TdaiCore>>();
103143
private server: http.Server | null = null;
104144
private startTime = Date.now();
105145

@@ -121,6 +161,7 @@ export class TdaiGateway {
121161
config: this.config.memory,
122162
sessionFilter: new SessionFilter(this.config.memory.capture.excludeAgents),
123163
});
164+
this.scopedCores.set("legacy", Promise.resolve(this.core));
124165
}
125166

126167
/**
@@ -160,7 +201,17 @@ export class TdaiGateway {
160201
});
161202
}
162203

163-
await this.core.destroy();
204+
const corePromises = [...this.scopedCores.values()];
205+
const settled = await Promise.allSettled(corePromises);
206+
const cores = new Set<TdaiCore>([this.core]);
207+
for (const item of settled) {
208+
if (item.status === "fulfilled") cores.add(item.value);
209+
}
210+
for (const core of cores) {
211+
await core.destroy();
212+
}
213+
this.scopedCores.clear();
214+
this.scopedCores.set("legacy", Promise.resolve(this.core));
164215
this.logger.info("Gateway stopped");
165216
}
166217

@@ -236,7 +287,8 @@ export class TdaiGateway {
236287
}
237288

238289
const startMs = Date.now();
239-
const result = await this.core.handleBeforeRecall(body.query, body.session_key);
290+
const core = await this.getCoreForUser(body.user_id);
291+
const result = await core.handleBeforeRecall(body.query, body.session_key);
240292
const elapsed = Date.now() - startMs;
241293

242294
this.logger.info(`Recall completed in ${elapsed}ms: context=${(result.appendSystemContext?.length ?? 0)} chars`);
@@ -258,7 +310,8 @@ export class TdaiGateway {
258310
}
259311

260312
const startMs = Date.now();
261-
const result = await this.core.handleTurnCommitted({
313+
const core = await this.getCoreForUser(body.user_id);
314+
const result = await core.handleTurnCommitted({
262315
userText: body.user_content,
263316
assistantText: body.assistant_content,
264317
messages: body.messages ?? [
@@ -287,7 +340,8 @@ export class TdaiGateway {
287340
return;
288341
}
289342

290-
const result = await this.core.searchMemories({
343+
const core = await this.getCoreForUser(body.user_id);
344+
const result = await core.searchMemories({
291345
query: body.query,
292346
limit: body.limit,
293347
type: body.type,
@@ -310,7 +364,8 @@ export class TdaiGateway {
310364
return;
311365
}
312366

313-
const result = await this.core.searchConversations({
367+
const core = await this.getCoreForUser(body.user_id);
368+
const result = await core.searchConversations({
314369
query: body.query,
315370
limit: body.limit,
316371
sessionKey: body.session_key,
@@ -331,7 +386,8 @@ export class TdaiGateway {
331386
return;
332387
}
333388

334-
await this.core.handleSessionEnd(body.session_key);
389+
const core = await this.getCoreForUser(body.user_id);
390+
await core.handleSessionEnd(body.session_key);
335391

336392
const response: SessionEndResponse = { flushed: true };
337393
sendJson(res, 200, response);
@@ -375,7 +431,8 @@ export class TdaiGateway {
375431
const ts =
376432
`${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-` +
377433
`${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
378-
const outputDir = `${this.config.data.baseDir}/seed-${ts}`;
434+
const userScope = resolveGatewayUserScope(this.config.data.baseDir, body.user_id);
435+
const outputDir = path.join(userScope.dataDir, `seed-${ts}`);
379436

380437
// Merge config overrides if provided
381438
// Start with the base memory config + inject llm config from gateway settings
@@ -434,6 +491,42 @@ export class TdaiGateway {
434491
};
435492
sendJson(res, 200, response);
436493
}
494+
495+
private async getCoreForUser(userId?: string): Promise<TdaiCore> {
496+
const scope = resolveGatewayUserScope(this.config.data.baseDir, userId);
497+
if (!scope.isolated) return this.core;
498+
499+
const existing = this.scopedCores.get(scope.cacheKey);
500+
if (existing) return existing;
501+
502+
const corePromise = this.createScopedCore(scope.dataDir).catch((err) => {
503+
this.scopedCores.delete(scope.cacheKey);
504+
throw err;
505+
});
506+
this.scopedCores.set(scope.cacheKey, corePromise);
507+
return corePromise;
508+
}
509+
510+
private async createScopedCore(dataDir: string): Promise<TdaiCore> {
511+
initDataDirectories(dataDir);
512+
513+
const adapter = new StandaloneHostAdapter({
514+
dataDir,
515+
llmConfig: this.config.llm,
516+
logger: this.logger,
517+
platform: "gateway",
518+
});
519+
520+
const core = new TdaiCore({
521+
hostAdapter: adapter,
522+
config: this.config.memory,
523+
sessionFilter: new SessionFilter(this.config.memory.capture.excludeAgents),
524+
});
525+
526+
await core.initialize();
527+
this.logger.info(`Gateway user scope initialized: ${dataDir}`);
528+
return core;
529+
}
437530
}
438531

439532
// ============================

src/gateway/types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ export interface MemorySearchRequest {
6868
limit?: number;
6969
type?: string;
7070
scene?: string;
71+
user_id?: string;
7172
}
7273

7374
export interface MemorySearchResponse {
@@ -84,6 +85,7 @@ export interface ConversationSearchRequest {
8485
query: string;
8586
limit?: number;
8687
session_key?: string;
88+
user_id?: string;
8789
}
8890

8991
export interface ConversationSearchResponse {
@@ -125,6 +127,8 @@ export interface SeedRequest {
125127
data: unknown;
126128
/** Fallback session key when input sessions lack one. */
127129
session_key?: string;
130+
/** Optional user identity used to route seeded data into an isolated gateway data scope. */
131+
user_id?: string;
128132
/** Require each round to have both user and assistant messages. */
129133
strict_round_role?: boolean;
130134
/** Auto-fill missing timestamps (default: true). */

src/gateway/user-scope.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import path from "node:path";
2+
import { describe, expect, test } from "vitest";
3+
import { resolveGatewayUserScope } from "./server.js";
4+
5+
describe("gateway user data scope", () => {
6+
test("keeps anonymous requests on the legacy base data directory", () => {
7+
const baseDir = path.join("/tmp", "memory-tdai");
8+
9+
const scope = resolveGatewayUserScope(baseDir);
10+
11+
expect(scope.isolated).toBe(false);
12+
expect(scope.cacheKey).toBe("legacy");
13+
expect(scope.dataDir).toBe(baseDir);
14+
});
15+
16+
test("routes an explicit user id into a stable isolated data directory", () => {
17+
const baseDir = path.join("/tmp", "memory-tdai");
18+
19+
const scope = resolveGatewayUserScope(baseDir, " default ");
20+
21+
expect(scope.isolated).toBe(true);
22+
expect(scope.cacheKey).toBe("user:default");
23+
expect(scope.dataDir).toMatch(/\/tmp\/memory-tdai\/users\/default-[0-9a-f]{12}$/);
24+
});
25+
26+
test("sanitizes malicious user ids so they cannot escape the base directory", () => {
27+
const baseDir = path.join("/tmp", "memory-tdai");
28+
29+
const scope = resolveGatewayUserScope(baseDir, "../lejun");
30+
31+
expect(scope.isolated).toBe(true);
32+
expect(scope.dataDir.startsWith(path.join(baseDir, "users") + path.sep)).toBe(true);
33+
expect(scope.dataDir).not.toContain("..");
34+
});
35+
});

0 commit comments

Comments
 (0)