feat: updade workspace
This commit is contained in:
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user