feat: updade workspace

This commit is contained in:
thanhnv
2026-07-11 15:56:31 +09:00
parent 4fc72332f5
commit 193a449829
120 changed files with 868 additions and 350 deletions
@@ -77,8 +77,8 @@ function stableJson(value: unknown): string {
export class ApprovalsService {
list(actor: SettingsActor, status = 'pending') {
this.requireRbac(actor, 'monitoring', 'read');
const res = runFile('python3', [INBOX_CLI, 'list', '--status', status]);
return { ...parseJson<Record<string, any>>(res.stdout, { count: 0, proposals: [], oversight: [] }), audit_verify: this.verifyAudit() };
const res = runFile('python3', [INBOX_CLI, 'list', '--status', status], this.tenantEnv(actor));
return { ...parseJson<Record<string, any>>(res.stdout, { count: 0, proposals: [], oversight: [] }), audit_verify: this.verifyAudit(actor) };
}
submit(input: ApprovalSubmit, actor: SettingsActor) {
@@ -109,8 +109,8 @@ export class ApprovalsService {
JSON.stringify(input.payload ?? {}),
];
if (input.sensitive) args.push('--sensitive');
const res = runFile('python3', args);
return { proposal: parseJson<Record<string, any>>(res.stdout, {}), audit_verify: this.verifyAudit() };
const res = runFile('python3', args, this.tenantEnv(actor));
return { proposal: parseJson<Record<string, any>>(res.stdout, {}), audit_verify: this.verifyAudit(actor) };
}
decide(input: ApprovalDecision, actor: SettingsActor) {
@@ -119,7 +119,7 @@ export class ApprovalsService {
}
this.requireRbac(actor, 'approval', 'grant');
try {
const pending = this.findProposal(input.id);
const pending = this.findProposal(input.id, actor);
this.verifyApprovalIdentity(input, actor, pending);
const res = runFile('python3', [
INBOX_CLI,
@@ -132,10 +132,10 @@ export class ApprovalsService {
actor.actor,
'--reason',
input.reason,
]);
], this.tenantEnv(actor));
const proposal = parseJson<Record<string, any>>(res.stdout, {});
const applied = input.decision === 'approve' ? this.applyApprovedProposal(proposal, actor) : null;
return { proposal, applied, audit_verify: this.verifyAudit() };
return { proposal, applied, audit_verify: this.verifyAudit(actor) };
} catch (err: any) {
if (err instanceof ForbiddenException) throw err;
if (Number(err.status) === 3 || Number(err.status) === 1) {
@@ -145,8 +145,8 @@ export class ApprovalsService {
}
}
private findProposal(id: string) {
const res = runFile('python3', [INBOX_CLI, 'list', '--status', 'all']);
private findProposal(id: string, actor: SettingsActor) {
const res = runFile('python3', [INBOX_CLI, 'list', '--status', 'all'], this.tenantEnv(actor));
const store = parseJson<Record<string, any>>(res.stdout, { proposals: [] });
const proposal = (store.proposals ?? []).find((p: Record<string, any>) => p.id === id);
if (!proposal) throw new ForbiddenException(`APPROVAL_DECIDE_DENY unknown_id ${id}`);
@@ -199,7 +199,7 @@ export class ApprovalsService {
`approved:${proposal.id}:${proposal.decision_reason ?? ''}`,
'--approval',
`inbox:${proposal.id}:${actor.actor}`,
]);
], this.tenantEnv(actor));
return parseJson<Record<string, any>>(res.stdout, {});
} catch (err: any) {
if (Number(err.status) === 2 || Number(err.status) === 3) {
@@ -234,9 +234,13 @@ export class ApprovalsService {
}
}
private verifyAudit() {
private tenantEnv(actor: SettingsActor): NodeJS.ProcessEnv {
return { CASAN_TENANT_ID: actor.tenant || 'default' };
}
private verifyAudit(actor: SettingsActor) {
try {
const res = runFile('python3', [INBOX_CLI, 'verify-audit']);
const res = runFile('python3', [INBOX_CLI, 'verify-audit'], this.tenantEnv(actor));
return { ok: true, output: res.stdout };
} catch (err: any) {
return { ok: false, output: err.stderr || err.stdout || err.message };
@@ -12,6 +12,11 @@ export class GoalsController {
return ok(await this.service.start(body, actorFromHeaders(headers)));
}
@Get('projects')
projects(@Headers() headers: Record<string, string | string[] | undefined>) {
return ok(this.service.projects(actorFromHeaders(headers)));
}
@Get()
list(@Headers() headers: Record<string, string | string[] | undefined>, @Query('limit') limit?: string) {
return ok(this.service.list(actorFromHeaders(headers), Number(limit) || 20));
@@ -1,5 +1,5 @@
import { BadRequestException, ForbiddenException, HttpException, HttpStatus, Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common';
import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, statSync, writeFileSync } from 'node:fs';
import { execFileSync, spawn } from 'node:child_process';
import { randomUUID } from 'node:crypto';
import { join } from 'node:path';
@@ -8,6 +8,14 @@ import type { SettingsActor } from '../settings/settings.service.js';
export interface GoalStartInput {
goal: string;
projectId: string;
}
export interface GoalProject {
project_id: string;
domain: string;
domain_root: string;
context_roots: string[];
}
export interface GoalStage {
@@ -23,7 +31,7 @@ export interface GoalJob {
id: string;
trace_id: string;
goal: string;
status: 'queued' | 'running' | 'completed' | 'degraded' | 'failed';
status: 'queued' | 'running' | 'completed' | 'degraded' | 'failed' | 'requires_approval';
actor: string;
tenant: string;
project: string;
@@ -42,6 +50,9 @@ export interface GoalJob {
audit_hash?: string;
local_usage?: Record<string, number>;
cloud_usage?: Record<string, number>;
workspace?: GoalProject;
context_manifest?: { files: number; characters: number; truncated: boolean; path?: string };
approval?: { id: string; status: string; action: string };
}
interface ModelConnection {
@@ -67,6 +78,7 @@ const HARNESS_BIN = join(APP_ROOT, 'packages', 'casan-harness', 'scripts', 'bash
const CONNECTIONS_CLI = join(HARNESS_BIN, 'model-connections.py');
const ORCHESTRATOR_CLI = join(HARNESS_BIN, 'goal-orchestrator.py');
const RBAC_CLI = join(HARNESS_BIN, 'rbac-check.py');
const PROJECT_REGISTRY = join(APP_ROOT, 'packages', 'casan-harness', 'level5', 'project-registry.json');
function parseJson<T>(value: string): T | null {
try {
@@ -86,7 +98,8 @@ export class GoalsService {
private readonly startWindows = new Map<string, number[]>();
async start(input: GoalStartInput, actor: SettingsActor): Promise<GoalJob> {
this.requireRead(actor);
const workspace = this.resolveProject(String(input.projectId ?? ''));
this.requireRead(actor, workspace.project_id);
const goal = String(input.goal ?? '').trim();
if (goal.length < 10 || goal.length > 8000) {
throw new BadRequestException('GOAL_LENGTH_INVALID');
@@ -115,7 +128,8 @@ export class GoalsService {
status: 'queued',
actor: actor.actor,
tenant: actor.tenant,
project: actor.project,
project: workspace.project_id,
workspace,
created_at: timestamp,
updated_at: timestamp,
local_provider: local?.id || 'local-policy',
@@ -156,24 +170,39 @@ export class GoalsService {
return job;
}
projects(actor: SettingsActor): { count: number; projects: GoalProject[] } {
const projects = this.registeredProjects().filter((project) => {
try {
this.requireRead(actor, project.project_id);
return true;
} catch {
return false;
}
});
return { count: projects.length, projects };
}
get(id: string, actor: SettingsActor): GoalJob {
this.requireRead(actor);
if (!/^[a-f0-9-]{36}$/.test(id)) throw new NotFoundException('GOAL_NOT_FOUND');
const path = this.jobPath(actor.tenant, id);
if (!existsSync(path)) throw new NotFoundException('GOAL_NOT_FOUND');
const job = parseJson<GoalJob>(readFileSync(path, 'utf8'));
if (!job || job.tenant !== actor.tenant) throw new NotFoundException('GOAL_NOT_FOUND');
this.requireRead(actor, job.project);
return job;
}
list(actor: SettingsActor, limit = 20): { count: number; goals: GoalJob[] } {
this.requireRead(actor);
this.requireRead(actor, actor.project);
const directory = join(APP_ROOT, '.specify', 'state', 'goals', safeTenant(actor.tenant));
if (!existsSync(directory)) return { count: 0, goals: [] };
const goals = readdirSync(directory)
.filter((name) => /^[a-f0-9-]{36}\.json$/.test(name))
.map((name) => parseJson<GoalJob>(readFileSync(join(directory, name), 'utf8')))
.filter((job): job is GoalJob => Boolean(job && job.tenant === actor.tenant))
.filter((job) => {
try { this.requireRead(actor, job.project); return true; } catch { return false; }
})
.sort((left, right) => right.created_at.localeCompare(left.created_at));
return { count: goals.length, goals: goals.slice(0, Math.max(1, Math.min(limit, 100))) };
}
@@ -182,6 +211,34 @@ export class GoalsService {
return join(APP_ROOT, '.specify', 'state', 'goals', safeTenant(tenant), `${id}.json`);
}
private registeredProjects(): GoalProject[] {
const parsed = parseJson<{ projects?: Array<Record<string, unknown>> }>(readFileSync(PROJECT_REGISTRY, 'utf8'));
const root = realpathSync(APP_ROOT);
return (parsed?.projects ?? []).filter((entry) => entry.status === 'active').map((entry) => {
const projectId = String(entry.project_id ?? '');
const domainRoot = String(entry.domain_root ?? '');
const rawRoots = Array.isArray(entry.context_roots) ? entry.context_roots.map(String) : [domainRoot];
if (!/^[A-Za-z0-9._-]+$/.test(projectId) || !domainRoot || rawRoots.length === 0) {
throw new InternalServerErrorException('GOAL_PROJECT_REGISTRY_INVALID');
}
const contextRoots = rawRoots.map((relative) => {
const absolute = realpathSync(join(APP_ROOT, relative));
if (!(absolute === root || absolute.startsWith(`${root}/`)) || !statSync(absolute).isDirectory() && !statSync(absolute).isFile()) {
throw new InternalServerErrorException('GOAL_PROJECT_CONTEXT_ROOT_DENIED');
}
return relative;
});
return { project_id: projectId, domain: String(entry.domain ?? projectId), domain_root: domainRoot, context_roots: contextRoots };
});
}
private resolveProject(projectId: string): GoalProject {
if (!projectId) throw new BadRequestException('GOAL_PROJECT_REQUIRED');
const project = this.registeredProjects().find((entry) => entry.project_id === projectId);
if (!project) throw new BadRequestException('GOAL_PROJECT_NOT_ALLOWED');
return project;
}
private connections(actor: SettingsActor): ModelConnection[] {
const payload = this.runPython(CONNECTIONS_CLI, ['list'], { CASAN_TENANT_ID: actor.tenant || 'default' });
const parsed = parseJson<ConnectionList>(payload);
@@ -259,10 +316,10 @@ export class GoalsService {
}
}
private requireRead(actor: SettingsActor): void {
private requireRead(actor: SettingsActor, targetProject: string): void {
try {
execFileSync('python3', [RBAC_CLI, 'check', '--role', actor.role, '--resource', 'monitoring', '--action', 'read',
'--role-project', actor.project, '--target-project', actor.project,
'--role-project', actor.project, '--target-project', targetProject,
'--role-tenant', actor.tenant, '--target-tenant', actor.tenant], {
cwd: APP_ROOT,
env: process.env,
@@ -1,4 +1,4 @@
import { ForbiddenException, Injectable, InternalServerErrorException } from '@nestjs/common';
import { BadRequestException, ForbiddenException, Injectable, InternalServerErrorException } from '@nestjs/common';
import { execFileSync } from 'node:child_process';
import { join } from 'node:path';
import { APP_ROOT } from '../common/app-root.js';
@@ -60,10 +60,11 @@ function parseJson<T>(raw: string, fallback: T): T {
export class SettingsService {
list(actor: SettingsActor) {
this.requireRbac(actor, 'read', false);
const policy = parseJson<Record<string, any>>(runPython(CP_CLI, ['list-policy']).stdout, {});
const settings = parseJson<Record<string, any>>(runPython(CP_CLI, ['get-all']).stdout, {});
const audit = parseJson<any[]>(runPython(CP_CLI, ['get-audit']).stdout, []);
const auditVerify = this.verifyAudit();
const tenantEnv = this.tenantEnv(actor);
const policy = parseJson<Record<string, any>>(runPython(CP_CLI, ['list-policy'], tenantEnv).stdout, {});
const settings = parseJson<Record<string, any>>(runPython(CP_CLI, ['get-all'], tenantEnv).stdout, {});
const audit = parseJson<any[]>(runPython(CP_CLI, ['get-audit'], tenantEnv).stdout, []);
const auditVerify = this.verifyAudit(actor);
return {
actor,
@@ -83,7 +84,7 @@ export class SettingsService {
if (!input.key || input.value === undefined || !input.reason) {
throw new ForbiddenException('SETTINGS_DENY key/value/reason required');
}
const sensitive = this.isSensitive(input.key);
const sensitive = this.isSensitive(input.key, actor);
this.requireRbac(actor, 'write', sensitive);
try {
const res = runPython(CP_CLI, [
@@ -96,11 +97,12 @@ export class SettingsService {
input.reason,
'--approval',
input.approval ?? '',
]);
return { key: input.key, setting: parseJson<Record<string, any>>(res.stdout, {}), audit_verify: this.verifyAudit() };
], this.tenantEnv(actor));
return { key: input.key, setting: parseJson<Record<string, any>>(res.stdout, {}), audit_verify: this.verifyAudit(actor) };
} catch (err: any) {
if (Number(err.status) === 3) throw new ForbiddenException(err.stderr || 'APPROVAL_REQUIRED');
if (Number(err.status) === 2) throw new ForbiddenException(err.stderr || 'SETTING_NOT_ALLOWED');
if (Number(err.status) === 5) throw new BadRequestException(err.stderr || 'SETTING_VALIDATION_ERROR');
throw new InternalServerErrorException(err.stderr || err.message);
}
}
@@ -109,19 +111,19 @@ export class SettingsService {
if (!input.key || !input.reason) {
throw new ForbiddenException('SETTINGS_DENY key/reason required');
}
const sensitive = this.isSensitive(input.key);
const sensitive = this.isSensitive(input.key, actor);
this.requireRbac(actor, 'write', sensitive);
try {
const res = runPython(CP_CLI, ['rollback', input.key, '--actor', actor.actor, '--reason', input.reason]);
return { key: input.key, setting: parseJson<Record<string, any>>(res.stdout, {}), audit_verify: this.verifyAudit() };
const res = runPython(CP_CLI, ['rollback', input.key, '--actor', actor.actor, '--reason', input.reason], this.tenantEnv(actor));
return { key: input.key, setting: parseJson<Record<string, any>>(res.stdout, {}), audit_verify: this.verifyAudit(actor) };
} catch (err: any) {
if (Number(err.status) === 4) throw new ForbiddenException(err.stderr || 'NO_PRIOR_VERSION');
throw new InternalServerErrorException(err.stderr || err.message);
}
}
private isSensitive(key: string): boolean {
const policy = parseJson<Record<string, any>>(runPython(CP_CLI, ['list-policy']).stdout, {});
private isSensitive(key: string, actor: SettingsActor): boolean {
const policy = parseJson<Record<string, any>>(runPython(CP_CLI, ['list-policy'], this.tenantEnv(actor)).stdout, {});
return Boolean(policy[key]?.securitySensitive);
}
@@ -164,9 +166,13 @@ export class SettingsService {
return runPython(RBAC_CLI, args);
}
private verifyAudit() {
private tenantEnv(actor: SettingsActor): NodeJS.ProcessEnv {
return { CASAN_TENANT_ID: actor.tenant };
}
private verifyAudit(actor: SettingsActor) {
try {
const res = runPython(CP_CLI, ['verify-audit']);
const res = runPython(CP_CLI, ['verify-audit'], this.tenantEnv(actor));
return { ok: true, output: res.stdout };
} catch (err: any) {
return { ok: false, output: err.stderr || err.stdout || err.message };