fix: enforce Tess session ownership scope (#715)
Some checks are pending
ci/woodpecker/push/ci Pipeline is pending
ci/woodpecker/push/publish Pipeline is pending

This commit was merged in pull request #715.
This commit is contained in:
2026-07-12 22:14:17 +00:00
parent ca9c2b5c23
commit 353e43c947
13 changed files with 750 additions and 145 deletions

View File

@@ -0,0 +1,142 @@
import { ForbiddenException } from '@nestjs/common';
import { describe, expect, it, vi } from 'vitest';
import { AgentService, type AgentSession } from '../agent.service.js';
import type { ActorTenantScope } from '../../auth/session-scope.js';
const CONVERSATION_ID = '22222222-2222-4222-8222-222222222222';
const OWNER_SCOPE: ActorTenantScope = { userId: 'owner-user', tenantId: 'owner-tenant' };
const FOREIGN_SCOPE: ActorTenantScope = { userId: 'foreign-user', tenantId: 'foreign-tenant' };
type AgentServiceInternals = {
sessions: Map<string, AgentSession>;
creating: Map<string, Promise<AgentSession>>;
};
function makeService(): AgentService {
return new AgentService(
{} as never,
{} as never,
{} as never,
{ available: false } as never,
{} as never,
{} as never,
{} as never,
null,
null,
{ collect: vi.fn().mockResolvedValue(undefined) } as never,
);
}
function internals(service: AgentService): AgentServiceInternals {
return service as unknown as AgentServiceInternals;
}
function makeSession(scope: ActorTenantScope = OWNER_SCOPE): AgentSession {
return {
id: CONVERSATION_ID,
provider: 'test-provider',
modelId: 'test-model',
piSession: {
thinkingLevel: 'off',
getAvailableThinkingLevels: vi.fn().mockReturnValue(['off', 'low', 'high']),
setThinkingLevel: vi.fn(),
abort: vi.fn().mockResolvedValue(undefined),
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
getSessionStats: vi.fn(),
getContextUsage: vi.fn(),
} as unknown as AgentSession['piSession'],
listeners: new Set(),
unsubscribe: vi.fn(),
createdAt: Date.now(),
promptCount: 0,
channels: new Set(),
skillPromptAdditions: [],
sandboxDir: '/tmp/tess-session-ownership-test',
allowedTools: null,
userId: scope.userId,
tenantId: scope.tenantId,
metrics: {
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
modelSwitches: 0,
messageCount: 0,
lastActivityAt: new Date('2026-07-12T00:00:00Z').toISOString(),
},
};
}
describe('AgentService owner/tenant scope enforcement', () => {
it('allows owner-scoped operations and rejects foreign scopes for seeded sessions', async () => {
const service = makeService();
const session = makeSession();
internals(service).sessions.set(CONVERSATION_ID, session);
expect(service.getSession(CONVERSATION_ID, OWNER_SCOPE)).toBe(session);
expect(service.getSession(CONVERSATION_ID, FOREIGN_SCOPE)).toBeUndefined();
expect(service.getSessionInfo(CONVERSATION_ID, FOREIGN_SCOPE)).toBeUndefined();
expect(service.listSessions(OWNER_SCOPE)).toHaveLength(1);
expect(service.listSessions(FOREIGN_SCOPE)).toEqual([]);
service.addChannel(CONVERSATION_ID, 'websocket:owner', OWNER_SCOPE);
expect(session.channels.has('websocket:owner')).toBe(true);
expect(() => service.addChannel(CONVERSATION_ID, 'websocket:foreign', FOREIGN_SCOPE)).toThrow(
ForbiddenException,
);
expect(() => service.removeChannel(CONVERSATION_ID, 'websocket:owner', FOREIGN_SCOPE)).toThrow(
ForbiddenException,
);
expect(() =>
service.updateSessionModel(CONVERSATION_ID, 'foreign-model', FOREIGN_SCOPE),
).toThrow(ForbiddenException);
service.updateSessionModel(CONVERSATION_ID, 'owner-model', OWNER_SCOPE);
expect(session.modelId).toBe('owner-model');
expect(() =>
service.applyAgentConfig(CONVERSATION_ID, 'agent-foreign', 'Foreign Agent', FOREIGN_SCOPE),
).toThrow(ForbiddenException);
service.applyAgentConfig(CONVERSATION_ID, 'agent-owner', 'Owner Agent', OWNER_SCOPE);
expect(session.agentConfigId).toBe('agent-owner');
expect(() => service.onEvent(CONVERSATION_ID, vi.fn(), FOREIGN_SCOPE)).toThrow(
ForbiddenException,
);
const cleanup = service.onEvent(CONVERSATION_ID, vi.fn(), OWNER_SCOPE);
cleanup();
await expect(
service.prompt(CONVERSATION_ID, 'foreign prompt', FOREIGN_SCOPE),
).rejects.toBeInstanceOf(ForbiddenException);
await service.prompt(CONVERSATION_ID, 'owner prompt', OWNER_SCOPE);
expect(session.piSession.prompt).toHaveBeenCalledWith('owner prompt');
await expect(service.destroySession(CONVERSATION_ID, FOREIGN_SCOPE)).rejects.toBeInstanceOf(
ForbiddenException,
);
expect(internals(service).sessions.has(CONVERSATION_ID)).toBe(true);
await service.destroySession(CONVERSATION_ID, OWNER_SCOPE);
expect(session.piSession.dispose).toHaveBeenCalled();
expect(internals(service).sessions.has(CONVERSATION_ID)).toBe(false);
});
it('checks owner/tenant scope before returning an in-flight session creation', async () => {
const service = makeService();
const session = makeSession();
internals(service).creating.set(CONVERSATION_ID, Promise.resolve(session));
await expect(
service.createSession(CONVERSATION_ID, {
userId: FOREIGN_SCOPE.userId,
tenantId: FOREIGN_SCOPE.tenantId,
}),
).rejects.toBeInstanceOf(ForbiddenException);
await expect(
service.createSession(CONVERSATION_ID, {
userId: OWNER_SCOPE.userId,
tenantId: OWNER_SCOPE.tenantId,
}),
).resolves.toBe(session);
});
});

View File

@@ -0,0 +1,262 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { ForbiddenException, NotFoundException } from '@nestjs/common';
import { describe, expect, it, vi } from 'vitest';
vi.mock('../agent.service.js', () => ({ AgentService: class AgentService {} }));
vi.mock('../../commands/command-executor.service.js', () => ({
CommandExecutorService: class CommandExecutorService {},
}));
vi.mock('../routing/routing-engine.service.js', () => ({
RoutingEngineService: class RoutingEngineService {},
}));
import { SessionsController } from '../sessions.controller.js';
import { ChatController } from '../../chat/chat.controller.js';
import { ChatGateway } from '../../chat/chat.gateway.js';
import type { AgentSession } from '../agent.service.js';
import type { SessionInfoDto } from '../session.dto.js';
const USER_A = { id: 'user-a', tenantId: 'tenant-a' };
const USER_B = { id: 'user-b', tenantId: 'tenant-b' };
const CONVERSATION_ID = '11111111-1111-4111-8111-111111111111';
function makeSessionInfo(overrides?: Partial<SessionInfoDto>): SessionInfoDto {
return {
id: CONVERSATION_ID,
provider: 'test-provider',
modelId: 'test-model',
createdAt: new Date('2026-07-12T00:00:00Z').toISOString(),
promptCount: 0,
channels: [],
durationMs: 0,
metrics: {
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
modelSwitches: 0,
messageCount: 0,
lastActivityAt: new Date('2026-07-12T00:00:00Z').toISOString(),
},
...overrides,
};
}
function makeAgentSession(owner = USER_A): AgentSession {
return {
id: CONVERSATION_ID,
provider: 'test-provider',
modelId: 'test-model',
piSession: {
thinkingLevel: 'off',
getAvailableThinkingLevels: vi.fn().mockReturnValue(['off', 'low', 'high']),
setThinkingLevel: vi.fn(),
abort: vi.fn().mockResolvedValue(undefined),
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
getSessionStats: vi.fn(),
getContextUsage: vi.fn(),
} as unknown as AgentSession['piSession'],
listeners: new Set(),
unsubscribe: vi.fn(),
createdAt: Date.now(),
promptCount: 0,
channels: new Set(),
skillPromptAdditions: [],
sandboxDir: '/tmp',
allowedTools: null,
userId: owner.id,
tenantId: owner.tenantId,
metrics: {
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
modelSwitches: 0,
messageCount: 0,
lastActivityAt: new Date('2026-07-12T00:00:00Z').toISOString(),
},
};
}
function makeScopedAgentService() {
const foreign = makeAgentSession(USER_A);
return {
listSessions: vi.fn((scope?: { userId: string; tenantId?: string }) =>
scope?.userId === USER_B.id ? [] : [makeSessionInfo({ id: foreign.id })],
),
getSessionInfo: vi.fn((_id: string, scope?: { userId: string; tenantId?: string }) =>
scope?.userId === USER_B.id ? undefined : makeSessionInfo({ id: foreign.id }),
),
destroySession: vi.fn(),
getSession: vi.fn((_id: string, scope?: { userId: string; tenantId?: string }) =>
scope?.userId === USER_B.id ? undefined : foreign,
),
createSession: vi.fn().mockRejectedValue(new ForbiddenException('Session scope mismatch')),
onEvent: vi.fn(() => vi.fn()),
addChannel: vi.fn(),
removeChannel: vi.fn(),
recordMessage: vi.fn(),
prompt: vi.fn().mockResolvedValue(undefined),
};
}
describe('TESS-M1-SEC-002 AgentService ownership boundary', () => {
it('requires explicit owner+tenant scope on protected session operations', () => {
const source = readFileSync(resolve('src/agent/agent.service.ts'), 'utf8');
expect(source).toContain('getSession(sessionId: string, scope: ActorTenantScope)');
expect(source).toContain('listSessions(scope: ActorTenantScope)');
expect(source).toContain('getSessionInfo(sessionId: string, scope: ActorTenantScope)');
expect(source).toContain(
'addChannel(sessionId: string, channel: string, scope: ActorTenantScope)',
);
expect(source).toContain(
'removeChannel(sessionId: string, channel: string, scope: ActorTenantScope)',
);
expect(source).toContain(
'async prompt(sessionId: string, message: string, scope: ActorTenantScope)',
);
expect(source).toContain('scope: ActorTenantScope,');
expect(source).toContain('async destroySession(sessionId: string, scope: ActorTenantScope)');
expect(source).not.toContain('scope?: ActorTenantScope');
});
});
describe('TESS-M1-SEC-002 REST session ownership and tenant binding', () => {
it('lists only sessions owned by the authenticated owner+tenant scope', () => {
const agentService = makeScopedAgentService();
const controller = new SessionsController(agentService as never);
expect(controller.list(USER_B)).toEqual({ sessions: [], total: 0 });
expect(agentService.listSessions).toHaveBeenCalledWith({
userId: USER_B.id,
tenantId: USER_B.tenantId,
});
});
it('does not reveal another owner/tenant session by guessed id', () => {
const agentService = makeScopedAgentService();
const controller = new SessionsController(agentService as never);
expect(() => controller.findOne(CONVERSATION_ID, USER_B)).toThrow(NotFoundException);
expect(agentService.getSessionInfo).toHaveBeenCalledWith(CONVERSATION_ID, {
userId: USER_B.id,
tenantId: USER_B.tenantId,
});
});
it('does not terminate another owner/tenant session by guessed id', async () => {
const agentService = makeScopedAgentService();
const controller = new SessionsController(agentService as never);
await expect(controller.destroy(CONVERSATION_ID, USER_B)).rejects.toBeInstanceOf(
NotFoundException,
);
expect(agentService.destroySession).not.toHaveBeenCalled();
});
});
describe('TESS-M1-SEC-002 REST chat send ownership and tenant binding', () => {
it('does not send a prompt into another owner/tenant session by guessed conversationId', async () => {
const agentService = makeScopedAgentService();
const controller = new ChatController(agentService as never);
await expect(
controller.chat({ conversationId: CONVERSATION_ID, content: 'take over' }, USER_B),
).rejects.toMatchObject({ status: 404 });
expect(agentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
userId: USER_B.id,
tenantId: USER_B.tenantId,
});
expect(agentService.prompt).not.toHaveBeenCalled();
});
});
describe('TESS-M1-SEC-002 WebSocket session ownership and tenant binding', () => {
function makeGateway(agentService = makeScopedAgentService()) {
const brain = {
conversations: {
findById: vi.fn().mockResolvedValue(undefined),
create: vi.fn().mockResolvedValue(undefined),
update: vi.fn().mockResolvedValue(undefined),
findMessages: vi.fn().mockResolvedValue([]),
addMessage: vi.fn().mockResolvedValue(undefined),
},
};
const commandRegistry = { getManifest: vi.fn().mockReturnValue([]) };
const commandExecutor = { execute: vi.fn() };
const routingEngine = {
resolve: vi.fn().mockResolvedValue({ provider: 'test', model: 'test-model' }),
};
const gateway = new ChatGateway(
agentService as never,
{} as never,
brain as never,
commandRegistry as never,
commandExecutor as never,
routingEngine as never,
);
return { gateway, agentService };
}
function makeSocket() {
return {
id: 'socket-b',
connected: true,
data: { user: USER_B, session: { id: 'auth-session-b', userId: USER_B.id } },
emit: vi.fn(),
disconnect: vi.fn(),
};
}
it('does not attach or send to another owner/tenant session by guessed conversationId', async () => {
const { gateway, agentService } = makeGateway();
const socket = makeSocket();
await gateway.handleMessage(socket as never, {
conversationId: CONVERSATION_ID,
content: 'attach to foreign session',
});
expect(agentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
userId: USER_B.id,
tenantId: USER_B.tenantId,
});
expect(agentService.onEvent).not.toHaveBeenCalled();
expect(agentService.addChannel).not.toHaveBeenCalled();
expect(agentService.prompt).not.toHaveBeenCalled();
expect(socket.emit).toHaveBeenCalledWith(
'error',
expect.objectContaining({ conversationId: CONVERSATION_ID }),
);
});
it('does not mutate thinking level on another owner/tenant session', () => {
const { gateway, agentService } = makeGateway();
const socket = makeSocket();
gateway.handleSetThinking(socket as never, { conversationId: CONVERSATION_ID, level: 'high' });
expect(agentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
userId: USER_B.id,
tenantId: USER_B.tenantId,
});
expect(socket.emit).toHaveBeenCalledWith(
'error',
expect.objectContaining({ conversationId: CONVERSATION_ID }),
);
});
it('does not terminate another owner/tenant session over WebSocket abort', async () => {
const { gateway, agentService } = makeGateway();
const socket = makeSocket();
await gateway.handleAbort(socket as never, { conversationId: CONVERSATION_ID });
expect(agentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
userId: USER_B.id,
tenantId: USER_B.tenantId,
});
expect(socket.emit).toHaveBeenCalledWith(
'error',
expect.objectContaining({ conversationId: CONVERSATION_ID }),
);
});
});

View File

@@ -1,4 +1,11 @@
import { Inject, Injectable, Logger, Optional, type OnModuleDestroy } from '@nestjs/common';
import {
ForbiddenException,
Inject,
Injectable,
Logger,
Optional,
type OnModuleDestroy,
} from '@nestjs/common';
import {
createAgentSession,
DefaultResourceLoader,
@@ -28,6 +35,7 @@ import type { SessionInfoDto, SessionMetrics } from './session.dto.js';
import { SystemOverrideService } from '../preferences/system-override.service.js';
import { PreferencesService } from '../preferences/preferences.service.js';
import { SessionGCService } from '../gc/session-gc.service.js';
import type { ActorTenantScope } from '../auth/session-scope.js';
/** A single message from DB conversation history, used for context injection. */
export interface ConversationHistoryMessage {
@@ -68,6 +76,8 @@ export interface AgentSessionOptions {
agentConfigId?: string;
/** ID of the user who owns this session. Used for preferences and system override lookups. */
userId?: string;
/** Server-derived tenant scope that owns this session. Falls back to userId for solo users. */
tenantId?: string;
/**
* Prior conversation messages to inject as context when resuming a session.
* These messages are formatted and prepended to the system prompt so the
@@ -94,6 +104,8 @@ export interface AgentSession {
allowedTools: string[] | null;
/** User ID that owns this session, used for preference lookups. */
userId?: string;
/** Server-derived tenant scope that owns this session. Falls back to userId for solo users. */
tenantId?: string;
/** Agent config ID applied to this session, if any (M5-001). */
agentConfigId?: string;
/** Human-readable agent name applied to this session, if any (M5-001). */
@@ -174,12 +186,20 @@ export class AgentService implements OnModuleDestroy {
.filter((t) => t.length > 0);
}
async createSession(sessionId: string, options?: AgentSessionOptions): Promise<AgentSession> {
async createSession(sessionId: string, options: AgentSessionOptions): Promise<AgentSession> {
const scope = this.scopeFromOptions(options);
const existing = this.sessions.get(sessionId);
if (existing) return existing;
if (existing) {
this.assertSessionScope(existing, scope);
return existing;
}
const inflight = this.creating.get(sessionId);
if (inflight) return inflight;
if (inflight) {
const session = await inflight;
this.assertSessionScope(session, scope);
return session;
}
const promise = this.doCreateSession(sessionId, options).finally(() => {
this.creating.delete(sessionId);
@@ -342,6 +362,7 @@ export class AgentService implements OnModuleDestroy {
sandboxDir,
allowedTools,
userId: mergedOptions?.userId,
tenantId: this.tenantIdFor(mergedOptions?.userId, mergedOptions?.tenantId),
agentConfigId: mergedOptions?.agentConfigId,
agentName: resolvedAgentName,
metrics: {
@@ -473,38 +494,70 @@ export class AgentService implements OnModuleDestroy {
return this.providerService.getDefaultModel() ?? null;
}
getSession(sessionId: string): AgentSession | undefined {
return this.sessions.get(sessionId);
getSession(sessionId: string, scope: ActorTenantScope): AgentSession | undefined {
const session = this.sessions.get(sessionId);
if (!session || !this.sessionMatchesScope(session, scope)) return undefined;
return session;
}
listSessions(): SessionInfoDto[] {
listSessions(scope: ActorTenantScope): SessionInfoDto[] {
const now = Date.now();
return Array.from(this.sessions.values()).map((s) => ({
id: s.id,
provider: s.provider,
modelId: s.modelId,
...(s.agentName ? { agentName: s.agentName } : {}),
createdAt: new Date(s.createdAt).toISOString(),
promptCount: s.promptCount,
channels: Array.from(s.channels),
durationMs: now - s.createdAt,
metrics: { ...s.metrics },
}));
return Array.from(this.sessions.values())
.filter((s) => this.sessionMatchesScope(s, scope))
.map((s) => this.toSessionInfo(s, now));
}
getSessionInfo(sessionId: string): SessionInfoDto | undefined {
listAllSessionsForSystem(): SessionInfoDto[] {
const now = Date.now();
return Array.from(this.sessions.values()).map((s) => this.toSessionInfo(s, now));
}
getSessionInfo(sessionId: string, scope: ActorTenantScope): SessionInfoDto | undefined {
const s = this.sessions.get(sessionId);
if (!s) return undefined;
if (!s || !this.sessionMatchesScope(s, scope)) return undefined;
return this.toSessionInfo(s);
}
private scopeFromOptions(options: AgentSessionOptions): ActorTenantScope {
if (!options.userId) {
throw new ForbiddenException('Session owner scope is required');
}
return {
id: s.id,
provider: s.provider,
modelId: s.modelId,
...(s.agentName ? { agentName: s.agentName } : {}),
createdAt: new Date(s.createdAt).toISOString(),
promptCount: s.promptCount,
channels: Array.from(s.channels),
durationMs: Date.now() - s.createdAt,
metrics: { ...s.metrics },
userId: options.userId,
tenantId: this.tenantIdFor(options.userId, options.tenantId) ?? options.userId,
};
}
private tenantIdFor(
userId: string | undefined,
tenantId: string | undefined,
): string | undefined {
return tenantId ?? userId;
}
private sessionMatchesScope(session: AgentSession, scope: ActorTenantScope): boolean {
return (
session.userId === scope.userId && (session.tenantId ?? session.userId) === scope.tenantId
);
}
private assertSessionScope(session: AgentSession, scope: ActorTenantScope): void {
if (!this.sessionMatchesScope(session, scope)) {
throw new ForbiddenException('Session does not belong to the current owner/tenant scope');
}
}
private toSessionInfo(session: AgentSession, now = Date.now()): SessionInfoDto {
return {
id: session.id,
provider: session.provider,
modelId: session.modelId,
...(session.agentName ? { agentName: session.agentName } : {}),
createdAt: new Date(session.createdAt).toISOString(),
promptCount: session.promptCount,
channels: Array.from(session.channels),
durationMs: now - session.createdAt,
metrics: { ...session.metrics },
};
}
@@ -553,9 +606,10 @@ export class AgentService implements OnModuleDestroy {
* not reconstructed — the model is used on the next createSession call for
* the same conversationId when the session is torn down or a new one is created.
*/
updateSessionModel(sessionId: string, modelId: string): void {
updateSessionModel(sessionId: string, modelId: string, scope: ActorTenantScope): void {
const session = this.sessions.get(sessionId);
if (!session) return;
this.assertSessionScope(session, scope);
const prev = session.modelId;
session.modelId = modelId;
this.recordModelSwitch(sessionId);
@@ -572,48 +626,51 @@ export class AgentService implements OnModuleDestroy {
sessionId: string,
agentConfigId: string,
agentName: string,
scope: ActorTenantScope,
modelId?: string,
): void {
const session = this.sessions.get(sessionId);
if (!session) return;
this.assertSessionScope(session, scope);
session.agentConfigId = agentConfigId;
session.agentName = agentName;
if (modelId) {
this.updateSessionModel(sessionId, modelId);
this.updateSessionModel(sessionId, modelId, scope);
}
this.logger.log(
`Session ${sessionId}: agent switched to "${agentName}" (${agentConfigId}) (M5-003)`,
);
}
addChannel(sessionId: string, channel: string): void {
addChannel(sessionId: string, channel: string, scope: ActorTenantScope): void {
const session = this.sessions.get(sessionId);
if (session) {
session.channels.add(channel);
}
if (!session) return;
this.assertSessionScope(session, scope);
session.channels.add(channel);
}
removeChannel(sessionId: string, channel: string): void {
removeChannel(sessionId: string, channel: string, scope: ActorTenantScope): void {
const session = this.sessions.get(sessionId);
if (session) {
session.channels.delete(channel);
}
if (!session) return;
this.assertSessionScope(session, scope);
session.channels.delete(channel);
}
async prompt(sessionId: string, message: string): Promise<void> {
async prompt(sessionId: string, message: string, scope: ActorTenantScope): Promise<void> {
const session = this.sessions.get(sessionId);
if (!session) {
throw new Error(`No agent session found: ${sessionId}`);
}
this.assertSessionScope(session, scope);
session.promptCount += 1;
// Prepend session-scoped system override if present (renew TTL on each turn)
let effectiveMessage = message;
if (this.systemOverride) {
const override = await this.systemOverride.get(sessionId);
const override = await this.systemOverride.get(sessionId, scope);
if (override) {
effectiveMessage = `[System Override]\n${override}\n\n${message}`;
await this.systemOverride.renew(sessionId);
await this.systemOverride.renew(sessionId, scope);
this.logger.debug(`Applied system override for session ${sessionId}`);
}
}
@@ -629,16 +686,28 @@ export class AgentService implements OnModuleDestroy {
}
}
onEvent(sessionId: string, listener: (event: AgentSessionEvent) => void): () => void {
onEvent(
sessionId: string,
listener: (event: AgentSessionEvent) => void,
scope: ActorTenantScope,
): () => void {
const session = this.sessions.get(sessionId);
if (!session) {
throw new Error(`No agent session found: ${sessionId}`);
}
this.assertSessionScope(session, scope);
session.listeners.add(listener);
return () => session.listeners.delete(listener);
}
async destroySession(sessionId: string): Promise<void> {
async destroySession(sessionId: string, scope: ActorTenantScope): Promise<void> {
const session = this.sessions.get(sessionId);
if (!session) return;
this.assertSessionScope(session, scope);
await this.destroySessionForSystem(sessionId);
}
private async destroySessionForSystem(sessionId: string): Promise<void> {
const session = this.sessions.get(sessionId);
if (!session) return;
this.logger.log(`Destroying agent session ${sessionId}`);
@@ -667,7 +736,7 @@ export class AgentService implements OnModuleDestroy {
async onModuleDestroy(): Promise<void> {
this.logger.log('Shutting down all agent sessions');
const stops = Array.from(this.sessions.keys()).map((id) => this.destroySession(id));
const stops = Array.from(this.sessions.keys()).map((id) => this.destroySessionForSystem(id));
const results = await Promise.allSettled(stops);
for (const result of results) {
if (result.status === 'rejected') {

View File

@@ -10,6 +10,8 @@ import {
UseGuards,
} from '@nestjs/common';
import { AuthGuard } from '../auth/auth.guard.js';
import { CurrentUser } from '../auth/current-user.decorator.js';
import { scopeFromUser, type AuthenticatedUserLike } from '../auth/session-scope.js';
import { AgentService } from './agent.service.js';
@Controller('api/sessions')
@@ -18,23 +20,24 @@ export class SessionsController {
constructor(@Inject(AgentService) private readonly agentService: AgentService) {}
@Get()
list() {
const sessions = this.agentService.listSessions();
list(@CurrentUser() user: AuthenticatedUserLike) {
const sessions = this.agentService.listSessions(scopeFromUser(user));
return { sessions, total: sessions.length };
}
@Get(':id')
findOne(@Param('id') id: string) {
const info = this.agentService.getSessionInfo(id);
findOne(@Param('id') id: string, @CurrentUser() user: AuthenticatedUserLike) {
const info = this.agentService.getSessionInfo(id, scopeFromUser(user));
if (!info) throw new NotFoundException('Session not found');
return info;
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
async destroy(@Param('id') id: string) {
const info = this.agentService.getSessionInfo(id);
async destroy(@Param('id') id: string, @CurrentUser() user: AuthenticatedUserLike) {
const scope = scopeFromUser(user);
const info = this.agentService.getSessionInfo(id, scope);
if (!info) throw new NotFoundException('Session not found');
await this.agentService.destroySession(id);
await this.agentService.destroySession(id, scope);
}
}