feat: appove and go
This commit is contained in:
@@ -83,7 +83,11 @@ export class ApprovalsService {
|
||||
list(actor: SettingsActor, status = 'pending') {
|
||||
this.requireRbac(actor, 'monitoring', 'read');
|
||||
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) };
|
||||
const inbox = parseJson<{ proposals?: Array<Record<string, any>>; oversight?: Array<Record<string, any>> }>(res.stdout, { proposals: [], oversight: [] });
|
||||
const proposals = (inbox.proposals ?? []).filter((proposal) => this.canAccessProject(actor, String(proposal.project ?? actor.project)));
|
||||
const visibleIds = new Set(proposals.map((proposal) => String(proposal.id ?? '')));
|
||||
const oversight = (inbox.oversight ?? []).filter((record) => visibleIds.has(String(record.proposal_id ?? '')));
|
||||
return { ...inbox, count: proposals.length, proposals, oversight, audit_verify: this.verifyAudit(actor) };
|
||||
}
|
||||
|
||||
submit(input: ApprovalSubmit, actor: SettingsActor) {
|
||||
@@ -122,9 +126,9 @@ export class ApprovalsService {
|
||||
if (!input.id || !input.decision || !input.reason) {
|
||||
throw new ForbiddenException('APPROVAL_DECIDE_DENY id/decision/reason required');
|
||||
}
|
||||
this.requireRbac(actor, 'approval', 'grant');
|
||||
try {
|
||||
const pending = this.findProposal(input.id, actor);
|
||||
this.requireRbac(actor, 'approval', 'grant', String(pending.project ?? actor.project));
|
||||
await this.verifyApprovalIdentity(input, actor, pending);
|
||||
const res = runFile('python3', [
|
||||
INBOX_CLI,
|
||||
@@ -281,7 +285,16 @@ export class ApprovalsService {
|
||||
}
|
||||
}
|
||||
|
||||
private requireRbac(actor: SettingsActor, resource: string, action: string) {
|
||||
private canAccessProject(actor: SettingsActor, targetProject: string): boolean {
|
||||
try {
|
||||
this.requireRbac(actor, 'monitoring', 'read', targetProject);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private requireRbac(actor: SettingsActor, resource: string, action: string, targetProject = actor.project) {
|
||||
try {
|
||||
runFile('python3', [
|
||||
RBAC_CLI,
|
||||
@@ -295,7 +308,7 @@ export class ApprovalsService {
|
||||
'--role-project',
|
||||
actor.project,
|
||||
'--target-project',
|
||||
actor.project,
|
||||
targetProject,
|
||||
'--role-tenant',
|
||||
actor.tenant,
|
||||
'--target-tenant',
|
||||
|
||||
@@ -33,6 +33,14 @@ function roleFromClaim(raw: string | undefined): string {
|
||||
return 'viewer';
|
||||
}
|
||||
|
||||
function projectFromClaims(raw: string | undefined): string | undefined {
|
||||
if (!raw) return undefined;
|
||||
const prefix = 'casan-project:';
|
||||
const projectClaim = raw.split(/[\s,]+/).find((claim) => claim.startsWith(prefix));
|
||||
const project = projectClaim?.slice(prefix.length);
|
||||
return project && /^[A-Za-z0-9._-]+$/.test(project) ? project : undefined;
|
||||
}
|
||||
|
||||
export function actorFromHeaders(headers: Record<string, string | string[] | undefined>): SettingsActor {
|
||||
const actor = firstHeader(headers['x-casan-actor'])
|
||||
|| firstHeader(headers['x-auth-request-user'])
|
||||
@@ -45,7 +53,7 @@ export function actorFromHeaders(headers: Record<string, string | string[] | und
|
||||
return {
|
||||
actor,
|
||||
role: roleFromClaim(roleClaim),
|
||||
project: firstHeader(headers['x-casan-project']) || 'default',
|
||||
project: firstHeader(headers['x-casan-project']) || projectFromClaims(roleClaim) || 'default',
|
||||
tenant: firstHeader(headers['x-casan-tenant']) || 'default',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -48,6 +48,8 @@ export interface GoalJob {
|
||||
finished_at?: string;
|
||||
local_provider: string;
|
||||
local_model: string;
|
||||
effective_local_provider?: string;
|
||||
effective_local_model?: string;
|
||||
cloud_provider: string;
|
||||
cloud_model: string;
|
||||
stages: GoalStage[];
|
||||
@@ -182,9 +184,20 @@ export class GoalsService {
|
||||
// must not silently change provider/model after a direct OpenAI repair
|
||||
// fails. Gateways can have their own transport and model-specific output
|
||||
// contracts; an operator can explicitly opt in after validating one.
|
||||
const gatewayPatchRepairModels = process.env.CASAN_GOAL_ENABLE_GATEWAY_PATCH_REPAIR === '1' ? gatewayModels : [];
|
||||
const gatewayPatchRepairModels = process.env.CASAN_GOAL_ENABLE_GATEWAY_PATCH_REPAIR === '1' ? gatewayModels.slice(0, 1) : [];
|
||||
const preferredCloudModel = cloudCandidates[0]?.model || '';
|
||||
const preferredCloudProvider = preferredCloudModel.startsWith('openai:') ? 'openai' : preferredCloudModel.startsWith('anthropic:') ? 'anthropic' : (cloud?.id || 'unavailable');
|
||||
// Coding-worker order is based on observed capability, not advertised
|
||||
// discovery: direct cloud credentials first, then a logged-in account
|
||||
// bridge, and only then the small local model. The account remains H3 when
|
||||
// direct cloud is H2, preserving a distinct reviewer channel.
|
||||
const cloudWorker = cloudCandidates[0];
|
||||
const accountWorker = cloudWorker ? '' : account;
|
||||
const accountReviewer = cloudWorker ? account : '';
|
||||
const workerModel = cloudWorker?.model || (accountWorker ? `account:${accountWorker}` : String(localRuntime.CASAN_CHAT_SELECTED_MODEL || `ollama:${localModel}`));
|
||||
const workerProvider = cloudWorker
|
||||
? (workerModel.startsWith('openai:') ? 'openai' : workerModel.startsWith('anthropic:') ? 'anthropic' : 'cloud')
|
||||
: (accountWorker ? `${accountWorker}-account` : (local?.id || 'local-policy'));
|
||||
const cloudModel = preferredCloudModel || gateway?.defaultModel || gateway?.models[0] || '';
|
||||
const id = randomUUID();
|
||||
const timestamp = new Date().toISOString();
|
||||
@@ -199,13 +212,13 @@ export class GoalsService {
|
||||
workspace,
|
||||
created_at: timestamp,
|
||||
updated_at: timestamp,
|
||||
local_provider: local?.id || 'local-policy',
|
||||
local_model: String(localRuntime.CASAN_CHAT_SELECTED_MODEL || `ollama:${localModel}`),
|
||||
cloud_provider: account ? `${account}-account` : (preferredCloudProvider !== 'unavailable' ? preferredCloudProvider : (selectedReviewer?.id || 'unavailable')),
|
||||
cloud_model: account ? `${account}-account-default` : preferredCloudModel || String(cloudRuntime.CASAN_CHAT_SELECTED_MODEL || ''),
|
||||
local_provider: workerProvider,
|
||||
local_model: workerModel,
|
||||
cloud_provider: accountReviewer ? `${accountReviewer}-account` : (preferredCloudProvider !== 'unavailable' ? preferredCloudProvider : (selectedReviewer?.id || 'unavailable')),
|
||||
cloud_model: accountReviewer ? `${accountReviewer}-account-default` : preferredCloudModel || String(cloudRuntime.CASAN_CHAT_SELECTED_MODEL || ''),
|
||||
stages: [
|
||||
{ id: 'local-worker', status: 'queued', detail: 'Waiting for local worker', provider: local?.id || 'local-policy', model: localModel },
|
||||
{ id: 'cloud-reviewer', status: 'queued', detail: account || preferredCloudModel || selectedReviewer ? 'Waiting for independent reviewer' : 'Cloud unavailable; local reviewer will be used', provider: account ? `${account}-account` : (preferredCloudProvider !== 'unavailable' ? preferredCloudProvider : (selectedReviewer?.id || 'local-policy')), model: account ? `${account}-account-default` : cloudModel || localModel },
|
||||
{ id: 'local-worker', status: 'queued', detail: 'Waiting for primary coding worker', provider: workerProvider, model: workerModel },
|
||||
{ id: 'cloud-reviewer', status: 'queued', detail: accountReviewer || preferredCloudModel || selectedReviewer ? 'Waiting for independent reviewer' : 'Cloud unavailable; local reviewer will be used', provider: accountReviewer ? `${accountReviewer}-account` : (preferredCloudProvider !== 'unavailable' ? preferredCloudProvider : (selectedReviewer?.id || 'local-policy')), model: accountReviewer ? `${accountReviewer}-account-default` : cloudModel || localModel },
|
||||
],
|
||||
};
|
||||
const jobFile = this.jobPath(actor.tenant, id);
|
||||
@@ -226,7 +239,7 @@ export class GoalsService {
|
||||
CASAN_GOAL_CLOUD_MODEL: job.cloud_model,
|
||||
CASAN_GOAL_LOCAL_PROVIDER: job.local_provider,
|
||||
CASAN_GOAL_CLOUD_PROVIDER: job.cloud_provider,
|
||||
CASAN_GOAL_ACCOUNT_PROVIDER: account || '',
|
||||
CASAN_GOAL_ACCOUNT_PROVIDER: accountReviewer || '',
|
||||
CASAN_GOAL_CLOUD_FALLBACK_MODEL: String(cloudRuntime.CASAN_CHAT_SELECTED_MODEL || ''),
|
||||
CASAN_GOAL_CLOUD_MODELS: cloudCandidates.map(({ model }) => model).join(','),
|
||||
CASAN_GOAL_OMNIROUTE_MODELS: gatewayModels.join(','),
|
||||
@@ -234,6 +247,8 @@ export class GoalsService {
|
||||
// explicit opt-in because it must not disguise a Codex patch failure.
|
||||
CASAN_GOAL_PATCH_REPAIR_MODELS: [...cloudCandidates.map(({ model }) => model), ...gatewayPatchRepairModels, String(localRuntime.CASAN_CHAT_SELECTED_MODEL || `ollama:${localModel}`)].filter((model, index, rows) => rows.indexOf(model) === index).join(','),
|
||||
CASAN_GOAL_LOCAL_REVIEWER_MODEL: String(localRuntime.CASAN_CHAT_SELECTED_MODEL || `ollama:${localModel}`),
|
||||
CASAN_GOAL_LOCAL_REVIEWER_PROVIDER: local?.id || 'local-policy',
|
||||
CASAN_GOAL_ENABLE_LOCAL_REVIEWER: process.env.CASAN_GOAL_ENABLE_LOCAL_REVIEWER || (account || preferredCloudModel ? '0' : '1'),
|
||||
CASAN_GOAL_REVIEWER_MAX_ATTEMPTS: process.env.CASAN_GOAL_REVIEWER_MAX_ATTEMPTS || '8',
|
||||
CASAN_GOAL_REVIEWER_DEADLINE_SEC: process.env.CASAN_GOAL_REVIEWER_DEADLINE_SEC || '600',
|
||||
},
|
||||
@@ -393,7 +408,8 @@ export class GoalsService {
|
||||
|
||||
apply(id: string, actor: SettingsActor): GoalJob {
|
||||
const job = this.get(id, actor);
|
||||
if (!job.patch_artifact || job.status !== 'requires_approval') {
|
||||
const retryableRollback = job.status === 'failed' && job.error === 'GOAL_APPLY_VERIFICATION_FAILED_ROLLED_BACK';
|
||||
if (!job.patch_artifact || (job.status !== 'requires_approval' && !retryableRollback)) {
|
||||
throw new BadRequestException('GOAL_PATCH_NOT_READY');
|
||||
}
|
||||
try {
|
||||
|
||||
@@ -102,6 +102,29 @@ test('operations owner request remains visible and actionable for an independent
|
||||
});
|
||||
});
|
||||
|
||||
test('project reviewer cannot see or decide another project approval', async () => {
|
||||
await withTempGovernance(async () => {
|
||||
const svc = new ApprovalsService();
|
||||
const submitted = svc.submit({
|
||||
action: 'goal.workspace.execute',
|
||||
target: 'project-alpha',
|
||||
risk: 'high',
|
||||
sensitive: true,
|
||||
reason: 'project alpha patch',
|
||||
payload: { goal_id: 'goal-alpha', project_id: 'project-alpha' },
|
||||
}, { ...projectAdmin, project: 'project-alpha' }) as { proposal: { id: string } };
|
||||
const otherProjectReviewer = { ...approver, project: 'project-beta' };
|
||||
|
||||
const reviewerInbox = svc.list(otherProjectReviewer, 'pending') as { count: number; proposals: Array<{ id: string }> };
|
||||
assert.equal(reviewerInbox.count, 0);
|
||||
assert.equal(reviewerInbox.proposals.length, 0);
|
||||
await assert.rejects(
|
||||
svc.decide({ id: submitted.proposal.id, decision: 'approve', reason: 'wrong project' }, otherProjectReviewer),
|
||||
ForbiddenException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('approval inbox denies forged JWT in strict mode without deciding proposal', async () => {
|
||||
await withTempGovernance(async ({ inbox }) => {
|
||||
const svc = new ApprovalsService();
|
||||
|
||||
@@ -15,10 +15,11 @@ test('auth context keeps local explicit roles for dev', () => {
|
||||
test('auth context maps IdP group claim to RBAC role', () => {
|
||||
const actor = actorFromHeaders({
|
||||
'x-auth-request-user': 'bob@example.com',
|
||||
'x-auth-request-groups': 'engineering,casan-approver',
|
||||
'x-auth-request-groups': 'engineering,casan-approver,casan-project:AINative_OKR_CASAN4',
|
||||
});
|
||||
assert.equal(actor.actor, 'bob@example.com');
|
||||
assert.equal(actor.role, 'approver');
|
||||
assert.equal(actor.project, 'AINative_OKR_CASAN4');
|
||||
});
|
||||
|
||||
test('auth context fails closed to viewer for unknown role claim', () => {
|
||||
|
||||
@@ -99,19 +99,39 @@ export function Approvals() {
|
||||
});
|
||||
|
||||
const decide = useMutation({
|
||||
mutationFn: ({ id, decision }: { id: string; decision: 'approve' | 'reject' }) => {
|
||||
mutationFn: async ({ proposal, decision }: { proposal: ApprovalProposal; decision: 'approve' | 'reject' }) => {
|
||||
if (!actor) throw new Error('Authenticated session is unavailable.');
|
||||
return api.decideApproval(actor, { id, decision, reason: decisionReason.trim() });
|
||||
const response = await api.decideApproval(actor, { id: proposal.id, decision, reason: decisionReason.trim() });
|
||||
if (decision !== 'approve' || proposal.action !== 'goal.workspace.execute') {
|
||||
return { response, continuedGoalId: null };
|
||||
}
|
||||
|
||||
const goalId = proposal.payload.goal_id;
|
||||
if (typeof goalId !== 'string' || goalId.length === 0) {
|
||||
throw new Error(`Request ${proposal.id} was approved, but its goal identifier is missing. Continue from Goal Orchestrator.`);
|
||||
}
|
||||
try {
|
||||
await api.applyGoal({ ...actor, project: proposal.project }, goalId);
|
||||
return { response, continuedGoalId: goalId };
|
||||
} catch (error) {
|
||||
throw new Error(`Request ${proposal.id} was approved, but apply/verification did not complete: ${errorMessage(error, 'unknown apply error')}`);
|
||||
}
|
||||
},
|
||||
onSuccess: (response) => {
|
||||
onSuccess: ({ response, continuedGoalId }) => {
|
||||
const verb = response.proposal.status === 'approved' ? 'approved' : 'rejected';
|
||||
setNotice({ tone: 'success', text: `Request ${response.proposal.id} was ${verb}.` });
|
||||
const continuation = continuedGoalId ? ' The governed change was applied and verified.' : '';
|
||||
setNotice({ tone: 'success', text: `Request ${response.proposal.id} was ${verb}.${continuation}` });
|
||||
void queryClient.invalidateQueries({ queryKey: ['approvals'] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['settings'] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['goals'] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['goal'] });
|
||||
},
|
||||
onError: (error) => setNotice({ tone: 'error', text: errorMessage(error, 'The approval decision could not be recorded.') }),
|
||||
onError: (error) => {
|
||||
setNotice({ tone: 'error', text: errorMessage(error, 'The approval decision could not be recorded.') });
|
||||
void queryClient.invalidateQueries({ queryKey: ['approvals'] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['goals'] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['goal'] });
|
||||
},
|
||||
});
|
||||
|
||||
if (session.isLoading || (actor && inbox.isLoading)) {
|
||||
@@ -184,7 +204,7 @@ export function Approvals() {
|
||||
<div className="mt-5 space-y-3">
|
||||
{inbox.data.proposals.map((proposal) => {
|
||||
const eligibility = reviewEligibility(actor, proposal);
|
||||
const isCurrentDecision = decide.isPending && decide.variables?.id === proposal.id;
|
||||
const isCurrentDecision = decide.isPending && decide.variables?.proposal.id === proposal.id;
|
||||
return (
|
||||
<article key={proposal.id} className="rounded-2xl border border-slate-200 bg-white p-4 transition hover:border-slate-300 hover:shadow-[0_12px_28px_rgba(15,23,42,0.055)] sm:p-5">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
@@ -211,8 +231,8 @@ export function Approvals() {
|
||||
<span className={`text-xs font-medium ${eligibility.allowed ? 'text-emerald-700' : 'text-amber-700'}`}>{eligibility.reason}</span>
|
||||
{eligibility.allowed && (
|
||||
<div className="flex gap-2">
|
||||
<button type="button" disabled={decide.isPending || decisionReason.trim().length < 5} onClick={() => decide.mutate({ id: proposal.id, decision: 'reject' })} className="rounded-lg border border-rose-200 bg-white px-3.5 py-2 text-xs font-semibold text-rose-700 transition hover:bg-rose-50 focus:outline-none focus:ring-4 focus:ring-rose-100 disabled:cursor-not-allowed disabled:opacity-40">{isCurrentDecision && decide.variables?.decision === 'reject' ? 'Rejecting…' : 'Reject'}</button>
|
||||
<button type="button" disabled={decide.isPending || decisionReason.trim().length < 5} onClick={() => decide.mutate({ id: proposal.id, decision: 'approve' })} className="rounded-lg bg-emerald-700 px-4 py-2 text-xs font-semibold text-white transition hover:bg-emerald-800 focus:outline-none focus:ring-4 focus:ring-emerald-100 disabled:cursor-not-allowed disabled:bg-slate-300">{isCurrentDecision && decide.variables?.decision === 'approve' ? 'Approving…' : 'Approve'}</button>
|
||||
<button type="button" disabled={decide.isPending || decisionReason.trim().length < 5} onClick={() => decide.mutate({ proposal, decision: 'reject' })} className="rounded-lg border border-rose-200 bg-white px-3.5 py-2 text-xs font-semibold text-rose-700 transition hover:bg-rose-50 focus:outline-none focus:ring-4 focus:ring-rose-100 disabled:cursor-not-allowed disabled:opacity-40">{isCurrentDecision && decide.variables?.decision === 'reject' ? 'Rejecting…' : 'Reject'}</button>
|
||||
<button type="button" disabled={decide.isPending || decisionReason.trim().length < 5} onClick={() => decide.mutate({ proposal, decision: 'approve' })} className="rounded-lg bg-emerald-700 px-4 py-2 text-xs font-semibold text-white transition hover:bg-emerald-800 focus:outline-none focus:ring-4 focus:ring-emerald-100 disabled:cursor-not-allowed disabled:bg-slate-300">{isCurrentDecision && decide.variables?.decision === 'approve' ? 'Approving and continuing…' : proposal.action === 'goal.workspace.execute' ? 'Approve & continue' : 'Approve'}</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -8,10 +8,12 @@ audit, or metric records.
|
||||
import argparse
|
||||
import fcntl
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
@@ -36,6 +38,11 @@ SECURITY = os.path.join(BIN, "security-check.sh")
|
||||
STATE_ROOT = os.environ.get("CASAN_STATE_ROOT") or os.path.join(ROOT, ".specify")
|
||||
PROJECT_REGISTRY = os.path.join(ROOT, "packages", "casan-harness", "level5", "project-registry.json")
|
||||
APPROVAL_INBOX = os.path.join(BIN, "approval-inbox.py")
|
||||
_MANIFEST_SPEC = importlib.util.spec_from_file_location(
|
||||
"casan_goal_project_manifest", os.path.join(BIN, "project_manifest.py")
|
||||
)
|
||||
PROJECT_MANIFEST = importlib.util.module_from_spec(_MANIFEST_SPEC)
|
||||
_MANIFEST_SPEC.loader.exec_module(PROJECT_MANIFEST)
|
||||
CONTEXT_EXTENSIONS = {".md", ".txt", ".json", ".yaml", ".yml", ".ts", ".tsx", ".js", ".mjs", ".py", ".sh", ".prisma", ".css", ".html"}
|
||||
CONTEXT_IGNORED = {"node_modules", ".git", "dist", "build", "coverage", ".vite", "tmp", "logs", "__pycache__"}
|
||||
SENSITIVE_NAMES = {".env", ".env.local", "credentials", "credentials.json", "secrets.json", "id_rsa", "id_ed25519"}
|
||||
@@ -173,8 +180,35 @@ def context_excerpt_is_sensitive(text: str) -> bool:
|
||||
))
|
||||
|
||||
|
||||
def restricted_context_paths(goal: str):
|
||||
"""Return an explicit user-declared file/directory boundary, if present."""
|
||||
section = re.search(
|
||||
r"(?:chỉ được đọc và thay đổi|chỉ làm việc trong|only (?:read and )?(?:modify|change)|only work (?:in|within))\s*:\s*"
|
||||
r"(.*?)(?=\n\s*(?:không sửa bất kỳ file nào khác|do not (?:modify|change) any other file|không thay đổi|không thêm|vấn đề|yêu cầu|verification|$))",
|
||||
goal,
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
if not section:
|
||||
return []
|
||||
paths = []
|
||||
for match in re.findall(r"(?:apps|packages|docs)/[A-Za-z0-9_./*-]+", section.group(1), re.IGNORECASE):
|
||||
cleaned = match.rstrip(".,:;)")
|
||||
scoped_directory = cleaned.endswith(("/*", "/**"))
|
||||
normalized = cleaned.rstrip("/*")
|
||||
if normalized and all(path != normalized for path, _ in paths):
|
||||
paths.append((normalized, scoped_directory))
|
||||
return paths
|
||||
|
||||
|
||||
def context_candidates(project: dict, goal: str):
|
||||
terms = {term.lower() for term in re.findall(r"[A-Za-z0-9_-]{3,}", goal)}
|
||||
explicit_paths = []
|
||||
for match in re.findall(r"(?:apps|packages|docs)/[A-Za-z0-9_./*-]+", goal, re.IGNORECASE):
|
||||
cleaned = match.rstrip(".,:;)")
|
||||
scoped_directory = cleaned.endswith(("/*", "/**"))
|
||||
normalized_path = cleaned.rstrip("/*").lower()
|
||||
if normalized_path and all(path != normalized_path for path, _ in explicit_paths):
|
||||
explicit_paths.append((normalized_path, scoped_directory))
|
||||
candidates = []
|
||||
seen = set()
|
||||
for relative_root, absolute_root in project["roots"]:
|
||||
@@ -200,6 +234,22 @@ def context_candidates(project: dict, goal: str):
|
||||
continue
|
||||
haystack = (relative + "\n" + raw[:4000]).lower()
|
||||
score = sum(4 if term in relative.lower() else 1 for term in terms if term in haystack)
|
||||
relative_lower = relative.lower()
|
||||
matching_paths = [
|
||||
(path, scoped_directory)
|
||||
for path, scoped_directory in explicit_paths
|
||||
if relative_lower == path or relative_lower.startswith(path + "/")
|
||||
]
|
||||
if matching_paths:
|
||||
# Directory globs under "only work in" sections describe the
|
||||
# actual patch surface. Exact source-file mentions come next;
|
||||
# architecture/requirement references remain supporting context.
|
||||
if any(scoped_directory for _, scoped_directory in matching_paths):
|
||||
score += 3_000
|
||||
elif os.path.splitext(relative_lower)[1] in {".ts", ".tsx", ".js", ".jsx", ".py", ".prisma", ".sql", ".sh"}:
|
||||
score += 2_000
|
||||
else:
|
||||
score += 1_000
|
||||
if relative.endswith(("README.md", "architecture.md", "technical_architecture.md", "package.json")):
|
||||
score += 3
|
||||
candidates.append((score, relative, raw))
|
||||
@@ -209,6 +259,17 @@ def context_candidates(project: dict, goal: str):
|
||||
def build_context(job_path: str, project_id: str, goal: str, write_intent=False):
|
||||
project = registered_project(project_id)
|
||||
candidates = context_candidates(project, goal)
|
||||
restricted_paths = restricted_context_paths(goal)
|
||||
if restricted_paths:
|
||||
candidates = [
|
||||
candidate for candidate in candidates
|
||||
if any(
|
||||
candidate[1] == path or (scoped_directory and candidate[1].startswith(path.rstrip("/") + "/"))
|
||||
for path, scoped_directory in restricted_paths
|
||||
)
|
||||
]
|
||||
if not candidates:
|
||||
raise ValueError("goal_context_explicit_scope_empty")
|
||||
excerpts, manifest_files, characters = [], [], 0
|
||||
# A diff can only apply when the model sees the exact target-file content.
|
||||
# Read-only analysis stays compact; write-intent gives the three most
|
||||
@@ -261,12 +322,39 @@ def build_context(job_path: str, project_id: str, goal: str, write_intent=False)
|
||||
|
||||
def requests_side_effect(goal: str) -> bool:
|
||||
normalized = " ".join(goal.lower().split())
|
||||
patterns = [
|
||||
r"^(hãy\s+)?(làm luôn|sửa|thay đổi|triển khai|thực hiện|hoàn thành|chạy|tạo|xóa|cài đặt|commit|push)\b",
|
||||
# The objective's leading command is authoritative. A scoped constraint
|
||||
# such as "Không thay đổi API contract" must not turn "Hoàn thiện ..."
|
||||
# into a read-only request. Conversely, a genuinely read-only audit does
|
||||
# not begin with one of these implementation commands.
|
||||
leading_write_patterns = [
|
||||
r"^(hãy\s+)?(làm luôn|sửa|thay đổi|triển khai|thực hiện|hoàn thiện|hoàn thành|chạy|tạo|xóa|cài đặt|commit|push)\b",
|
||||
r"^(please\s+)?(implement|fix|change|deploy|run|create|delete|install|commit|push)\b",
|
||||
r"\b(thực hiện thao tác|sửa code|ghi file|mở pull request|create a pull request)\b",
|
||||
]
|
||||
return any(re.search(pattern, normalized) for pattern in patterns)
|
||||
if any(re.search(pattern, normalized) for pattern in leading_write_patterns):
|
||||
return True
|
||||
|
||||
patch_requests = re.finditer(r"\b(tạo|generate|xuất|produce)\b.{0,40}\b(patch|unified git diff)\b", normalized)
|
||||
explicit_patch_request = any(
|
||||
not normalized[max(0, match.start() - 8):match.start()].endswith(("không ", "do not "))
|
||||
for match in patch_requests
|
||||
)
|
||||
explicit_read_only = bool(re.search(r"\b(không|do not)\s+(sửa|thay đổi|triển khai|implement|fix|tạo|apply|áp dụng)\b", normalized))
|
||||
if explicit_read_only and not explicit_patch_request:
|
||||
return False
|
||||
imperative_patterns = [r"\b(thực hiện thao tác|sửa code|ghi file|mở pull request|create a pull request)\b"]
|
||||
if any(re.search(pattern, normalized) for pattern in imperative_patterns):
|
||||
return True
|
||||
|
||||
# Structured objectives commonly start with a phase label rather than the
|
||||
# imperative itself (for example "PHASE 1 — Hoàn thiện backend..."). Keep
|
||||
# those write requests on the patch + approval path without classifying a
|
||||
# read-only audit that merely mentions source files as a side effect.
|
||||
structured_write_patterns = [
|
||||
r"\b(hoàn thiện|triển khai|implement|fix|sửa|thay đổi)\b.{0,120}\b(ứng dụng|backend|frontend|module|component|api|source|code|file)\b",
|
||||
r"\b(tạo|generate|xuất|produce)\b.{0,40}\b(patch|unified git diff)\b",
|
||||
r"\b(apply|áp dụng)\b.{0,40}\b(patch|thay đổi|change)\b",
|
||||
]
|
||||
return any(re.search(pattern, normalized) for pattern in structured_write_patterns)
|
||||
|
||||
|
||||
def extract_patch(text: str) -> str:
|
||||
@@ -344,6 +432,17 @@ def normalize_unified_diff(patch: str) -> str:
|
||||
return "\n".join(normalized) + "\n" if changed else patch
|
||||
|
||||
|
||||
def mechanical_patch_error(error: object) -> bool:
|
||||
"""Identify git parser errors that hunk recounting can safely repair."""
|
||||
normalized = str(error).lower()
|
||||
return any(marker in normalized for marker in (
|
||||
"corrupt patch",
|
||||
"patch fragment without header",
|
||||
"malformed patch",
|
||||
"unexpected end of file in patch",
|
||||
))
|
||||
|
||||
|
||||
def validate_write_output(text: str) -> str:
|
||||
"""Fail at the producing harness when a write-intent reply is not a diff.
|
||||
|
||||
@@ -356,7 +455,7 @@ def validate_write_output(text: str) -> str:
|
||||
validate_patch_check(patch)
|
||||
return patch
|
||||
except ValueError as original_error:
|
||||
if "corrupt patch" not in str(original_error):
|
||||
if not mechanical_patch_error(original_error):
|
||||
raise
|
||||
normalized = normalize_unified_diff(patch)
|
||||
if normalized == patch:
|
||||
@@ -387,14 +486,106 @@ def patch_repair_models(primary_model: str):
|
||||
"""Use direct cloud -> gateway -> local order for one bounded H2 recovery."""
|
||||
configured = [item.strip() for item in os.environ.get("CASAN_GOAL_PATCH_REPAIR_MODELS", "").split(",") if item.strip()]
|
||||
unique, seen = [], set()
|
||||
for candidate in configured + [primary_model]:
|
||||
ordered = [primary_model, *configured] if primary_model.startswith("account:") else [*configured, primary_model]
|
||||
for candidate in ordered:
|
||||
if candidate and candidate not in seen:
|
||||
seen.add(candidate)
|
||||
unique.append(candidate)
|
||||
return unique
|
||||
|
||||
|
||||
def model_preflight_enabled() -> bool:
|
||||
configured = os.environ.get("CASAN_GOAL_MODEL_PREFLIGHT")
|
||||
return configured == "1" if configured is not None else os.environ.get("CASAN_PROFILE") == "prod"
|
||||
|
||||
|
||||
def model_preflight(model: str):
|
||||
"""Probe real patch generation before assigning a model to H2/H3.
|
||||
|
||||
Model discovery only proves that an ID is advertised. This bounded probe
|
||||
verifies the generation route and unified-diff capability, and caches the
|
||||
verdict so a broken gateway is not retried for every goal.
|
||||
"""
|
||||
if not model_preflight_enabled():
|
||||
return True, "preflight_disabled"
|
||||
if model.startswith("account:"):
|
||||
endpoint = os.environ.get("CASAN_AUTH_BRIDGE_URL", "")
|
||||
elif model.startswith("openai-compatible:"):
|
||||
endpoint = os.environ.get("CASAN_OPENAI_COMPATIBLE_BASE_URL", "")
|
||||
else:
|
||||
endpoint = os.environ.get("OLLAMA_HOST", "")
|
||||
cache_key = sha(f"{model}|{endpoint}")
|
||||
cache_directory = os.path.join(STATE_ROOT, "cache")
|
||||
cache_path = os.path.join(cache_directory, "goal-model-preflight.json")
|
||||
lock_path = os.path.join(cache_directory, "goal-model-preflight.lock")
|
||||
os.makedirs(cache_directory, mode=0o700, exist_ok=True)
|
||||
ttl = int(os.environ.get("CASAN_GOAL_MODEL_PREFLIGHT_TTL_SEC", "600"))
|
||||
with open(lock_path, "a", encoding="utf-8") as lock:
|
||||
fcntl.flock(lock.fileno(), fcntl.LOCK_EX)
|
||||
try:
|
||||
cache = load_json(cache_path) if os.path.isfile(cache_path) else {}
|
||||
except (OSError, ValueError, json.JSONDecodeError):
|
||||
cache = {}
|
||||
cached = cache.get(cache_key, {}) if isinstance(cache, dict) else {}
|
||||
age = time.time() - float(cached.get("checked_epoch", 0))
|
||||
if age <= max(30, ttl):
|
||||
fcntl.flock(lock.fileno(), fcntl.LOCK_UN)
|
||||
return bool(cached.get("healthy")), f"cached:{cached.get('reason', 'unknown')}"
|
||||
fcntl.flock(lock.fileno(), fcntl.LOCK_UN)
|
||||
|
||||
prompt = (
|
||||
"Patch capability probe. Return ONLY a unified git diff inside a ```diff fence that changes "
|
||||
"the only line in probe.txt from old to new. No prose. The diff must begin with "
|
||||
"`diff --git a/probe.txt b/probe.txt`."
|
||||
)
|
||||
timeout_setting = (
|
||||
os.environ.get("CASAN_GOAL_LOCAL_MODEL_PREFLIGHT_TIMEOUT_SEC", "90")
|
||||
if model.startswith("ollama:")
|
||||
else os.environ.get("CASAN_GOAL_MODEL_PREFLIGHT_TIMEOUT_SEC", "30")
|
||||
)
|
||||
timeout = max(5, min(int(timeout_setting), 120))
|
||||
if model.startswith("account:"):
|
||||
ok, output, _, reason = call_account_model(model.split(":", 1)[1], prompt, timeout)
|
||||
else:
|
||||
ok, output, _, reason = call_model(
|
||||
model, prompt, model.startswith(("openai:", "anthropic:", "openai-compatible:")),
|
||||
timeout_seconds=timeout, max_output_tokens=512,
|
||||
)
|
||||
healthy = False
|
||||
if ok:
|
||||
try:
|
||||
patch = extract_patch(output)
|
||||
healthy = (
|
||||
"diff --git a/probe.txt b/probe.txt" in patch
|
||||
and "\n-old\n" in patch
|
||||
and "\n+new\n" in patch
|
||||
)
|
||||
reason = "ok" if healthy else "patch_probe_contract_invalid"
|
||||
except ValueError as error:
|
||||
reason = str(error)
|
||||
record = {
|
||||
"model": model,
|
||||
"provider": provider_for_model(model),
|
||||
"healthy": healthy,
|
||||
"reason": reason[:180],
|
||||
"checked_at": now(),
|
||||
"checked_epoch": time.time(),
|
||||
}
|
||||
with open(lock_path, "a", encoding="utf-8") as lock:
|
||||
fcntl.flock(lock.fileno(), fcntl.LOCK_EX)
|
||||
try:
|
||||
cache = load_json(cache_path) if os.path.isfile(cache_path) else {}
|
||||
except (OSError, ValueError, json.JSONDecodeError):
|
||||
cache = {}
|
||||
cache[cache_key] = record
|
||||
atomic_json(cache_path, cache)
|
||||
fcntl.flock(lock.fileno(), fcntl.LOCK_UN)
|
||||
return healthy, reason
|
||||
|
||||
|
||||
def provider_for_model(model: str) -> str:
|
||||
if model.startswith("account:"):
|
||||
return f"{model.split(':', 1)[1]}-account"
|
||||
if model.startswith("openai:"):
|
||||
return "openai"
|
||||
if model.startswith("anthropic:"):
|
||||
@@ -420,7 +611,7 @@ def repair_write_output(model: str, original_prompt: str, invalid_output: str, c
|
||||
def repair_prompt_for(previous_output: str, error: str) -> str:
|
||||
hunk_instruction = (
|
||||
"The previous diff is syntactically corrupt. Recompute every `@@ -old,count +new,count @@` header from the exact added/removed/context lines that follow it; do not omit or invent any hunk line. "
|
||||
if "corrupt patch" in error.lower() else ""
|
||||
if mechanical_patch_error(error) else ""
|
||||
)
|
||||
return (
|
||||
f"Your previous response violated the required write-output contract: {error}. " +
|
||||
@@ -434,7 +625,21 @@ def repair_write_output(model: str, original_prompt: str, invalid_output: str, c
|
||||
last_metadata, last_reason = {}, "goal_patch_missing"
|
||||
attempts = []
|
||||
for attempt_number, candidate in enumerate(candidates, start=1):
|
||||
ok, output, metadata, reason = call_model(candidate, repair_prompt, candidate.startswith(("openai:", "anthropic:", "openai-compatible:")), max_output_tokens=patch_output_tokens())
|
||||
healthy, preflight_reason = model_preflight(candidate)
|
||||
if not healthy:
|
||||
last_reason = f"goal_patch_model_preflight_failed:{preflight_reason}"
|
||||
attempts.append({
|
||||
"attempt": attempt_number,
|
||||
"provider": provider_for_model(candidate),
|
||||
"model": candidate,
|
||||
"status": "failed",
|
||||
"reason": last_reason,
|
||||
})
|
||||
continue
|
||||
if candidate.startswith("account:"):
|
||||
ok, output, metadata, reason = call_account_model(candidate.split(":", 1)[1], repair_prompt, 300)
|
||||
else:
|
||||
ok, output, metadata, reason = call_model(candidate, repair_prompt, candidate.startswith(("openai:", "anthropic:", "openai-compatible:")), max_output_tokens=patch_output_tokens())
|
||||
metadata = dict(metadata)
|
||||
metadata["repair_model"] = candidate
|
||||
metadata["repair_attempt"] = attempt_number
|
||||
@@ -464,7 +669,7 @@ def repair_write_output(model: str, original_prompt: str, invalid_output: str, c
|
||||
# incomplete first diff. Give the same stronger direct model one
|
||||
# corrective pass containing its own failed patch and git error
|
||||
# before sending source context to a weaker gateway.
|
||||
if candidate.endswith("-codex") and attempt_number < patch_repair_attempts():
|
||||
if (candidate.endswith("-codex") or candidate == "account:codex") and attempt_number < patch_repair_attempts():
|
||||
candidates.insert(attempt_number, candidate)
|
||||
del candidates[patch_repair_attempts():]
|
||||
repair_prompt = repair_prompt_for(output, last_reason)
|
||||
@@ -493,8 +698,52 @@ def merge_usage(primary: dict, additional: dict) -> dict:
|
||||
return merged
|
||||
|
||||
|
||||
def validate_patch_semantics(job: dict, artifact: str, changed_files: list[str]) -> list[dict]:
|
||||
"""Build and test an applied patch in an isolated workspace before approval."""
|
||||
artifact = os.path.realpath(artifact)
|
||||
if os.path.commonpath([ROOT, artifact]) != ROOT or not os.path.isfile(artifact):
|
||||
raise ValueError("goal_patch_artifact_path_denied")
|
||||
manifest = PROJECT_MANIFEST.load(ROOT, project_id=str(job.get("project") or ""))
|
||||
commands = PROJECT_MANIFEST.verification_commands(manifest, changed_files)
|
||||
if not commands:
|
||||
raise ValueError("goal_patch_verification_unmapped")
|
||||
results = []
|
||||
with tempfile.TemporaryDirectory(prefix="casan-goal-verify-") as sandbox:
|
||||
paths = ["package.json", "package-lock.json", *manifest["source_roots"]]
|
||||
paths.extend(str(value) for value in job.get("workspace", {}).get("context_roots", []))
|
||||
for relative in dict.fromkeys(paths):
|
||||
source = os.path.realpath(os.path.join(ROOT, relative))
|
||||
if os.path.commonpath([ROOT, source]) != ROOT or not os.path.exists(source):
|
||||
continue
|
||||
destination = os.path.join(sandbox, relative)
|
||||
os.makedirs(os.path.dirname(destination), exist_ok=True)
|
||||
if os.path.isdir(source):
|
||||
shutil.copytree(source, destination, dirs_exist_ok=True, symlinks=True, ignore=shutil.ignore_patterns("node_modules", "dist", "coverage"))
|
||||
else:
|
||||
shutil.copy2(source, destination)
|
||||
dependencies = os.path.join(ROOT, "node_modules")
|
||||
if os.path.isdir(dependencies):
|
||||
os.symlink(dependencies, os.path.join(sandbox, "node_modules"), target_is_directory=True)
|
||||
applied = subprocess.run(
|
||||
["git", "apply", "--whitespace=error", artifact], cwd=sandbox,
|
||||
capture_output=True, text=True, timeout=30,
|
||||
)
|
||||
if applied.returncode != 0:
|
||||
raise ValueError("goal_patch_sandbox_apply_failed:" + (applied.stderr or applied.stdout).strip()[:300])
|
||||
timeout = max(30, int(os.environ.get("CASAN_GOAL_PATCH_VERIFY_TIMEOUT_SEC", "300")))
|
||||
for command in commands:
|
||||
result = subprocess.run(command, cwd=sandbox, capture_output=True, text=True, timeout=timeout)
|
||||
output = (result.stdout + result.stderr)[-4000:]
|
||||
results.append({"command": " ".join(command), "exit_code": result.returncode, "output": output})
|
||||
if result.returncode != 0:
|
||||
raise ValueError("goal_patch_verification_failed:" + output[-1200:])
|
||||
return results
|
||||
|
||||
|
||||
def validate_and_store_patch(job_path: str, job: dict, patch: str) -> dict:
|
||||
roots = [str(value).strip("/") for value in job.get("workspace", {}).get("context_roots", [])]
|
||||
explicit_scope = restricted_context_paths(str(job.get("goal") or ""))
|
||||
roots = [path.strip("/") for path, _ in explicit_scope] or [str(value).strip("/") for value in job.get("workspace", {}).get("context_roots", [])]
|
||||
directory_roots = {path.strip("/") for path, scoped_directory in explicit_scope if scoped_directory}
|
||||
changed = []
|
||||
header_paths = []
|
||||
for line in patch.splitlines():
|
||||
@@ -514,7 +763,7 @@ def validate_and_store_patch(job_path: str, job: dict, patch: str) -> dict:
|
||||
path = path[2:]
|
||||
if path.startswith("/") or ".." in path.split("/"):
|
||||
raise ValueError("goal_patch_path_denied")
|
||||
if not any(path == root or path.startswith(root + "/") for root in roots):
|
||||
if not any(path == root or (root in directory_roots and path.startswith(root + "/")) for root in roots):
|
||||
raise ValueError(f"goal_patch_outside_workspace:{path}")
|
||||
changed.append(path)
|
||||
if not changed or len(set(changed)) > 20:
|
||||
@@ -529,7 +778,12 @@ def validate_and_store_patch(job_path: str, job: dict, patch: str) -> dict:
|
||||
if check.returncode != 0:
|
||||
os.unlink(artifact)
|
||||
raise ValueError("goal_patch_check_failed:" + (check.stderr or check.stdout).strip()[:160])
|
||||
return {"path": os.path.relpath(artifact, ROOT), "sha256": sha(patch), "files": sorted(set(changed)), "bytes": len(patch.encode("utf-8")), "status": "awaiting_approval", "preview": patch[:50_000]}
|
||||
try:
|
||||
verification = validate_patch_semantics(job, artifact, sorted(set(changed)))
|
||||
except Exception:
|
||||
os.unlink(artifact)
|
||||
raise
|
||||
return {"path": os.path.relpath(artifact, ROOT), "sha256": sha(patch), "files": sorted(set(changed)), "bytes": len(patch.encode("utf-8")), "status": "awaiting_approval", "preview": patch[:50_000], "preapproval_verification": verification}
|
||||
|
||||
|
||||
def submit_side_effect(job: dict, manifest: dict, patch_artifact: dict) -> dict:
|
||||
@@ -702,8 +956,8 @@ def reviewer_candidates(account_provider: str, cloud_model: str, local_model: st
|
||||
candidates.append({"kind": "model", "provider": "omniroute", "model": value, "value": value})
|
||||
|
||||
local_reviewer = os.environ.get("CASAN_GOAL_LOCAL_REVIEWER_MODEL", "").strip() or local_model
|
||||
if local_reviewer:
|
||||
candidates.append({"kind": "model", "provider": os.environ.get("CASAN_GOAL_LOCAL_PROVIDER", "local-policy"), "model": local_reviewer, "value": local_reviewer})
|
||||
if local_reviewer and os.environ.get("CASAN_GOAL_ENABLE_LOCAL_REVIEWER", "1") == "1":
|
||||
candidates.append({"kind": "model", "provider": os.environ.get("CASAN_GOAL_LOCAL_REVIEWER_PROVIDER", os.environ.get("CASAN_GOAL_LOCAL_PROVIDER", "local-policy")), "model": local_reviewer, "value": local_reviewer})
|
||||
|
||||
unique, seen = [], set()
|
||||
for candidate in candidates:
|
||||
@@ -714,15 +968,17 @@ def reviewer_candidates(account_provider: str, cloud_model: str, local_model: st
|
||||
return unique
|
||||
|
||||
|
||||
def run_reviewer_chain(job_path: str, prompt: str, account_provider: str, cloud_model: str, local_model: str):
|
||||
def run_reviewer_chain(job_path: str, prompt: str, account_provider: str, cloud_model: str, local_model: str, max_output_tokens=None, excluded_models=None):
|
||||
max_attempts = max(1, min(int(os.environ.get("CASAN_GOAL_REVIEWER_MAX_ATTEMPTS", "5")), 10))
|
||||
deadline_seconds = max(1, min(int(os.environ.get("CASAN_GOAL_REVIEWER_DEADLINE_SEC", "360")), 900))
|
||||
deadline = time.monotonic() + deadline_seconds
|
||||
ledger = []
|
||||
update_job(job_path, reviewer_attempts=ledger)
|
||||
last_reason = "reviewer_candidates_unavailable"
|
||||
candidates = reviewer_candidates(account_provider, cloud_model, local_model)
|
||||
if len(candidates) > max_attempts and candidates[-1].get("provider") == os.environ.get("CASAN_GOAL_LOCAL_PROVIDER", "local-policy"):
|
||||
excluded = set(excluded_models or [])
|
||||
candidates = [candidate for candidate in reviewer_candidates(account_provider, cloud_model, local_model) if candidate.get("value") not in excluded]
|
||||
local_reviewer_provider = os.environ.get("CASAN_GOAL_LOCAL_REVIEWER_PROVIDER", os.environ.get("CASAN_GOAL_LOCAL_PROVIDER", "local-policy"))
|
||||
if len(candidates) > max_attempts and candidates[-1].get("provider") == local_reviewer_provider:
|
||||
candidates = candidates[:max_attempts - 1] + [candidates[-1]] if max_attempts > 1 else [candidates[-1]]
|
||||
else:
|
||||
candidates = candidates[:max_attempts]
|
||||
@@ -734,10 +990,26 @@ def run_reviewer_chain(job_path: str, prompt: str, account_provider: str, cloud_
|
||||
attempt_number = len(ledger) + 1
|
||||
stage(job_path, "cloud-reviewer", "running", f"Reviewer attempt {attempt_number}/{max_attempts}", candidate["provider"], candidate["model"])
|
||||
started_at, started_clock = now(), time.monotonic()
|
||||
if candidate["kind"] == "model" and max_output_tokens is not None:
|
||||
healthy, preflight_reason = model_preflight(candidate["value"])
|
||||
if not healthy:
|
||||
reason = f"model_preflight_failed:{preflight_reason}"
|
||||
ledger.append({
|
||||
"attempt": attempt_number, "provider": candidate["provider"], "model": candidate["model"],
|
||||
"status": "failed", "reason": reason, "retryable": True,
|
||||
"started_at": started_at, "finished_at": now(),
|
||||
"latency_ms": int((time.monotonic() - started_clock) * 1000),
|
||||
})
|
||||
update_job(job_path, reviewer_attempts=ledger)
|
||||
last_reason = reason
|
||||
continue
|
||||
if candidate["kind"] == "account":
|
||||
ok, result, metadata, reason = call_account_model(candidate["value"], prompt, remaining)
|
||||
else:
|
||||
ok, result, metadata, reason = call_model(candidate["value"], prompt, candidate["provider"] != os.environ.get("CASAN_GOAL_LOCAL_PROVIDER", "local-policy"), remaining)
|
||||
if max_output_tokens is None:
|
||||
ok, result, metadata, reason = call_model(candidate["value"], prompt, candidate["provider"] != local_reviewer_provider, remaining)
|
||||
else:
|
||||
ok, result, metadata, reason = call_model(candidate["value"], prompt, candidate["provider"] != local_reviewer_provider, remaining, max_output_tokens=max_output_tokens)
|
||||
retryable = False if ok else reviewer_failure_retryable(reason)
|
||||
ledger.append({
|
||||
"attempt": attempt_number, "provider": candidate["provider"], "model": candidate["model"],
|
||||
@@ -846,6 +1118,12 @@ def run(job_path: str) -> int:
|
||||
})
|
||||
emit(goal_id, "H4-security", "running", "Objective and workspace snapshot passed; model outputs pending")
|
||||
|
||||
local_healthy, local_preflight_reason = model_preflight(local_model)
|
||||
if not local_healthy:
|
||||
stage(job_path, "local-worker", "error", f"model_preflight_failed:{local_preflight_reason}", job.get("local_provider", ""), local_model)
|
||||
emit(goal_id, "H2-tool", "error", "Local worker failed patch-generation preflight", {"provider": job.get("local_provider", ""), "model": local_model, "reason": local_preflight_reason})
|
||||
raise RuntimeError(f"local_worker_preflight_failed:{local_preflight_reason}")
|
||||
|
||||
stage(job_path, "local-worker", "running", "Local model is developing the primary solution", job.get("local_provider", ""), local_model)
|
||||
emit(goal_id, "H2-tool", "running", "Local worker is developing a solution", {"provider": job.get("local_provider", ""), "model": local_model})
|
||||
output_contract = (
|
||||
@@ -859,7 +1137,15 @@ def run(job_path: str) -> int:
|
||||
"Do not claim to inspect any filesystem outside this snapshot and do not perform side effects.\n\n"
|
||||
f"OBJECTIVE:\n{safe_goal}\n\nWORKSPACE SNAPSHOT ({project_id}):\n{context_bundle}"
|
||||
)
|
||||
ok, local_draft, local_meta, reason = call_model(local_model, local_prompt, False)
|
||||
if local_model.startswith("account:"):
|
||||
ok, local_draft, local_meta, reason = call_account_model(local_model.split(":", 1)[1], local_prompt, 300)
|
||||
else:
|
||||
ok, local_draft, local_meta, reason = call_model(
|
||||
local_model,
|
||||
local_prompt,
|
||||
False,
|
||||
max_output_tokens=patch_output_tokens() if write_intent else None,
|
||||
)
|
||||
if not ok:
|
||||
stage(job_path, "local-worker", "error", reason, job.get("local_provider", ""), local_model)
|
||||
emit(goal_id, "H2-tool", "error", "Local worker failed", {"reason": reason})
|
||||
@@ -897,14 +1183,18 @@ def run(job_path: str) -> int:
|
||||
stage(job_path, "local-worker", "error", reason, provider_for_model(actual_repair_model), actual_repair_model)
|
||||
emit(goal_id, "H2-tool", "error", "Worker violated patch output contract after repair", {"reason": reason, "repair_provider": provider_for_model(actual_repair_model), "repair_model": actual_repair_model, "repair_attempts": repaired_meta.get("repair_attempts", [])})
|
||||
raise ValueError(reason)
|
||||
stage(job_path, "local-worker", "pass", "Primary solution prepared", job.get("local_provider", ""), local_model)
|
||||
effective_local_model = str(local_meta.get("repair_model") or local_model)
|
||||
effective_local_provider = provider_for_model(effective_local_model)
|
||||
stage(job_path, "local-worker", "pass", "Primary solution prepared", effective_local_provider, effective_local_model)
|
||||
update_job(
|
||||
job_path,
|
||||
local_draft=safe_local,
|
||||
local_usage=local_meta,
|
||||
patch_repair_attempts=local_meta.get("repair_attempts", []),
|
||||
effective_local_provider=effective_local_provider,
|
||||
effective_local_model=effective_local_model,
|
||||
)
|
||||
emit(goal_id, "H2-tool", "pass", "Local solution prepared", {"provider": job.get("local_provider", ""), "model": local_model, **local_meta})
|
||||
emit(goal_id, "H2-tool", "pass", "Primary solution prepared", {"provider": effective_local_provider, "model": effective_local_model, **local_meta})
|
||||
|
||||
stage(job_path, "cloud-reviewer", "running", "Independent reviewer is challenging and improving the local solution", job.get("cloud_provider", ""), cloud_model)
|
||||
emit(goal_id, "H3-eval", "running", "Cloud reviewer is evaluating the local solution", {"provider": job.get("cloud_provider", ""), "model": cloud_model})
|
||||
@@ -923,7 +1213,13 @@ def run(job_path: str) -> int:
|
||||
f"LOCAL WORKER PROPOSAL:\n{safe_local[:5000]}"
|
||||
)
|
||||
cloud_ok, cloud_result, cloud_meta, cloud_reason, reviewer = run_reviewer_chain(
|
||||
job_path, review_prompt, account_provider, cloud_model, local_model
|
||||
job_path,
|
||||
review_prompt,
|
||||
account_provider,
|
||||
cloud_model,
|
||||
local_model,
|
||||
max_output_tokens=patch_output_tokens() if write_intent else None,
|
||||
excluded_models={effective_local_model},
|
||||
)
|
||||
reviewer_provider = str(reviewer.get("provider") or job.get("cloud_provider", ""))
|
||||
reviewer_model = str(reviewer.get("model") or cloud_model)
|
||||
@@ -965,8 +1261,13 @@ def run(job_path: str) -> int:
|
||||
patch_artifact["approval_id"] = proposal["id"]
|
||||
final_status = "requires_approval"
|
||||
metric_status = "degraded"
|
||||
safe_result = "Implementation patch generated and independently reviewed. Approval is required before applying it to the workspace."
|
||||
emit(goal_id, "H7-orchestration", "blocked", "Reviewed patch awaits approval", {"proposal_id": proposal["id"], "patch_sha256": patch_artifact["sha256"]})
|
||||
if cloud_ok:
|
||||
safe_result = "Implementation patch generated and independently reviewed. Approval is required before applying it to the workspace."
|
||||
wait_detail = "Independently reviewed patch awaits approval"
|
||||
else:
|
||||
safe_result = "Implementation patch generated and validated. Automated H3 review was unavailable, so Independent Reviewer approval is required before applying it to the workspace."
|
||||
wait_detail = "Validated patch awaits Independent Reviewer approval after H3 degradation"
|
||||
emit(goal_id, "H7-orchestration", "blocked", wait_detail, {"proposal_id": proposal["id"], "patch_sha256": patch_artifact["sha256"], "cloud_incorporated": cloud_ok})
|
||||
job = update_job(job_path, status=final_status, result=safe_result, patch_artifact=patch_artifact, approval=approval, cloud_usage=cloud_meta, finished_at=now())
|
||||
else:
|
||||
job = update_job(job_path, status=final_status, result=safe_result, cloud_usage=cloud_meta, finished_at=now())
|
||||
|
||||
@@ -97,14 +97,29 @@ def _manifest_for_files(files: list[str]) -> dict:
|
||||
|
||||
def verification_commands(files: list[str], manifest: dict | None = None) -> list[list[str]]:
|
||||
project = manifest or _manifest_for_files(files)
|
||||
return [["git", "diff", "--check", "--", *files], *PROJECT_MANIFEST.verification_commands(project, files)]
|
||||
# `git apply --check --whitespace=error` already validates the exact patch
|
||||
# before mutation. Runtime images intentionally do not need repository
|
||||
# metadata, so post-apply verification is limited to manifest build/tests.
|
||||
return PROJECT_MANIFEST.verification_commands(project, files)
|
||||
|
||||
|
||||
def ready_for_apply(job: dict) -> bool:
|
||||
if not job.get("patch_artifact"):
|
||||
return False
|
||||
if job.get("status") == "requires_approval":
|
||||
return True
|
||||
return (
|
||||
job.get("status") == "failed"
|
||||
and job.get("error") == "GOAL_APPLY_VERIFICATION_FAILED_ROLLED_BACK"
|
||||
)
|
||||
|
||||
|
||||
def execute(job_path: str, actor: str) -> dict:
|
||||
job = load(job_path)
|
||||
if job.get("status") != "requires_approval" or not job.get("patch_artifact"):
|
||||
if not ready_for_apply(job):
|
||||
raise RuntimeError("GOAL_APPLY_JOB_NOT_READY")
|
||||
proposal = verify_approval(job)
|
||||
job.setdefault("approval", {})["status"] = "approved"
|
||||
if proposal.get("approver") != actor:
|
||||
raise PermissionError("GOAL_APPLY_APPROVER_IDENTITY_MISMATCH")
|
||||
artifact = job["patch_artifact"]
|
||||
@@ -135,7 +150,8 @@ def execute(job_path: str, actor: str) -> dict:
|
||||
rollback = run(["git", "apply", "--reverse", patch_path], 30)
|
||||
if rollback.returncode != 0:
|
||||
raise RuntimeError("GOAL_APPLY_ROLLBACK_FAILED")
|
||||
job.update(status="failed", error="GOAL_APPLY_VERIFICATION_FAILED_ROLLED_BACK", verification=checks, finished_at=now(), updated_at=now())
|
||||
artifact["status"] = "awaiting_apply_retry"
|
||||
job.update(status="requires_approval", error="GOAL_APPLY_VERIFICATION_FAILED_ROLLED_BACK", patch_artifact=artifact, verification=checks, finished_at=now(), updated_at=now())
|
||||
save(job_path, job)
|
||||
raise
|
||||
|
||||
|
||||
@@ -25,6 +25,22 @@ TRACE_DIR="$LOG_DIR/trace"
|
||||
AUDIT_DIR="$LOG_DIR/audit"
|
||||
SECURITY_DIR="$CASAN_HARNESS_ROOT/security"
|
||||
|
||||
# Prefer the OS Python over framework/shim installations that may exist in a
|
||||
# developer shell but cannot execute. The runtime image also exposes this path.
|
||||
PYTHON_BIN="${CASAN_PYTHON_BIN:-}"
|
||||
if [[ -z "$PYTHON_BIN" ]]; then
|
||||
for candidate in /usr/bin/python3 python3 python; do
|
||||
if command -v "$candidate" >/dev/null 2>&1 && "$candidate" --version >/dev/null 2>&1; then
|
||||
PYTHON_BIN="$candidate"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
if [[ -z "$PYTHON_BIN" ]]; then
|
||||
echo "SECURITY_RUNTIME_UNAVAILABLE: working Python 3 interpreter not found" >&2
|
||||
exit 69
|
||||
fi
|
||||
|
||||
# Shared log taxonomy (error<warn<info<debug<trace via CASAN_LOG_LEVEL). Used to
|
||||
# make semantic skips loud (never silent) — stderr only, stdout contract intact.
|
||||
# shellcheck source=casan-log.sh
|
||||
@@ -59,7 +75,7 @@ new_trace_id() {
|
||||
}
|
||||
|
||||
json_escape() {
|
||||
python -c 'import json,sys; print(json.dumps(sys.stdin.read()))' 2>/dev/null || sed 's/\\/\\\\/g; s/"/\\"/g'
|
||||
"$PYTHON_BIN" -c 'import json,sys; print(json.dumps(sys.stdin.read()))' 2>/dev/null || sed 's/\\/\\\\/g; s/"/\\"/g'
|
||||
}
|
||||
|
||||
hash_text() {
|
||||
@@ -85,7 +101,7 @@ load_yaml_values() {
|
||||
local file="$1"
|
||||
local key="$2"
|
||||
[[ -f "$file" ]] || return 0
|
||||
python - "$file" "$key" <<'PY'
|
||||
"$PYTHON_BIN" - "$file" "$key" <<'PY'
|
||||
import re
|
||||
import sys
|
||||
path, key = sys.argv[1], sys.argv[2]
|
||||
@@ -98,6 +114,45 @@ with open(path, encoding="utf-8") as f:
|
||||
PY
|
||||
}
|
||||
|
||||
# Load only rule patterns whose declared action matches the requested action.
|
||||
# The previous generic loader returned every `pattern:` in prompt-filter.yaml,
|
||||
# which accidentally promoted `require_approval`, `alert`, and `log` rules to
|
||||
# hard blocks. That made ordinary source code containing methods such as
|
||||
# `delete()` fail the workspace-context scan as prompt injection.
|
||||
load_yaml_rule_patterns() {
|
||||
local file="$1"
|
||||
local requested_action="$2"
|
||||
[[ -f "$file" ]] || return 0
|
||||
"$PYTHON_BIN" - "$file" "$requested_action" <<'PY'
|
||||
import re
|
||||
import sys
|
||||
|
||||
path, requested_action = sys.argv[1], sys.argv[2]
|
||||
pattern = None
|
||||
action = None
|
||||
|
||||
def flush():
|
||||
if pattern is not None and action == requested_action:
|
||||
print(pattern)
|
||||
|
||||
with open(path, encoding="utf-8") as handle:
|
||||
for line in handle:
|
||||
if re.match(r"^\s*-\s+id:\s*", line):
|
||||
flush()
|
||||
pattern = None
|
||||
action = None
|
||||
continue
|
||||
pattern_match = re.match(r'^\s*pattern:\s*"(.*)"\s*$', line)
|
||||
if pattern_match:
|
||||
pattern = pattern_match.group(1)
|
||||
continue
|
||||
action_match = re.match(r"^\s*action:\s*([A-Za-z_]+)\s*$", line)
|
||||
if action_match:
|
||||
action = action_match.group(1)
|
||||
flush()
|
||||
PY
|
||||
}
|
||||
|
||||
TRACE_ID="$(new_trace_id)"
|
||||
TIMESTAMP="$(timestamp)"
|
||||
CONTENT="$(cat "$INPUT_FILE")"
|
||||
@@ -126,7 +181,7 @@ BLOCK_PATTERNS=(
|
||||
|
||||
while IFS= read -r pattern; do
|
||||
[[ -n "$pattern" ]] && BLOCK_PATTERNS+=("$pattern")
|
||||
done < <(load_yaml_values "$SECURITY_DIR/prompt-filter.yaml" "pattern")
|
||||
done < <(load_yaml_rule_patterns "$SECURITY_DIR/prompt-filter.yaml" "block")
|
||||
|
||||
APPROVAL_PATTERNS=(
|
||||
"delete[[:space:]].*"
|
||||
@@ -169,8 +224,8 @@ NORM_CONTENT="$(normalize_for_match "$CONTENT")"
|
||||
# fullwidth/zero-width/Cyrillic-lookalike obfuscation cannot split or disguise
|
||||
# a blocked phrase (V3). Falls back to the raw content if python is missing.
|
||||
UNI_CONTENT="$CONTENT"
|
||||
if command -v python >/dev/null 2>&1; then
|
||||
UNI_CONTENT="$(printf '%s' "$CONTENT" | python "$SCRIPT_DIR/unicode-normalize.py" 2>/dev/null)"
|
||||
if [[ -n "$PYTHON_BIN" ]]; then
|
||||
UNI_CONTENT="$(printf '%s' "$CONTENT" | "$PYTHON_BIN" "$SCRIPT_DIR/unicode-normalize.py" 2>/dev/null)"
|
||||
[[ -n "$UNI_CONTENT" ]] || UNI_CONTENT="$CONTENT"
|
||||
fi
|
||||
UNI_NORM_CONTENT="$(normalize_for_match "$UNI_CONTENT")"
|
||||
@@ -180,8 +235,8 @@ UNI_NORM_CONTENT="$(normalize_for_match "$UNI_CONTENT")"
|
||||
# Only mostly-printable decodes survive, so random base64-looking words never
|
||||
# create a false positive.
|
||||
DECODED_CONTENT=""
|
||||
if command -v python >/dev/null 2>&1; then
|
||||
DECODED_CONTENT="$(printf '%s' "$CONTENT" | python "$SCRIPT_DIR/decode-suspicious.py" 2>/dev/null || true)"
|
||||
if [[ -n "$PYTHON_BIN" ]]; then
|
||||
DECODED_CONTENT="$(printf '%s' "$CONTENT" | "$PYTHON_BIN" "$SCRIPT_DIR/decode-suspicious.py" 2>/dev/null || true)"
|
||||
fi
|
||||
|
||||
# Matches a pattern against the raw (case-insensitive), leetspeak-folded,
|
||||
@@ -279,7 +334,7 @@ if [[ "$MODE" == "input" ]]; then
|
||||
SEM_JSON="$TRACE_DIR/semantic-$TRACE_ID.json"
|
||||
"$SCRIPT_DIR/model-router.sh" "$INPUT_FILE" "$SEM_JSON" --role classify >/dev/null 2>&1 || true
|
||||
if [[ -f "$SEM_JSON" ]]; then
|
||||
SEM_VERDICT="$(python -c "import json;print(json.load(open('$SEM_JSON')).get('verdict',''))" 2>/dev/null || echo "")"
|
||||
SEM_VERDICT="$("$PYTHON_BIN" -c "import json;print(json.load(open('$SEM_JSON')).get('verdict',''))" 2>/dev/null || echo "")"
|
||||
fi
|
||||
fi
|
||||
if [[ "$SEM_VERDICT" == "INJECTION" ]]; then
|
||||
@@ -302,8 +357,8 @@ fi
|
||||
SAFE_CONTENT="$CONTENT"
|
||||
# Policy-driven PII masking (source of truth: pii-rules.yaml). Built-in sed
|
||||
# masking below remains as defense-in-depth if the policy file is unavailable.
|
||||
if [[ -f "$SECURITY_DIR/pii-rules.yaml" ]] && command -v python >/dev/null 2>&1; then
|
||||
SAFE_CONTENT="$(printf '%s' "$SAFE_CONTENT" | python "$SCRIPT_DIR/pii-mask.py" "$SECURITY_DIR/pii-rules.yaml")"
|
||||
if [[ -f "$SECURITY_DIR/pii-rules.yaml" ]] && [[ -n "$PYTHON_BIN" ]]; then
|
||||
SAFE_CONTENT="$(printf '%s' "$SAFE_CONTENT" | "$PYTHON_BIN" "$SCRIPT_DIR/pii-mask.py" "$SECURITY_DIR/pii-rules.yaml")"
|
||||
fi
|
||||
SAFE_CONTENT="$(printf '%s' "$SAFE_CONTENT" | sed -E "s/$EMAIL_REGEX/***MASKED_EMAIL***/g")"
|
||||
SAFE_CONTENT="$(printf '%s' "$SAFE_CONTENT" | sed -E "s/$PHONE_REGEX/***MASKED_PHONE***/g")"
|
||||
@@ -333,7 +388,7 @@ fi
|
||||
|
||||
INPUT_HASH="$(printf '%s' "$CONTENT" | hash_text)"
|
||||
OUTPUT_HASH="$(printf '%s' "$SAFE_CONTENT" | hash_text)"
|
||||
RULES_JSON="$(printf '%s\n' "${MATCHED_RULES[@]:-}" | python -c 'import json,sys; print(json.dumps([x for x in sys.stdin.read().splitlines() if x]))')"
|
||||
RULES_JSON="$(printf '%s\n' "${MATCHED_RULES[@]:-}" | "$PYTHON_BIN" -c 'import json,sys; print(json.dumps([x for x in sys.stdin.read().splitlines() if x]))')"
|
||||
|
||||
TRACE_FILE="$TRACE_DIR/security-$TRACE_ID.json"
|
||||
cat > "$TRACE_FILE" <<EOF
|
||||
|
||||
@@ -26,6 +26,15 @@ def job_file(directory):
|
||||
|
||||
|
||||
class ReviewerFallbackTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
# Production enables route preflight. Individual tests opt in explicitly
|
||||
# so cached/live provider health cannot affect deterministic unit tests.
|
||||
self.environment = patch.dict(
|
||||
os.environ, {"CASAN_GOAL_MODEL_PREFLIGHT": "0"}, clear=False,
|
||||
)
|
||||
self.environment.start()
|
||||
self.addCleanup(self.environment.stop)
|
||||
|
||||
def test_account_then_omniroute_then_local_and_persists_ledger(self):
|
||||
calls = []
|
||||
|
||||
@@ -115,6 +124,78 @@ class ReviewerFallbackTests(unittest.TestCase):
|
||||
self.assertEqual(calls, ["openai:gpt", "openai-compatible:route-a", "ollama:local"])
|
||||
self.assertFalse(job["reviewer_attempts"][0]["retryable"])
|
||||
|
||||
def test_patch_reviewer_excludes_h2_model_and_receives_full_output_budget(self):
|
||||
calls = []
|
||||
|
||||
def model(model, prompt, cloud, timeout, max_output_tokens=None):
|
||||
calls.append((model, max_output_tokens))
|
||||
return True, "reviewed patch", {}, "ok"
|
||||
|
||||
environment = {
|
||||
"CASAN_GOAL_OMNIROUTE_MODELS": "route-a",
|
||||
"CASAN_GOAL_REVIEWER_MAX_ATTEMPTS": "3",
|
||||
"CASAN_GOAL_REVIEWER_DEADLINE_SEC": "30",
|
||||
}
|
||||
with tempfile.TemporaryDirectory() as directory, patch.dict(os.environ, environment, clear=False), patch.object(MODULE, "call_model", model):
|
||||
path = job_file(directory)
|
||||
ok, _, _, _, reviewer = MODULE.run_reviewer_chain(
|
||||
path, "prompt", "", "openai:gpt-worker", "ollama:local",
|
||||
max_output_tokens=8192, excluded_models={"openai:gpt-worker"},
|
||||
)
|
||||
|
||||
self.assertTrue(ok)
|
||||
self.assertEqual(reviewer["model"], "openai-compatible:route-a")
|
||||
self.assertEqual(calls, [("openai-compatible:route-a", 8192)])
|
||||
|
||||
def test_local_reviewer_can_be_disabled_when_human_approval_is_available(self):
|
||||
calls = []
|
||||
|
||||
def model(model, prompt, cloud, timeout):
|
||||
calls.append(model)
|
||||
return False, "", {}, "gateway_unavailable"
|
||||
|
||||
environment = {
|
||||
"CASAN_GOAL_OMNIROUTE_MODELS": "route-a",
|
||||
"CASAN_GOAL_ENABLE_LOCAL_REVIEWER": "0",
|
||||
"CASAN_GOAL_REVIEWER_MAX_ATTEMPTS": "3",
|
||||
"CASAN_GOAL_REVIEWER_DEADLINE_SEC": "30",
|
||||
}
|
||||
with tempfile.TemporaryDirectory() as directory, patch.dict(os.environ, environment, clear=False), patch.object(MODULE, "call_model", model):
|
||||
path = job_file(directory)
|
||||
ok, _, _, reason, _ = MODULE.run_reviewer_chain(path, "prompt", "", "", "ollama:ornith:9b")
|
||||
|
||||
self.assertFalse(ok)
|
||||
self.assertEqual(reason, "gateway_unavailable")
|
||||
self.assertEqual(calls, ["openai-compatible:route-a"])
|
||||
|
||||
def test_patch_reviewer_skips_unhealthy_route_before_full_generation(self):
|
||||
calls = []
|
||||
|
||||
def model(model, prompt, cloud, timeout, max_output_tokens=None):
|
||||
calls.append(model)
|
||||
return True, "reviewed", {}, "ok"
|
||||
|
||||
environment = {
|
||||
"CASAN_GOAL_OMNIROUTE_MODELS": "route-b",
|
||||
"CASAN_GOAL_ENABLE_LOCAL_REVIEWER": "0",
|
||||
"CASAN_GOAL_REVIEWER_MAX_ATTEMPTS": "3",
|
||||
"CASAN_GOAL_REVIEWER_DEADLINE_SEC": "30",
|
||||
}
|
||||
with tempfile.TemporaryDirectory() as directory, patch.dict(os.environ, environment, clear=False), \
|
||||
patch.object(MODULE, "model_preflight", side_effect=[(False, "gateway_503"), (True, "ok")]), \
|
||||
patch.object(MODULE, "call_model", model):
|
||||
path = job_file(directory)
|
||||
ok, _, _, _, reviewer = MODULE.run_reviewer_chain(
|
||||
path, "prompt", "", "openai-compatible:route-a", "ollama:ornith:9b", max_output_tokens=8192,
|
||||
)
|
||||
with open(path, encoding="utf-8") as handle:
|
||||
job = json.load(handle)
|
||||
|
||||
self.assertTrue(ok)
|
||||
self.assertEqual(reviewer["model"], "openai-compatible:route-b")
|
||||
self.assertEqual(calls, ["openai-compatible:route-b"])
|
||||
self.assertIn("model_preflight_failed", job["reviewer_attempts"][0]["reason"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -29,9 +29,79 @@ class Result:
|
||||
|
||||
|
||||
class GoalPatchWorkflowTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
# Keep unit tests independent from production route-health settings.
|
||||
# Tests that exercise preflight mock model_preflight explicitly.
|
||||
self.environment = patch.dict(
|
||||
os.environ, {"CASAN_GOAL_MODEL_PREFLIGHT": "0"}, clear=False,
|
||||
)
|
||||
self.environment.start()
|
||||
self.addCleanup(self.environment.stop)
|
||||
|
||||
def test_vietnamese_completion_goal_is_write_intent(self):
|
||||
self.assertTrue(ORCHESTRATOR.requests_side_effect("Hoàn thành component KeyResultDetail với form update progress đầy đủ"))
|
||||
|
||||
def test_phase_labeled_backend_completion_is_write_intent(self):
|
||||
objective = """PHASE 1 — Hoàn thiện backend Objective và Key Result cho ứng dụng OKR.
|
||||
|
||||
Chỉ làm việc trong phạm vi apps/okr/backend.
|
||||
Tạo patch nhưng không tự apply. Chờ Independent Reviewer phê duyệt.
|
||||
"""
|
||||
self.assertTrue(ORCHESTRATOR.requests_side_effect(objective))
|
||||
|
||||
def test_read_only_backend_audit_is_not_write_intent(self):
|
||||
objective = "Rà soát backend và liệt kê các file có rủi ro. Không sửa code, không tạo patch."
|
||||
self.assertFalse(ORCHESTRATOR.requests_side_effect(objective))
|
||||
|
||||
def test_scoped_negative_constraint_does_not_cancel_write_intent(self):
|
||||
objective = """Hoàn thiện tính nguyên tử của luồng cập nhật tiến độ Key Result trong ứng dụng OKR.
|
||||
|
||||
Không thay đổi API contract. Chỉ trả về unified git diff và chờ Independent Reviewer phê duyệt.
|
||||
"""
|
||||
self.assertTrue(ORCHESTRATOR.requests_side_effect(objective))
|
||||
|
||||
def test_valid_worker_patch_still_reaches_human_approval_when_h3_is_unavailable(self):
|
||||
diff = "diff --git a/apps/okr/backend/a.ts b/apps/okr/backend/a.ts\n--- a/apps/okr/backend/a.ts\n+++ b/apps/okr/backend/a.ts\n@@ -1 +1 @@\n-a\n+b\n"
|
||||
manifest = {"file_count": 1, "characters": 10, "truncated": False, "bundle_sha256": "context-sha"}
|
||||
artifact = {"path": ".specify/state/goals/default/job.patch", "sha256": "patch-sha", "files": ["apps/okr/backend/a.ts"], "status": "awaiting_approval"}
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = os.path.join(directory, "job.json")
|
||||
with open(path, "w", encoding="utf-8") as handle:
|
||||
json.dump({
|
||||
"id": "goal-1", "goal": "Hoàn thiện backend nhưng không thay đổi API contract.",
|
||||
"project": "AINative_OKR_CASAN4", "actor": "owner", "local_provider": "ollama",
|
||||
"cloud_provider": "omniroute", "workspace": {"context_roots": ["apps/okr/backend"]},
|
||||
"stages": [],
|
||||
}, handle)
|
||||
with (
|
||||
patch.dict(os.environ, {"CASAN_GOAL_LOCAL_MODEL": "ollama:worker", "CASAN_GOAL_CLOUD_MODEL": "openai-compatible:reviewer"}, clear=False),
|
||||
patch.object(ORCHESTRATOR, "scan", side_effect=lambda text, mode: (True, text)),
|
||||
patch.object(ORCHESTRATOR, "build_context", return_value=("snapshot", manifest, "manifest.json")),
|
||||
patch.object(ORCHESTRATOR, "call_model", return_value=(True, diff, {}, "ok")),
|
||||
patch.object(ORCHESTRATOR, "validate_write_output", return_value=diff),
|
||||
patch.object(ORCHESTRATOR, "run_reviewer_chain", return_value=(False, "", {}, "model_output_empty", {"provider": "ollama", "model": "ollama:worker"})),
|
||||
patch.object(ORCHESTRATOR, "validate_and_store_patch", return_value=artifact),
|
||||
patch.object(ORCHESTRATOR, "submit_side_effect", return_value={"id": "AP-1", "status": "pending", "action": "goal.workspace.execute"}),
|
||||
patch.object(ORCHESTRATOR, "emit"), patch.object(ORCHESTRATOR, "metric"),
|
||||
patch.object(ORCHESTRATOR, "audit", return_value="audit-sha"),
|
||||
):
|
||||
exit_code = ORCHESTRATOR.run(path)
|
||||
with open(path, encoding="utf-8") as handle:
|
||||
job = json.load(handle)
|
||||
self.assertEqual(exit_code, 0)
|
||||
self.assertEqual(job["status"], "requires_approval")
|
||||
self.assertEqual(job["approval"]["id"], "AP-1")
|
||||
self.assertIn("Automated H3 review was unavailable", job["result"])
|
||||
self.assertNotIn("independently reviewed", job["result"])
|
||||
|
||||
def test_source_code_delete_method_is_not_promoted_to_security_block(self):
|
||||
source = "export class Service { async delete(id: number) { return this.repo.delete({ where: { id } }); } }"
|
||||
with tempfile.TemporaryDirectory() as directory, \
|
||||
patch.dict(os.environ, {"CASAN_STATE_ROOT": directory, "CASAN_SECURITY_STRICT": "0"}, clear=False):
|
||||
allowed, safe_source = ORCHESTRATOR.scan(source, "input")
|
||||
self.assertTrue(allowed)
|
||||
self.assertIn("repo.delete", safe_source)
|
||||
|
||||
def test_extract_patch_requires_unified_diff(self):
|
||||
with self.assertRaisesRegex(ValueError, "goal_patch_missing"):
|
||||
ORCHESTRATOR.extract_patch("implementation plan only")
|
||||
@@ -60,6 +130,31 @@ class GoalPatchWorkflowTests(unittest.TestCase):
|
||||
self.assertIn("@@ -1,1 +1,1 @@", normalized)
|
||||
self.assertEqual(check.call_count, 2)
|
||||
|
||||
def test_patch_fragment_error_triggers_deterministic_hunk_recount(self):
|
||||
wrong_counts = (
|
||||
"diff --git a/a b/a\n--- a/a\n+++ b/a\n"
|
||||
"@@ -1,5 +1,5 @@\n-old\n+new\n"
|
||||
"@@ -10,7 +10,7 @@\n-tail-old\n+tail-new\n"
|
||||
)
|
||||
with patch.object(
|
||||
ORCHESTRATOR,
|
||||
"validate_patch_check",
|
||||
side_effect=[ValueError("goal_patch_check_failed:error: patch fragment without header at line 7: @@ -10,7 +10,7 @@"), None],
|
||||
) as check:
|
||||
normalized = ORCHESTRATOR.validate_write_output(wrong_counts)
|
||||
self.assertIn("@@ -1,1 +1,1 @@", normalized)
|
||||
self.assertIn("@@ -10,1 +10,1 @@", normalized)
|
||||
self.assertEqual(check.call_count, 2)
|
||||
|
||||
def test_patch_fragment_error_adds_recount_instruction_to_model_repair(self):
|
||||
with (
|
||||
patch.dict(os.environ, {"CASAN_GOAL_PATCH_REPAIR_ATTEMPTS": "1", "CASAN_GOAL_PATCH_REPAIR_MODELS": "openai:gpt"}, clear=False),
|
||||
patch.object(ORCHESTRATOR, "model_preflight", return_value=(True, "ok")),
|
||||
patch.object(ORCHESTRATOR, "call_model", return_value=(False, "", {}, "stopped")) as call,
|
||||
):
|
||||
ORCHESTRATOR.repair_write_output("openai:gpt", "original", "invalid", "goal_patch_check_failed:patch fragment without header")
|
||||
self.assertIn("Recompute every `@@ -old,count +new,count @@` header", call.call_args.args[1])
|
||||
|
||||
def test_truncated_replacement_is_not_reinterpreted_as_deletion(self):
|
||||
truncated = "diff --git a/a b/a\n--- a/a\n+++ b/a\n@@ -1,1 +1,1 @@\n-old\n"
|
||||
self.assertEqual(ORCHESTRATOR.normalize_unified_diff(truncated), truncated)
|
||||
@@ -80,6 +175,50 @@ class GoalPatchWorkflowTests(unittest.TestCase):
|
||||
self.assertEqual(manifest["files"][0]["characters"], len(raw.strip()))
|
||||
self.assertFalse(manifest["files"][0]["truncated"])
|
||||
|
||||
def test_explicit_scope_paths_rank_source_ahead_of_general_architecture(self):
|
||||
project = ORCHESTRATOR.registered_project("AINative_OKR_CASAN4")
|
||||
objective = """PHASE 1 — Hoàn thiện backend Objective và Key Result.
|
||||
Nguồn sự thật:
|
||||
- docs/technical_architecture.md
|
||||
- apps/okr/domain/input/okr-requirement.md
|
||||
Chỉ làm việc trong:
|
||||
- apps/okr/backend/src/objectives/**
|
||||
- apps/okr/backend/src/key-results/**
|
||||
- apps/okr/backend/prisma/schema.prisma
|
||||
Tạo patch nhưng không tự apply.
|
||||
"""
|
||||
ranked = ORCHESTRATOR.context_candidates(project, objective)
|
||||
top_paths = [row[1] for row in ranked[:10]]
|
||||
self.assertTrue(any(path.startswith("apps/okr/backend/src/objectives/") for path in top_paths))
|
||||
self.assertTrue(any(path.startswith("apps/okr/backend/src/key-results/") for path in top_paths))
|
||||
self.assertIn("apps/okr/backend/prisma/schema.prisma", top_paths)
|
||||
self.assertNotIn("docs/technical_architecture.md", top_paths)
|
||||
self.assertNotIn("apps/okr/domain/input/okr-requirement.md", top_paths)
|
||||
|
||||
def test_explicit_only_scope_excludes_unrequested_context_files(self):
|
||||
goal = """Hoàn thiện backend.
|
||||
Chỉ được đọc và thay đổi:
|
||||
apps/okr/backend/src/key-results/key-results.service.ts
|
||||
apps/okr/backend/test/services.test.ts
|
||||
Không sửa bất kỳ file nào khác.
|
||||
"""
|
||||
candidates = [
|
||||
(2000, "apps/okr/backend/src/key-results/key-results.service.ts", "service"),
|
||||
(2000, "apps/okr/backend/test/services.test.ts", "tests"),
|
||||
(50, "apps/okr/backend/src/objectives/objectives.service.ts", "unrequested"),
|
||||
]
|
||||
project = {"domain": "OKR", "domain_root": "apps/okr", "roots": []}
|
||||
with tempfile.TemporaryDirectory() as directory, \
|
||||
patch.object(ORCHESTRATOR, "registered_project", return_value=project), \
|
||||
patch.object(ORCHESTRATOR, "context_candidates", return_value=candidates), \
|
||||
patch.object(ORCHESTRATOR, "scan", side_effect=lambda text, mode: (True, text)):
|
||||
bundle, manifest, _ = ORCHESTRATOR.build_context(os.path.join(directory, "goal.json"), "okr", goal, True)
|
||||
self.assertEqual([row["path"] for row in manifest["files"]], [
|
||||
"apps/okr/backend/src/key-results/key-results.service.ts",
|
||||
"apps/okr/backend/test/services.test.ts",
|
||||
])
|
||||
self.assertNotIn("unrequested", bundle)
|
||||
|
||||
def test_invalid_write_output_gets_one_bounded_repair_attempt(self):
|
||||
repaired = "```diff\ndiff --git a/apps/okr/frontend/a.ts b/apps/okr/frontend/a.ts\n--- a/apps/okr/frontend/a.ts\n+++ b/apps/okr/frontend/a.ts\n@@ -1 +1 @@\n-a\n+b\n```"
|
||||
with patch.dict(os.environ, {"CASAN_GOAL_PATCH_REPAIR_ATTEMPTS": "1"}, clear=False), \
|
||||
@@ -114,6 +253,53 @@ class GoalPatchWorkflowTests(unittest.TestCase):
|
||||
self.assertEqual(call.call_args.args[0], "openai:gpt-4o-mini")
|
||||
self.assertEqual(call.call_args.kwargs["max_output_tokens"], ORCHESTRATOR.patch_output_tokens())
|
||||
|
||||
def test_repair_skips_model_that_fails_generation_preflight(self):
|
||||
repaired = "diff --git a/apps/okr/frontend/a.ts b/apps/okr/frontend/a.ts\n--- a/apps/okr/frontend/a.ts\n+++ b/apps/okr/frontend/a.ts\n@@ -1 +1 @@\n-a\n+b\n"
|
||||
with (
|
||||
patch.dict(os.environ, {"CASAN_GOAL_PATCH_REPAIR_ATTEMPTS": "2", "CASAN_GOAL_PATCH_REPAIR_MODELS": "openai-compatible:auto/coding,ollama:ornith:9b"}, clear=False),
|
||||
patch.object(ORCHESTRATOR, "model_preflight", side_effect=[(False, "gateway_503"), (True, "ok")]),
|
||||
patch.object(ORCHESTRATOR, "call_model", return_value=(True, repaired, {}, "ok")) as call,
|
||||
patch.object(ORCHESTRATOR, "validate_write_output", return_value=repaired),
|
||||
):
|
||||
ok, _, usage, reason = ORCHESTRATOR.repair_write_output("ollama:ornith:9b", "original", "invalid")
|
||||
self.assertTrue(ok)
|
||||
self.assertEqual(reason, "ok")
|
||||
self.assertEqual(call.call_count, 1)
|
||||
self.assertEqual(call.call_args.args[0], "ollama:ornith:9b")
|
||||
self.assertIn("model_preflight_failed", usage["repair_attempts"][0]["reason"])
|
||||
|
||||
def test_logged_in_account_worker_is_first_patch_repair_candidate(self):
|
||||
repaired = "diff --git a/apps/okr/frontend/a.ts b/apps/okr/frontend/a.ts\n--- a/apps/okr/frontend/a.ts\n+++ b/apps/okr/frontend/a.ts\n@@ -1 +1 @@\n-a\n+b\n"
|
||||
with (
|
||||
patch.dict(os.environ, {"CASAN_GOAL_PATCH_REPAIR_ATTEMPTS": "2", "CASAN_GOAL_PATCH_REPAIR_MODELS": "openai-compatible:auto/coding,ollama:ornith:9b"}, clear=False),
|
||||
patch.object(ORCHESTRATOR, "model_preflight", return_value=(True, "ok")),
|
||||
patch.object(ORCHESTRATOR, "call_account_model", return_value=(True, repaired, {}, "ok")) as account_call,
|
||||
patch.object(ORCHESTRATOR, "call_model") as routed_call,
|
||||
patch.object(ORCHESTRATOR, "validate_write_output", return_value=repaired),
|
||||
):
|
||||
ok, _, usage, reason = ORCHESTRATOR.repair_write_output("account:codex", "original", "invalid")
|
||||
self.assertTrue(ok)
|
||||
self.assertEqual(reason, "ok")
|
||||
self.assertEqual(account_call.call_args.args[0], "codex")
|
||||
routed_call.assert_not_called()
|
||||
self.assertEqual(usage["repair_attempts"][0]["provider"], "codex-account")
|
||||
|
||||
def test_account_codex_gets_corrective_pass_before_other_routes(self):
|
||||
repaired = "diff --git a/apps/okr/frontend/a.ts b/apps/okr/frontend/a.ts\n--- a/apps/okr/frontend/a.ts\n+++ b/apps/okr/frontend/a.ts\n@@ -1 +1 @@\n-a\n+b\n"
|
||||
with (
|
||||
patch.dict(os.environ, {"CASAN_GOAL_PATCH_REPAIR_ATTEMPTS": "3", "CASAN_GOAL_PATCH_REPAIR_MODELS": "openai-compatible:auto/coding,ollama:ornith:9b"}, clear=False),
|
||||
patch.object(ORCHESTRATOR, "model_preflight", return_value=(True, "ok")),
|
||||
patch.object(ORCHESTRATOR, "call_account_model", side_effect=[(True, "corrupt", {}, "ok"), (True, repaired, {}, "ok")]) as account_call,
|
||||
patch.object(ORCHESTRATOR, "call_model") as routed_call,
|
||||
patch.object(ORCHESTRATOR, "validate_write_output", side_effect=[ValueError("goal_patch_check_failed:patch fragment without header"), repaired]),
|
||||
):
|
||||
ok, _, usage, reason = ORCHESTRATOR.repair_write_output("account:codex", "original", "invalid")
|
||||
self.assertTrue(ok)
|
||||
self.assertEqual(reason, "ok")
|
||||
self.assertEqual(account_call.call_count, 2)
|
||||
routed_call.assert_not_called()
|
||||
self.assertEqual([row["model"] for row in usage["repair_attempts"]], ["account:codex", "account:codex"])
|
||||
|
||||
def test_repair_tries_next_stronger_candidate_when_first_patch_is_corrupt(self):
|
||||
repaired = "diff --git a/apps/okr/frontend/a.ts b/apps/okr/frontend/a.ts\n--- a/apps/okr/frontend/a.ts\n+++ b/apps/okr/frontend/a.ts\n@@ -1 +1 @@\n-a\n+b\n"
|
||||
with (
|
||||
@@ -178,6 +364,16 @@ class GoalPatchWorkflowTests(unittest.TestCase):
|
||||
with self.assertRaisesRegex(ValueError, "goal_patch_outside_workspace"):
|
||||
ORCHESTRATOR.validate_and_store_patch(path, job, content)
|
||||
|
||||
def test_patch_outside_explicit_file_scope_is_denied(self):
|
||||
job = {
|
||||
"goal": "Chỉ được đọc và thay đổi:\napps/okr/backend/allowed.ts\nKhông sửa bất kỳ file nào khác.",
|
||||
"workspace": {"context_roots": ["apps/okr/backend"]},
|
||||
}
|
||||
content = "diff --git a/apps/okr/backend/other.ts b/apps/okr/backend/other.ts\n--- a/apps/okr/backend/other.ts\n+++ b/apps/okr/backend/other.ts\n@@ -1 +1 @@\n-a\n+b\n"
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
with self.assertRaisesRegex(ValueError, "goal_patch_outside_workspace"):
|
||||
ORCHESTRATOR.validate_and_store_patch(os.path.join(directory, "job.json"), job, content)
|
||||
|
||||
def test_patch_rename_source_outside_workspace_is_denied(self):
|
||||
job = {"workspace": {"context_roots": ["apps/okr/frontend"]}}
|
||||
content = "diff --git a/package.json b/apps/okr/frontend/package.json\nsimilarity index 100%\nrename from package.json\nrename to apps/okr/frontend/package.json\n"
|
||||
@@ -199,9 +395,20 @@ class GoalPatchWorkflowTests(unittest.TestCase):
|
||||
|
||||
def test_frontend_patch_runs_build_and_tests(self):
|
||||
commands = EXECUTOR.verification_commands(["apps/okr/frontend/src/pages/KeyResultDetail.tsx"])
|
||||
self.assertFalse(any(command[:2] == ["git", "diff"] for command in commands))
|
||||
self.assertIn(["npm", "run", "build", "-w", "@ainative-okr/frontend"], commands)
|
||||
self.assertIn(["npm", "test", "-w", "@ainative-okr/frontend"], commands)
|
||||
|
||||
def test_executor_can_retry_only_a_verified_rollback_failure(self):
|
||||
artifact = {"path": "job.patch"}
|
||||
self.assertTrue(EXECUTOR.ready_for_apply({"status": "requires_approval", "patch_artifact": artifact}))
|
||||
self.assertTrue(EXECUTOR.ready_for_apply({
|
||||
"status": "failed",
|
||||
"error": "GOAL_APPLY_VERIFICATION_FAILED_ROLLED_BACK",
|
||||
"patch_artifact": artifact,
|
||||
}))
|
||||
self.assertFalse(EXECUTOR.ready_for_apply({"status": "failed", "error": "OTHER", "patch_artifact": artifact}))
|
||||
|
||||
def test_service_desk_patch_uses_service_desk_manifest_commands(self):
|
||||
commands = EXECUTOR.verification_commands(["apps/service-desk/src/ticket.js"])
|
||||
self.assertIn(["node", "--check", "apps/service-desk/src/ticket.js"], commands)
|
||||
|
||||
Reference in New Issue
Block a user