feat(tess): persist durable session state (#729)
This commit was merged in pull request #729.
This commit is contained in:
@@ -12,8 +12,10 @@ const adminCommand: CommandDef = {
|
||||
};
|
||||
const payload: SlashCommandPayload = { command: 'gc', conversationId: 'conversation-1' };
|
||||
|
||||
function createService(role: string): CommandAuthorizationService {
|
||||
const entries = new Map<string, string>();
|
||||
function createService(
|
||||
role: string,
|
||||
entries: Map<string, string> = new Map<string, string>(),
|
||||
): CommandAuthorizationService {
|
||||
const db = {
|
||||
select: () => ({ from: () => ({ where: () => ({ limit: async () => [{ role }] }) }) }),
|
||||
};
|
||||
@@ -58,4 +60,57 @@ describe('CommandAuthorizationService', () => {
|
||||
(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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,24 @@ export interface CommandApproval {
|
||||
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;
|
||||
@@ -75,6 +93,57 @@ export class CommandAuthorizationService {
|
||||
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 })
|
||||
@@ -98,12 +167,17 @@ export class CommandAuthorizationService {
|
||||
const key = this.key(approvalId);
|
||||
const encoded = await this.redis.get(key);
|
||||
if (!encoded) return false;
|
||||
const parsed: unknown = JSON.parse(encoded);
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(encoded);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
!this.isApproval(parsed) ||
|
||||
!this.isCommandApproval(parsed) ||
|
||||
parsed.actorId !== actorId ||
|
||||
parsed.actionDigest !== actionDigest ||
|
||||
Date.parse(parsed.expiresAt) <= Date.now()
|
||||
!this.isUnexpired(parsed.expiresAt)
|
||||
)
|
||||
return false;
|
||||
return (await this.redis.del(key)) === 1;
|
||||
@@ -121,18 +195,74 @@ export class CommandAuthorizationService {
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
private isApproval(value: unknown): value is CommandApproval {
|
||||
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 `tess:command-approval:${approvalId}`;
|
||||
return `interaction:command-approval:${approvalId}`;
|
||||
}
|
||||
|
||||
private runtimeKey(agentName: string, approvalId: string): string {
|
||||
return `agent:${encodeURIComponent(agentName)}:command-approval:${approvalId}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ 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';
|
||||
@@ -26,9 +27,15 @@ const COMMANDS_QUEUE_HANDLE = 'COMMANDS_QUEUE_HANDLE';
|
||||
},
|
||||
CommandRegistryService,
|
||||
CommandAuthorizationService,
|
||||
CommandRuntimeApprovalVerifier,
|
||||
CommandExecutorService,
|
||||
],
|
||||
exports: [
|
||||
CommandRegistryService,
|
||||
CommandAuthorizationService,
|
||||
CommandRuntimeApprovalVerifier,
|
||||
CommandExecutorService,
|
||||
],
|
||||
exports: [CommandRegistryService, CommandExecutorService],
|
||||
})
|
||||
export class CommandsModule implements OnApplicationShutdown {
|
||||
constructor(@Inject(COMMANDS_QUEUE_HANDLE) private readonly handle: QueueHandle) {}
|
||||
|
||||
23
apps/gateway/src/commands/runtime-approval-verifier.ts
Normal file
23
apps/gateway/src/commands/runtime-approval-verifier.ts
Normal file
@@ -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