feat: harden chat state by tenant

This commit is contained in:
thanhnv
2026-07-08 23:38:22 +09:00
parent b3b0544ba8
commit c800f7edf7
20 changed files with 380 additions and 54 deletions
@@ -18,8 +18,8 @@ export class ChatController {
}
@Get('replay')
replay(@Query('chatId') chatId?: string, @Query('turnId') turnId?: string) {
return ok(this.svc.replay(chatId || '', turnId || ''));
replay(@Query('chatId') chatId?: string, @Query('turnId') turnId?: string, @Query('tenant') tenant?: string) {
return ok(this.svc.replay(chatId || '', turnId || '', tenant || ''));
}
@Get('actions')
@@ -25,13 +25,13 @@ const AGENT_CLI = join(HARNESS_BIN, 'chat-agent-resolver.py');
const REPLAY_CLI = join(HARNESS_BIN, 'chat-replay.py');
const RBAC_CLI = join(HARNESS_BIN, 'rbac-check.py');
function runPython(script: string, args: string[]): CommandResult {
function runPython(script: string, args: string[], extraEnv: NodeJS.ProcessEnv = {}): CommandResult {
try {
const stdout = execFileSync('python3', [script, ...args], {
cwd: APP_ROOT,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
env: process.env,
env: { ...process.env, ...extraEnv },
});
return { status: 0, stdout: stdout.trim(), stderr: '' };
} catch (err: any) {
@@ -94,11 +94,12 @@ export class ChatService {
return { ok: res.status === 0, output: res.stdout || res.stderr };
}
replay(chatId = '', turnId = '') {
replay(chatId = '', turnId = '', tenant = '') {
const args = ['replay'];
if (chatId) args.push('--chat-id', chatId);
if (turnId) args.push('--turn-id', turnId);
const res = runPython(REPLAY_CLI, args);
const env = tenant && tenant !== 'default' ? { CASAN_TENANT_ID: tenant } : {};
const res = runPython(REPLAY_CLI, args, env);
const parsed = parseJson<Record<string, any>>(res.stdout);
if (parsed) return { ok: res.status === 0, ...parsed };
throw new InternalServerErrorException(res.stderr || res.stdout || 'CHAT_REPLAY_FAILED');
@@ -15,9 +15,11 @@ function withTempChatState(fn: () => void) {
CASAN_CHAT_AUDIT_HEAD: process.env.CASAN_CHAT_AUDIT_HEAD,
CASAN_CHAT_METRICS_LOG: process.env.CASAN_CHAT_METRICS_LOG,
CASAN_APPROVAL_INBOX_FILE: process.env.CASAN_APPROVAL_INBOX_FILE,
CASAN_TENANT_STATE_ROOT: process.env.CASAN_TENANT_STATE_ROOT,
};
const state = mkdtempSync(join(tmpdir(), 'cp-chat-'));
process.env.CASAN_STATE_ROOT = state;
process.env.CASAN_TENANT_STATE_ROOT = join(state, 'tenants');
process.env.CASAN_APPROVAL_INBOX_FILE = join(state, 'approval-inbox.json');
delete process.env.CASAN_CHAT_AUDIT_LOG;
delete process.env.CASAN_CHAT_AUDIT_HEAD;
@@ -135,3 +137,22 @@ test('chat ask escalates delegation approval into approval inbox', () => {
assert.equal(inbox.proposals[0].payload.agent_binding.decision, 'REQUIRES_APPROVAL');
});
});
test('chat replay is partitioned by non-default tenant', () => {
withTempChatState(() => {
const svc = new ChatService();
const alpha = { ...viewer, actor: 'tenant-alpha', tenant: 'alpha' };
const res = svc.ask({ message: 'Summarize Plan 18 MVP-2 status', chatId: 'tenant-chat' }, alpha) as any;
assert.equal(res.success, true);
assert.equal(res.audit_verify.ok, true);
const alphaReplay = svc.replay('tenant-chat', '', 'alpha') as any;
assert.equal(alphaReplay.ok, true);
assert.equal(alphaReplay.decision, 'MATCH');
assert.equal(alphaReplay.records, 1);
const betaReplay = svc.replay('tenant-chat', '', 'beta') as any;
assert.equal(betaReplay.ok, true);
assert.equal(betaReplay.records, 0);
});
});