wip(sync): merge main into next with combined resolutions
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { CommandDef, SlashCommandPayload } from '@mosaicstack/types';
|
||||
import { CommandAuthorizationService } from './command-authorization.service.js';
|
||||
|
||||
const adminCommand: CommandDef = {
|
||||
name: 'gc',
|
||||
description: 'GC',
|
||||
aliases: [],
|
||||
scope: 'admin',
|
||||
execution: 'socket',
|
||||
available: true,
|
||||
};
|
||||
const payload: SlashCommandPayload = { command: 'gc', conversationId: 'conversation-1' };
|
||||
|
||||
function createService(
|
||||
role: string,
|
||||
entries: Map<string, string> = new Map<string, string>(),
|
||||
): CommandAuthorizationService {
|
||||
const db = {
|
||||
select: () => ({ from: () => ({ where: () => ({ limit: async () => [{ role }] }) }) }),
|
||||
};
|
||||
const redis = {
|
||||
get: async (key: string) => entries.get(key) ?? null,
|
||||
set: async (key: string, value: string) => {
|
||||
entries.set(key, value);
|
||||
},
|
||||
del: async (key: string) => Number(entries.delete(key)),
|
||||
};
|
||||
return new CommandAuthorizationService(db as never, redis);
|
||||
}
|
||||
|
||||
describe('CommandAuthorizationService', () => {
|
||||
it('consumes one exact actor-bound approval once', async (): Promise<void> => {
|
||||
const service = createService('admin');
|
||||
const approval = await service.createApproval(adminCommand, payload, 'admin-1');
|
||||
expect(approval).not.toBeNull();
|
||||
expect(
|
||||
(await service.authorize(adminCommand, payload, 'admin-1', approval!.approvalId)).allowed,
|
||||
).toBe(true);
|
||||
expect(
|
||||
(await service.authorize(adminCommand, payload, 'admin-1', approval!.approvalId)).allowed,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects an approval when the structured action is mutated', async (): Promise<void> => {
|
||||
const service = createService('admin');
|
||||
const approval = await service.createApproval(adminCommand, payload, 'admin-1');
|
||||
const mutated = { ...payload, conversationId: 'other-conversation' };
|
||||
expect(approval).not.toBeNull();
|
||||
expect(
|
||||
(await service.authorize(adminCommand, mutated, 'admin-1', approval!.approvalId)).allowed,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('denies an admin command to a member before approval is considered', async (): Promise<void> => {
|
||||
const service = createService('member');
|
||||
const approval = await service.createApproval(adminCommand, payload, 'member-1');
|
||||
expect(approval).toBeNull();
|
||||
expect(
|
||||
(await service.authorize(adminCommand, payload, 'member-1', 'forged-approval-id')).allowed,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('denies a malformed durable approval expiry instead of treating it as unexpired', async (): Promise<void> => {
|
||||
const entries = new Map<string, string>();
|
||||
const action = {
|
||||
providerId: 'fleet',
|
||||
sessionId: 'nova',
|
||||
actorId: 'admin-1',
|
||||
tenantId: 'tenant-1',
|
||||
channelId: 'discord:operator',
|
||||
correlationId: 'correlation-malformed-expiry',
|
||||
agentName: 'Nova',
|
||||
};
|
||||
const service = createService('admin', entries);
|
||||
const approval = await service.createRuntimeTerminationApproval(action);
|
||||
expect(approval).not.toBeNull();
|
||||
const key = `agent:Nova:command-approval:${approval!.approvalId}`;
|
||||
const stored = entries.get(key);
|
||||
expect(stored).toBeDefined();
|
||||
entries.set(key, JSON.stringify({ ...JSON.parse(stored!), expiresAt: 'not-a-date' }));
|
||||
|
||||
expect(await service.consumeRuntimeTerminationApproval(approval!.approvalId, action)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('persists and consumes one exact runtime termination approval across a service restart', async (): Promise<void> => {
|
||||
const entries = new Map<string, string>();
|
||||
const action = {
|
||||
providerId: 'fleet',
|
||||
sessionId: 'nova',
|
||||
actorId: 'admin-1',
|
||||
tenantId: 'tenant-1',
|
||||
channelId: 'discord:operator',
|
||||
correlationId: 'correlation-1',
|
||||
agentName: 'Nova',
|
||||
};
|
||||
const beforeRestart = createService('admin', entries);
|
||||
const approval = await beforeRestart.createRuntimeTerminationApproval(action);
|
||||
|
||||
const afterRestart = createService('admin', entries);
|
||||
expect(
|
||||
await afterRestart.consumeRuntimeTerminationApproval(approval!.approvalId, {
|
||||
...action,
|
||||
sessionId: 'forged-session',
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(await afterRestart.consumeRuntimeTerminationApproval(approval!.approvalId, action)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(await afterRestart.consumeRuntimeTerminationApproval(approval!.approvalId, action)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,268 @@
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { eq, users as usersTable, type Db } from '@mosaicstack/db';
|
||||
import type { CommandDef, SlashCommandPayload } from '@mosaicstack/types';
|
||||
import { DB } from '../database/database.module.js';
|
||||
import { COMMANDS_REDIS } from './commands.tokens.js';
|
||||
|
||||
export type CommandRole = 'admin' | 'member' | 'viewer';
|
||||
|
||||
export interface CommandApproval {
|
||||
approvalId: string;
|
||||
actionDigest: string;
|
||||
actorId: string;
|
||||
command: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
/** Exact immutable binding for a privileged runtime termination. */
|
||||
export interface RuntimeTerminationApprovalAction {
|
||||
providerId: string;
|
||||
sessionId: string;
|
||||
actorId: string;
|
||||
tenantId: string;
|
||||
channelId: string;
|
||||
correlationId: string;
|
||||
/** Provisioned roster identity; isolates approvals between interaction agents. */
|
||||
agentName: string;
|
||||
}
|
||||
|
||||
export interface RuntimeTerminationApproval extends RuntimeTerminationApprovalAction {
|
||||
approvalId: string;
|
||||
actionDigest: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
export interface CommandAuthorizationResult {
|
||||
allowed: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CommandAuthorizationService {
|
||||
constructor(
|
||||
@Inject(DB) private readonly db: Db,
|
||||
@Inject(COMMANDS_REDIS)
|
||||
private readonly redis: {
|
||||
get(key: string): Promise<string | null>;
|
||||
set(key: string, value: string, ...args: string[]): Promise<unknown>;
|
||||
del(key: string): Promise<number>;
|
||||
},
|
||||
) {}
|
||||
|
||||
async authorize(
|
||||
command: CommandDef,
|
||||
payload: SlashCommandPayload,
|
||||
actorId: string,
|
||||
approvalId?: string,
|
||||
): Promise<CommandAuthorizationResult> {
|
||||
const role = await this.resolveRole(actorId);
|
||||
if (!role || !this.hasScope(role, command.scope)) {
|
||||
return { allowed: false, reason: 'not authorized for this command scope' };
|
||||
}
|
||||
if (command.scope !== 'admin') return { allowed: true };
|
||||
if (!approvalId) return { allowed: false, reason: 'durable approval is required' };
|
||||
const actionDigest = this.actionDigest(command.name, payload);
|
||||
const approved = await this.consumeApproval(approvalId, actorId, actionDigest);
|
||||
return approved
|
||||
? { allowed: true }
|
||||
: {
|
||||
allowed: false,
|
||||
reason: 'approval is invalid, expired, replayed, or does not match this action',
|
||||
};
|
||||
}
|
||||
|
||||
async createApproval(
|
||||
command: CommandDef,
|
||||
payload: SlashCommandPayload,
|
||||
actorId: string,
|
||||
): Promise<CommandApproval | null> {
|
||||
const role = await this.resolveRole(actorId);
|
||||
if (!role || command.scope !== 'admin' || !this.hasScope(role, command.scope)) return null;
|
||||
|
||||
const approvalId = randomUUID();
|
||||
const expiresAt = new Date(Date.now() + 5 * 60_000).toISOString();
|
||||
const approval: CommandApproval = {
|
||||
approvalId,
|
||||
actionDigest: this.actionDigest(command.name, payload),
|
||||
actorId,
|
||||
command: command.name,
|
||||
expiresAt,
|
||||
};
|
||||
await this.redis.set(this.key(approvalId), JSON.stringify(approval), 'EX', '300');
|
||||
return approval;
|
||||
}
|
||||
|
||||
/**
|
||||
* Uses the same `interaction:command-approval:*` store and one-time deletion rule as
|
||||
* command approvals. This deliberately avoids a parallel approval database.
|
||||
*/
|
||||
async createRuntimeTerminationApproval(
|
||||
action: RuntimeTerminationApprovalAction,
|
||||
): Promise<RuntimeTerminationApproval | null> {
|
||||
if (!this.hasRuntimeTerminationAction(action)) return null;
|
||||
const role = await this.resolveRole(action.actorId);
|
||||
if (role !== 'admin') return null;
|
||||
|
||||
const approval: RuntimeTerminationApproval = {
|
||||
approvalId: randomUUID(),
|
||||
actionDigest: this.runtimeActionDigest(action),
|
||||
...action,
|
||||
expiresAt: new Date(Date.now() + 5 * 60_000).toISOString(),
|
||||
};
|
||||
await this.redis.set(
|
||||
this.runtimeKey(action.agentName, approval.approvalId),
|
||||
JSON.stringify(approval),
|
||||
'EX',
|
||||
'300',
|
||||
);
|
||||
return approval;
|
||||
}
|
||||
|
||||
async consumeRuntimeTerminationApproval(
|
||||
approvalId: string,
|
||||
action: RuntimeTerminationApprovalAction,
|
||||
): Promise<boolean> {
|
||||
const encoded = await this.redis.get(this.runtimeKey(action.agentName, approvalId));
|
||||
if (!encoded) return false;
|
||||
let approval: unknown;
|
||||
try {
|
||||
approval = JSON.parse(encoded);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
!this.isRuntimeTerminationApproval(approval) ||
|
||||
approval.actionDigest !== this.runtimeActionDigest(action) ||
|
||||
approval.actorId !== action.actorId ||
|
||||
approval.tenantId !== action.tenantId ||
|
||||
!this.isUnexpired(approval.expiresAt)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if ((await this.resolveRole(approval.actorId)) !== 'admin') return false;
|
||||
return (await this.redis.del(this.runtimeKey(action.agentName, approvalId))) === 1;
|
||||
}
|
||||
|
||||
private async resolveRole(actorId: string): Promise<CommandRole | null> {
|
||||
const [user] = await this.db
|
||||
.select({ role: usersTable.role })
|
||||
.from(usersTable)
|
||||
.where(eq(usersTable.id, actorId))
|
||||
.limit(1);
|
||||
const role = user?.role;
|
||||
return role === 'admin' || role === 'member' || role === 'viewer' ? role : null;
|
||||
}
|
||||
|
||||
private hasScope(role: CommandRole, scope: CommandDef['scope']): boolean {
|
||||
if (role === 'admin') return true;
|
||||
return role === 'member' && (scope === 'core' || scope === 'agent');
|
||||
}
|
||||
|
||||
private async consumeApproval(
|
||||
approvalId: string,
|
||||
actorId: string,
|
||||
actionDigest: string,
|
||||
): Promise<boolean> {
|
||||
const key = this.key(approvalId);
|
||||
const encoded = await this.redis.get(key);
|
||||
if (!encoded) return false;
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(encoded);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
!this.isCommandApproval(parsed) ||
|
||||
parsed.actorId !== actorId ||
|
||||
parsed.actionDigest !== actionDigest ||
|
||||
!this.isUnexpired(parsed.expiresAt)
|
||||
)
|
||||
return false;
|
||||
return (await this.redis.del(key)) === 1;
|
||||
}
|
||||
|
||||
private actionDigest(command: string, payload: SlashCommandPayload): string {
|
||||
return createHash('sha256')
|
||||
.update(
|
||||
JSON.stringify({
|
||||
command,
|
||||
args: payload.args?.trim() ?? '',
|
||||
conversationId: payload.conversationId,
|
||||
}),
|
||||
)
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
private hasRuntimeTerminationAction(action: RuntimeTerminationApprovalAction): boolean {
|
||||
return [
|
||||
action.providerId,
|
||||
action.sessionId,
|
||||
action.actorId,
|
||||
action.tenantId,
|
||||
action.channelId,
|
||||
action.correlationId,
|
||||
action.agentName,
|
||||
].every((value: string): boolean => value.trim().length > 0);
|
||||
}
|
||||
|
||||
private runtimeActionDigest(action: RuntimeTerminationApprovalAction): string {
|
||||
return createHash('sha256')
|
||||
.update(
|
||||
JSON.stringify({
|
||||
providerId: action.providerId,
|
||||
sessionId: action.sessionId,
|
||||
actorId: action.actorId,
|
||||
tenantId: action.tenantId,
|
||||
channelId: action.channelId,
|
||||
correlationId: action.correlationId,
|
||||
agentName: action.agentName,
|
||||
}),
|
||||
)
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
private isUnexpired(expiresAt: unknown): expiresAt is string {
|
||||
if (typeof expiresAt !== 'string') return false;
|
||||
const expiresAtMs = Date.parse(expiresAt);
|
||||
return Number.isFinite(expiresAtMs) && expiresAtMs > Date.now();
|
||||
}
|
||||
|
||||
private isCommandApproval(value: unknown): value is CommandApproval {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
'approvalId' in value &&
|
||||
'actionDigest' in value &&
|
||||
'actorId' in value &&
|
||||
'expiresAt' in value &&
|
||||
'command' in value
|
||||
);
|
||||
}
|
||||
|
||||
private isRuntimeTerminationApproval(value: unknown): value is RuntimeTerminationApproval {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
'approvalId' in value &&
|
||||
'actionDigest' in value &&
|
||||
'actorId' in value &&
|
||||
'tenantId' in value &&
|
||||
'providerId' in value &&
|
||||
'sessionId' in value &&
|
||||
'channelId' in value &&
|
||||
'correlationId' in value &&
|
||||
'agentName' in value &&
|
||||
'expiresAt' in value
|
||||
);
|
||||
}
|
||||
|
||||
private key(approvalId: string): string {
|
||||
return `interaction:command-approval:${approvalId}`;
|
||||
}
|
||||
|
||||
private runtimeKey(agentName: string, approvalId: string): string {
|
||||
return `agent:${encodeURIComponent(agentName)}:command-approval:${approvalId}`;
|
||||
}
|
||||
}
|
||||
@@ -89,6 +89,7 @@ function buildService(): CommandExecutorService {
|
||||
describe('CommandExecutorService — P8-012 commands', () => {
|
||||
let service: CommandExecutorService;
|
||||
const userId = 'user-123';
|
||||
const userScope = { userId, tenantId: userId };
|
||||
const conversationId = 'conv-456';
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -99,31 +100,26 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
||||
// /provider login — missing provider name
|
||||
it('/provider login with no provider name returns usage error', async () => {
|
||||
const payload: SlashCommandPayload = { command: 'provider', args: 'login', conversationId };
|
||||
const result = await service.execute(payload, userId);
|
||||
const result = await service.execute(payload, userScope);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('Usage: /provider login');
|
||||
expect(result.command).toBe('provider');
|
||||
});
|
||||
|
||||
// /provider login anthropic — success with URL containing poll token
|
||||
it('/provider login <name> returns success with URL and poll token', async () => {
|
||||
// /provider login anthropic — no bearer token or auth URL reaches chat output
|
||||
it('/provider login <name> keeps its one-time token out of chat output', async () => {
|
||||
const payload: SlashCommandPayload = {
|
||||
command: 'provider',
|
||||
args: 'login anthropic',
|
||||
conversationId,
|
||||
};
|
||||
const result = await service.execute(payload, userId);
|
||||
const result = await service.execute(payload, userScope);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.command).toBe('provider');
|
||||
expect(result.message).toContain('anthropic');
|
||||
expect(result.message).toContain('http');
|
||||
// data should contain loginUrl and pollToken
|
||||
expect(result.data).toBeDefined();
|
||||
const data = result.data as Record<string, unknown>;
|
||||
expect(typeof data['loginUrl']).toBe('string');
|
||||
expect(typeof data['pollToken']).toBe('string');
|
||||
expect(data['loginUrl'] as string).toContain('anthropic');
|
||||
expect(data['loginUrl'] as string).toContain(data['pollToken'] as string);
|
||||
expect(result.message).not.toContain('http');
|
||||
expect(result.message).not.toContain('token=');
|
||||
expect(result.data).toEqual({ provider: 'anthropic' });
|
||||
// Verify Valkey was called
|
||||
expect(mockRedis.set).toHaveBeenCalledOnce();
|
||||
const [key, value, , ttl] = mockRedis.set.mock.calls[0] as [string, string, string, number];
|
||||
@@ -138,7 +134,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
||||
// /provider with no args — returns usage
|
||||
it('/provider with no args returns usage message', async () => {
|
||||
const payload: SlashCommandPayload = { command: 'provider', conversationId };
|
||||
const result = await service.execute(payload, userId);
|
||||
const result = await service.execute(payload, userScope);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toContain('Usage: /provider');
|
||||
});
|
||||
@@ -146,7 +142,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
||||
// /provider list
|
||||
it('/provider list returns success', async () => {
|
||||
const payload: SlashCommandPayload = { command: 'provider', args: 'list', conversationId };
|
||||
const result = await service.execute(payload, userId);
|
||||
const result = await service.execute(payload, userScope);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.command).toBe('provider');
|
||||
});
|
||||
@@ -154,7 +150,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
||||
// /provider logout with no name — usage error
|
||||
it('/provider logout with no name returns error', async () => {
|
||||
const payload: SlashCommandPayload = { command: 'provider', args: 'logout', conversationId };
|
||||
const result = await service.execute(payload, userId);
|
||||
const result = await service.execute(payload, userScope);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('Usage: /provider logout');
|
||||
});
|
||||
@@ -166,7 +162,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
||||
args: 'unknown',
|
||||
conversationId,
|
||||
};
|
||||
const result = await service.execute(payload, userId);
|
||||
const result = await service.execute(payload, userScope);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('Unknown subcommand');
|
||||
});
|
||||
@@ -174,7 +170,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
||||
// /mission status
|
||||
it('/mission status returns stub message', async () => {
|
||||
const payload: SlashCommandPayload = { command: 'mission', args: 'status', conversationId };
|
||||
const result = await service.execute(payload, userId);
|
||||
const result = await service.execute(payload, userScope);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.command).toBe('mission');
|
||||
expect(result.message).toContain('Mission status');
|
||||
@@ -183,7 +179,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
||||
// /mission with no args
|
||||
it('/mission with no args returns status stub', async () => {
|
||||
const payload: SlashCommandPayload = { command: 'mission', conversationId };
|
||||
const result = await service.execute(payload, userId);
|
||||
const result = await service.execute(payload, userScope);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toContain('Mission status');
|
||||
});
|
||||
@@ -195,7 +191,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
||||
args: 'set my-mission-123',
|
||||
conversationId,
|
||||
};
|
||||
const result = await service.execute(payload, userId);
|
||||
const result = await service.execute(payload, userScope);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toContain('my-mission-123');
|
||||
});
|
||||
@@ -203,7 +199,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
||||
// /agent list
|
||||
it('/agent list returns stub message', async () => {
|
||||
const payload: SlashCommandPayload = { command: 'agent', args: 'list', conversationId };
|
||||
const result = await service.execute(payload, userId);
|
||||
const result = await service.execute(payload, userScope);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.command).toBe('agent');
|
||||
expect(result.message).toContain('agent');
|
||||
@@ -212,7 +208,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
||||
// /agent with no args
|
||||
it('/agent with no args returns usage', async () => {
|
||||
const payload: SlashCommandPayload = { command: 'agent', conversationId };
|
||||
const result = await service.execute(payload, userId);
|
||||
const result = await service.execute(payload, userScope);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toContain('Usage: /agent');
|
||||
});
|
||||
@@ -224,7 +220,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
||||
args: 'my-agent-id',
|
||||
conversationId,
|
||||
};
|
||||
const result = await service.execute(payload, userId);
|
||||
const result = await service.execute(payload, userScope);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toContain('my-agent-id');
|
||||
});
|
||||
@@ -232,7 +228,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
||||
// /prdy
|
||||
it('/prdy returns PRD wizard message', async () => {
|
||||
const payload: SlashCommandPayload = { command: 'prdy', conversationId };
|
||||
const result = await service.execute(payload, userId);
|
||||
const result = await service.execute(payload, userScope);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.command).toBe('prdy');
|
||||
expect(result.message).toContain('mosaic prdy');
|
||||
@@ -241,7 +237,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
||||
// /tools
|
||||
it('/tools returns tools stub message', async () => {
|
||||
const payload: SlashCommandPayload = { command: 'tools', conversationId };
|
||||
const result = await service.execute(payload, userId);
|
||||
const result = await service.execute(payload, userScope);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.command).toBe('tools');
|
||||
expect(result.message).toContain('tools');
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { SlashCommandPayload } from '@mosaicstack/types';
|
||||
import { CommandAuthorizationService } from './command-authorization.service.js';
|
||||
import { CommandExecutorService } from './command-executor.service.js';
|
||||
|
||||
const registry = {
|
||||
getManifest: vi.fn(() => ({
|
||||
version: 1,
|
||||
commands: [
|
||||
{
|
||||
name: 'gc',
|
||||
description: 'System-wide garbage collection',
|
||||
aliases: [],
|
||||
scope: 'admin' as const,
|
||||
execution: 'socket' as const,
|
||||
available: true,
|
||||
},
|
||||
],
|
||||
skills: [],
|
||||
})),
|
||||
};
|
||||
|
||||
const sessionGc = {
|
||||
sweepOrphans: vi.fn().mockResolvedValue({ orphanedSessions: 1, totalCleaned: [], duration: 1 }),
|
||||
};
|
||||
|
||||
const scope = (userId: string) => ({ userId, tenantId: 'tenant-1' });
|
||||
|
||||
const authorization = {
|
||||
authorize: vi.fn((_command: unknown, _payload: unknown, actorId: string) =>
|
||||
Promise.resolve(
|
||||
actorId === 'member-1'
|
||||
? { allowed: false, reason: 'durable approval is required' }
|
||||
: { allowed: false, reason: 'not authorized for this command scope' },
|
||||
),
|
||||
),
|
||||
};
|
||||
|
||||
function buildExecutor(authorizationService: unknown = authorization): CommandExecutorService {
|
||||
return new CommandExecutorService(
|
||||
registry as never,
|
||||
{ getSession: vi.fn() } as never,
|
||||
{ clear: vi.fn(), set: vi.fn() } as never,
|
||||
sessionGc as never,
|
||||
{ set: vi.fn() } as never,
|
||||
{ agents: {} } as never,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
authorizationService as never,
|
||||
);
|
||||
}
|
||||
|
||||
function createDurableAuthorization(): CommandAuthorizationService {
|
||||
const entries = new Map<string, string>();
|
||||
const db = {
|
||||
select: () => ({ from: () => ({ where: () => ({ limit: async () => [{ role: 'admin' }] }) }) }),
|
||||
};
|
||||
const redis = {
|
||||
get: async (key: string) => entries.get(key) ?? null,
|
||||
set: async (key: string, value: string) => {
|
||||
entries.set(key, value);
|
||||
},
|
||||
del: async (key: string) => Number(entries.delete(key)),
|
||||
};
|
||||
return new CommandAuthorizationService(db as never, redis);
|
||||
}
|
||||
|
||||
describe('TESS-M1-SEC-001 command authorization abuse cases', () => {
|
||||
const payload: SlashCommandPayload = { command: 'gc', conversationId: 'conversation-1' };
|
||||
|
||||
beforeEach((): void => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('denies a forged admin identity and does not execute a system-wide command', async (): Promise<void> => {
|
||||
const result = await buildExecutor().execute(payload, scope('admin-forged-by-client'));
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('not authorized');
|
||||
expect(sessionGc.sweepOrphans).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('denies a privileged command without a server-bound durable approval', async (): Promise<void> => {
|
||||
const result = await buildExecutor().execute(payload, scope('member-1'));
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('approval');
|
||||
expect(sessionGc.sweepOrphans).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('executes an admin command only after a valid durable approval is issued and supplied', async (): Promise<void> => {
|
||||
const executor = buildExecutor(createDurableAuthorization());
|
||||
|
||||
const adminScope = scope('admin-1');
|
||||
const denied = await executor.execute(payload, adminScope);
|
||||
const approval = await executor.createApproval(payload, adminScope);
|
||||
const approved = await executor.execute(
|
||||
{ ...payload, approvalId: approval?.approvalId },
|
||||
adminScope,
|
||||
);
|
||||
|
||||
expect(denied.success).toBe(false);
|
||||
expect(denied.message).toContain('approval');
|
||||
expect(approval).not.toBeNull();
|
||||
// A valid durable approval is consumed, but cannot authorize an unimplemented
|
||||
// global retention operation. Session-scoped cleanup remains lifecycle-only.
|
||||
expect(approved.success).toBe(false);
|
||||
expect(approved.message).toContain('Global GC is disabled');
|
||||
expect(sessionGc.sweepOrphans).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import type { QueueHandle } from '@mosaicstack/queue';
|
||||
import type { Brain } from '@mosaicstack/brain';
|
||||
import type { SlashCommandPayload, SlashCommandResultPayload } from '@mosaicstack/types';
|
||||
import { AgentService } from '../agent/agent.service.js';
|
||||
import type { ActorTenantScope } from '../auth/session-scope.js';
|
||||
import { ChatGateway } from '../chat/chat.gateway.js';
|
||||
import { SessionGCService } from '../gc/session-gc.service.js';
|
||||
import { SystemOverrideService } from '../preferences/system-override.service.js';
|
||||
@@ -10,6 +11,7 @@ import { ReloadService } from '../reload/reload.service.js';
|
||||
import { McpClientService } from '../mcp-client/mcp-client.service.js';
|
||||
import { BRAIN } from '../brain/brain.tokens.js';
|
||||
import { COMMANDS_REDIS } from './commands.tokens.js';
|
||||
import { CommandAuthorizationService } from './command-authorization.service.js';
|
||||
import { CommandRegistryService } from './command-registry.service.js';
|
||||
|
||||
@Injectable()
|
||||
@@ -35,10 +37,17 @@ export class CommandExecutorService {
|
||||
@Optional()
|
||||
@Inject(McpClientService)
|
||||
private readonly mcpClient: McpClientService | null,
|
||||
@Optional()
|
||||
@Inject(CommandAuthorizationService)
|
||||
private readonly authorization: CommandAuthorizationService | null = null,
|
||||
) {}
|
||||
|
||||
async execute(payload: SlashCommandPayload, userId: string): Promise<SlashCommandResultPayload> {
|
||||
async execute(
|
||||
payload: SlashCommandPayload,
|
||||
scope: ActorTenantScope,
|
||||
): Promise<SlashCommandResultPayload> {
|
||||
const { command, args, conversationId } = payload;
|
||||
const userId = scope.userId;
|
||||
|
||||
const def = this.registry.getManifest().commands.find((c) => c.name === command);
|
||||
if (!def) {
|
||||
@@ -50,14 +59,24 @@ export class CommandExecutorService {
|
||||
};
|
||||
}
|
||||
|
||||
const authorization = await this.authorization?.authorize(
|
||||
def,
|
||||
payload,
|
||||
userId,
|
||||
payload.approvalId,
|
||||
);
|
||||
if (authorization && !authorization.allowed) {
|
||||
return { command, conversationId, success: false, message: authorization.reason };
|
||||
}
|
||||
|
||||
try {
|
||||
switch (command) {
|
||||
case 'model':
|
||||
return await this.handleModel(args ?? null, conversationId);
|
||||
return await this.handleModel(args ?? null, conversationId, scope);
|
||||
case 'thinking':
|
||||
return await this.handleThinking(args ?? null, conversationId);
|
||||
case 'system':
|
||||
return await this.handleSystem(args ?? null, conversationId);
|
||||
return await this.handleSystem(args ?? null, conversationId, scope);
|
||||
case 'new':
|
||||
return {
|
||||
command,
|
||||
@@ -86,18 +105,17 @@ export class CommandExecutorService {
|
||||
success: true,
|
||||
message: 'Retry last message requested.',
|
||||
};
|
||||
case 'gc': {
|
||||
// Admin-only: system-wide GC sweep across all sessions
|
||||
const result = await this.sessionGC.sweepOrphans();
|
||||
case 'gc':
|
||||
// Global retention requires a separate, authorized and audited job.
|
||||
// Session cleanup is performed only through the session lifecycle.
|
||||
return {
|
||||
command: 'gc',
|
||||
success: true,
|
||||
message: `GC sweep complete: ${result.orphanedSessions} orphaned sessions cleaned in ${result.duration}ms.`,
|
||||
success: false,
|
||||
message: 'Global GC is disabled pending an authorized retention job.',
|
||||
conversationId,
|
||||
};
|
||||
}
|
||||
case 'agent':
|
||||
return await this.handleAgent(args ?? null, conversationId, userId);
|
||||
return await this.handleAgent(args ?? null, conversationId, scope);
|
||||
case 'provider':
|
||||
return await this.handleProvider(args ?? null, userId, conversationId);
|
||||
case 'mission':
|
||||
@@ -146,13 +164,22 @@ export class CommandExecutorService {
|
||||
}
|
||||
}
|
||||
|
||||
async createApproval(payload: SlashCommandPayload, scope: ActorTenantScope) {
|
||||
const def = this.registry
|
||||
.getManifest()
|
||||
.commands.find((command) => command.name === payload.command);
|
||||
if (!def || !this.authorization) return null;
|
||||
return this.authorization.createApproval(def, payload, scope.userId);
|
||||
}
|
||||
|
||||
private async handleModel(
|
||||
args: string | null,
|
||||
conversationId: string,
|
||||
scope: ActorTenantScope,
|
||||
): Promise<SlashCommandResultPayload> {
|
||||
if (!args || args.trim().length === 0) {
|
||||
// Show current override or usage hint
|
||||
const currentOverride = this.chatGateway?.getModelOverride(conversationId);
|
||||
const currentOverride = this.chatGateway?.getModelOverride(conversationId, scope);
|
||||
if (currentOverride) {
|
||||
return {
|
||||
command: 'model',
|
||||
@@ -174,7 +201,7 @@ export class CommandExecutorService {
|
||||
|
||||
// /model clear removes the override and re-enables automatic routing
|
||||
if (modelName === 'clear') {
|
||||
this.chatGateway?.setModelOverride(conversationId, null);
|
||||
this.chatGateway?.setModelOverride(conversationId, null, scope);
|
||||
return {
|
||||
command: 'model',
|
||||
conversationId,
|
||||
@@ -184,9 +211,9 @@ export class CommandExecutorService {
|
||||
}
|
||||
|
||||
// Set the sticky per-session override (M4-007)
|
||||
this.chatGateway?.setModelOverride(conversationId, modelName);
|
||||
this.chatGateway?.setModelOverride(conversationId, modelName, scope);
|
||||
|
||||
const session = this.agentService.getSession(conversationId);
|
||||
const session = this.agentService.getSession(conversationId, scope);
|
||||
if (!session) {
|
||||
return {
|
||||
command: 'model',
|
||||
@@ -227,10 +254,11 @@ export class CommandExecutorService {
|
||||
private async handleSystem(
|
||||
args: string | null,
|
||||
conversationId: string,
|
||||
scope: ActorTenantScope,
|
||||
): Promise<SlashCommandResultPayload> {
|
||||
if (!args || args.trim().length === 0) {
|
||||
// Clear the override when called with no args
|
||||
await this.systemOverride.clear(conversationId);
|
||||
await this.systemOverride.clear(conversationId, scope);
|
||||
return {
|
||||
command: 'system',
|
||||
conversationId,
|
||||
@@ -239,7 +267,7 @@ export class CommandExecutorService {
|
||||
};
|
||||
}
|
||||
|
||||
await this.systemOverride.set(conversationId, args.trim());
|
||||
await this.systemOverride.set(conversationId, args.trim(), scope);
|
||||
return {
|
||||
command: 'system',
|
||||
conversationId,
|
||||
@@ -251,8 +279,9 @@ export class CommandExecutorService {
|
||||
private async handleAgent(
|
||||
args: string | null,
|
||||
conversationId: string,
|
||||
userId: string,
|
||||
scope: ActorTenantScope,
|
||||
): Promise<SlashCommandResultPayload> {
|
||||
const userId = scope.userId;
|
||||
if (!args) {
|
||||
return {
|
||||
command: 'agent',
|
||||
@@ -341,11 +370,14 @@ export class CommandExecutorService {
|
||||
conversationId,
|
||||
agentConfig.id,
|
||||
agentConfig.name,
|
||||
scope,
|
||||
agentConfig.model ?? undefined,
|
||||
);
|
||||
|
||||
// Broadcast updated session:info so TUI TopBar reflects new agent/model
|
||||
this.chatGateway?.broadcastSessionInfo(conversationId, { agentName: agentConfig.name });
|
||||
this.chatGateway?.broadcastSessionInfo(conversationId, scope, {
|
||||
agentName: agentConfig.name,
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
`Agent switched to "${agentConfig.name}" (${agentConfig.id}) for conversation ${conversationId} (M5-003)`,
|
||||
@@ -406,24 +438,30 @@ export class CommandExecutorService {
|
||||
};
|
||||
}
|
||||
const pollToken = crypto.randomUUID();
|
||||
const pollKey = `mosaic:auth:poll:${pollToken}`;
|
||||
const tokenDigest = await crypto.subtle.digest(
|
||||
'SHA-256',
|
||||
new TextEncoder().encode(pollToken),
|
||||
);
|
||||
const tokenHash = Array.from(new Uint8Array(tokenDigest), (byte: number): string =>
|
||||
byte.toString(16).padStart(2, '0'),
|
||||
).join('');
|
||||
const key = `mosaic:auth:poll:${tokenHash}`;
|
||||
if (this.redis) {
|
||||
// Store pending state in Valkey (TTL 5 minutes)
|
||||
// Persist only a short-lived token digest. The raw token is delivered only by
|
||||
// the authenticated dashboard flow, never in chat output or command metadata.
|
||||
await this.redis.set(
|
||||
pollKey,
|
||||
key,
|
||||
JSON.stringify({ status: 'pending', provider: providerName, userId }),
|
||||
'EX',
|
||||
300,
|
||||
);
|
||||
}
|
||||
// In production this would construct an OAuth URL
|
||||
const loginUrl = `${process.env['MOSAIC_BASE_URL'] ?? 'http://localhost:3000'}/auth/provider/${providerName}?token=${pollToken}`;
|
||||
return {
|
||||
command: 'provider',
|
||||
success: true,
|
||||
message: `Open this URL to authenticate with ${providerName}:\n${loginUrl}`,
|
||||
message: `Provider login for ${providerName} is ready. Continue in the authenticated dashboard.`,
|
||||
conversationId,
|
||||
data: { loginUrl, pollToken, provider: providerName },
|
||||
data: { provider: providerName },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -159,6 +159,7 @@ describe('CommandExecutorService — integration', () => {
|
||||
let registry: CommandRegistryService;
|
||||
let executor: CommandExecutorService;
|
||||
const userId = 'user-integ-001';
|
||||
const userScope = { userId, tenantId: userId };
|
||||
const conversationId = 'conv-integ-001';
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -170,28 +171,26 @@ describe('CommandExecutorService — integration', () => {
|
||||
// Unknown command returns error
|
||||
it('unknown command returns success:false with descriptive message', async () => {
|
||||
const payload: SlashCommandPayload = { command: 'nonexistent', conversationId };
|
||||
const result = await executor.execute(payload, userId);
|
||||
const result = await executor.execute(payload, userScope);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('nonexistent');
|
||||
expect(result.command).toBe('nonexistent');
|
||||
});
|
||||
|
||||
// /gc handler calls SessionGCService.sweepOrphans (admin-only, no userId arg)
|
||||
it('/gc calls SessionGCService.sweepOrphans without arguments', async () => {
|
||||
it('/gc refuses an unaudited global sweep', async () => {
|
||||
const payload: SlashCommandPayload = { command: 'gc', conversationId };
|
||||
const result = await executor.execute(payload, userId);
|
||||
expect(mockSessionGC.sweepOrphans).toHaveBeenCalledWith();
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toContain('GC sweep complete');
|
||||
expect(result.message).toContain('3 orphaned sessions');
|
||||
const result = await executor.execute(payload, userScope);
|
||||
expect(mockSessionGC.sweepOrphans).not.toHaveBeenCalled();
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('disabled pending an authorized retention job');
|
||||
});
|
||||
|
||||
// /system with args calls SystemOverrideService.set
|
||||
it('/system with text calls SystemOverrideService.set', async () => {
|
||||
const override = 'You are a helpful assistant.';
|
||||
const payload: SlashCommandPayload = { command: 'system', args: override, conversationId };
|
||||
const result = await executor.execute(payload, userId);
|
||||
expect(mockSystemOverride.set).toHaveBeenCalledWith(conversationId, override);
|
||||
const result = await executor.execute(payload, userScope);
|
||||
expect(mockSystemOverride.set).toHaveBeenCalledWith(conversationId, override, userScope);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toContain('override set');
|
||||
});
|
||||
@@ -199,8 +198,8 @@ describe('CommandExecutorService — integration', () => {
|
||||
// /system with no args clears the override
|
||||
it('/system with no args calls SystemOverrideService.clear', async () => {
|
||||
const payload: SlashCommandPayload = { command: 'system', conversationId };
|
||||
const result = await executor.execute(payload, userId);
|
||||
expect(mockSystemOverride.clear).toHaveBeenCalledWith(conversationId);
|
||||
const result = await executor.execute(payload, userScope);
|
||||
expect(mockSystemOverride.clear).toHaveBeenCalledWith(conversationId, userScope);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toContain('cleared');
|
||||
});
|
||||
@@ -212,7 +211,7 @@ describe('CommandExecutorService — integration', () => {
|
||||
args: 'claude-3-opus',
|
||||
conversationId,
|
||||
};
|
||||
const result = await executor.execute(payload, userId);
|
||||
const result = await executor.execute(payload, userScope);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.command).toBe('model');
|
||||
expect(result.message).toContain('claude-3-opus');
|
||||
@@ -221,7 +220,7 @@ describe('CommandExecutorService — integration', () => {
|
||||
// /thinking with valid level returns success
|
||||
it('/thinking with valid level returns success', async () => {
|
||||
const payload: SlashCommandPayload = { command: 'thinking', args: 'high', conversationId };
|
||||
const result = await executor.execute(payload, userId);
|
||||
const result = await executor.execute(payload, userScope);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toContain('high');
|
||||
});
|
||||
@@ -229,7 +228,7 @@ describe('CommandExecutorService — integration', () => {
|
||||
// /thinking with invalid level returns usage message
|
||||
it('/thinking with invalid level returns usage message', async () => {
|
||||
const payload: SlashCommandPayload = { command: 'thinking', args: 'invalid', conversationId };
|
||||
const result = await executor.execute(payload, userId);
|
||||
const result = await executor.execute(payload, userScope);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toContain('Usage:');
|
||||
});
|
||||
@@ -237,7 +236,7 @@ describe('CommandExecutorService — integration', () => {
|
||||
// /new command returns success
|
||||
it('/new returns success', async () => {
|
||||
const payload: SlashCommandPayload = { command: 'new', conversationId };
|
||||
const result = await executor.execute(payload, userId);
|
||||
const result = await executor.execute(payload, userScope);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.command).toBe('new');
|
||||
});
|
||||
@@ -245,7 +244,7 @@ describe('CommandExecutorService — integration', () => {
|
||||
// /reload without reloadService returns failure
|
||||
it('/reload without ReloadService returns failure', async () => {
|
||||
const payload: SlashCommandPayload = { command: 'reload', conversationId };
|
||||
const result = await executor.execute(payload, userId);
|
||||
const result = await executor.execute(payload, userScope);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('ReloadService');
|
||||
});
|
||||
@@ -255,7 +254,7 @@ describe('CommandExecutorService — integration', () => {
|
||||
for (const cmd of stubCommands) {
|
||||
it(`/${cmd} returns success (stub)`, async () => {
|
||||
const payload: SlashCommandPayload = { command: cmd, conversationId };
|
||||
const result = await executor.execute(payload, userId);
|
||||
const result = await executor.execute(payload, userScope);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.command).toBe(cmd);
|
||||
});
|
||||
|
||||
@@ -5,8 +5,10 @@ import { MOSAIC_CONFIG } from '../config/config.module.js';
|
||||
import { ChatModule } from '../chat/chat.module.js';
|
||||
import { GCModule } from '../gc/gc.module.js';
|
||||
import { ReloadModule } from '../reload/reload.module.js';
|
||||
import { CommandAuthorizationService } from './command-authorization.service.js';
|
||||
import { CommandExecutorService } from './command-executor.service.js';
|
||||
import { CommandRegistryService } from './command-registry.service.js';
|
||||
import { CommandRuntimeApprovalVerifier } from './runtime-approval-verifier.js';
|
||||
import { COMMANDS_REDIS } from './commands.tokens.js';
|
||||
|
||||
const COMMANDS_QUEUE_HANDLE = 'COMMANDS_QUEUE_HANDLE';
|
||||
@@ -30,9 +32,16 @@ const COMMANDS_QUEUE_HANDLE = 'COMMANDS_QUEUE_HANDLE';
|
||||
inject: [COMMANDS_QUEUE_HANDLE],
|
||||
},
|
||||
CommandRegistryService,
|
||||
CommandAuthorizationService,
|
||||
CommandRuntimeApprovalVerifier,
|
||||
CommandExecutorService,
|
||||
],
|
||||
exports: [
|
||||
CommandRegistryService,
|
||||
CommandAuthorizationService,
|
||||
CommandRuntimeApprovalVerifier,
|
||||
CommandExecutorService,
|
||||
],
|
||||
exports: [CommandRegistryService, CommandExecutorService],
|
||||
})
|
||||
export class CommandsModule implements OnApplicationShutdown {
|
||||
constructor(
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type {
|
||||
RuntimeApprovalVerifier,
|
||||
RuntimeTerminationAction,
|
||||
} from '../agent/runtime-provider-registry.service.js';
|
||||
import { CommandAuthorizationService } from './command-authorization.service.js';
|
||||
|
||||
/**
|
||||
* Adapter from the provider registry's exact termination action to the shared,
|
||||
* Redis-backed `interaction:command-approval:*` store. It has no separate approval
|
||||
* persistence or replay semantics.
|
||||
*/
|
||||
@Injectable()
|
||||
export class CommandRuntimeApprovalVerifier implements RuntimeApprovalVerifier {
|
||||
constructor(
|
||||
@Inject(CommandAuthorizationService)
|
||||
private readonly authorization: CommandAuthorizationService,
|
||||
) {}
|
||||
|
||||
async consume(approvalRef: string, action: RuntimeTerminationAction): Promise<boolean> {
|
||||
return this.authorization.consumeRuntimeTerminationApproval(approvalRef, action);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user