feat(wave): #119/#122/#131/#132 verts + sprint plugins ESM/persistance #135/#136/#139

État d'intégration confiné à la branche batch. Les tickets #119 (skills →
capacités agent découvrables), #122 (override permissions par défaut), #131
(effort par agent/presets) et #132 (outil MCP d'édition du contexte projet)
sont verts en périmètre. Le sprint plugins multi-fichiers ESM / persistance
plugin-owned (#135/#136/#139) est co-implémenté dans les MÊMES fichiers de
câblage (frontend/src/ports/index.ts, backend/src/lib.rs, domain/ports.rs,
backend/dto.rs), inséparable sans staging interactif (indisponible ici).

Commit unique volontaire : préserve l'état vert QA sans découpe hunk risquée.
NON mergé vers develop tant que #137 (QA e2e plugins) n'est pas vert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-03 11:06:23 +02:00
parent 22c6bd803d
commit 171c6c923c
59 changed files with 3654 additions and 291 deletions

View File

@ -11,6 +11,7 @@ import type {
AgentProfile,
DiagnosticWarning,
DomainEvent,
EffortSelection,
EmbedderEngines,
EmbedderProfile,
EmbeddedServerStatus,
@ -37,7 +38,6 @@ import type {
OpenCodeProviderCatalogEntry,
ProfileModelCatalog,
ProfileModelCatalogEntry,
EffectivePermissions,
PairedDevice,
PairingCode,
PermissionSet,
@ -72,6 +72,7 @@ import type {
ProjectSystemPermissions,
ProfileAvailability,
ResumableAgent,
ResolvedAgentPermissions,
ResolvedAgentSystemPermissions,
ServerExposurePreview,
ServerExposureSettings,
@ -129,6 +130,9 @@ import type {
PluginEventSubscribeInput,
PluginEventUnsubscribeInput,
PluginGateway,
PluginStorageGateway,
PluginStorageGetInput,
PluginStorageSetInput,
PluginProjectStructureQuery,
PluginRunCommandInput,
PluginTaskGateway,
@ -603,6 +607,28 @@ export class MockAgentGateway implements AgentGateway {
};
}
async updateAgentEffort(
projectId: string,
agentId: string,
effort: EffortSelection | null,
): Promise<Agent> {
const list = this.getAgents(projectId);
const idx = list.findIndex((a) => a.id === agentId);
if (idx === -1) {
const err: GatewayError = {
code: "NOT_FOUND",
message: `agent ${agentId} not found in project ${projectId}`,
};
throw err;
}
const next =
effort === null
? (({ effort: _dropped, ...rest }) => rest)(list[idx])
: { ...list[idx], effort };
list[idx] = next;
return structuredClone(next);
}
// ── Internal helpers for MockTemplateGateway (same-package use only) ──
/**
@ -2006,6 +2032,8 @@ export class MockSkillGateway implements SkillGateway {
const skill: Skill = {
id: `mock-skill-${this.seq}`,
name: input.name,
description: input.description ?? null,
kind: input.kind ?? "workflow",
contentMd: input.content,
scope: input.scope,
};
@ -2431,14 +2459,18 @@ export class MockPermissionGateway implements PermissionGateway {
async resolveAgentPermissions(
projectId: string,
agentId: string,
): Promise<EffectivePermissions | null> {
): Promise<ResolvedAgentPermissions> {
const doc = this.doc(projectId);
const project = doc.projectDefaults;
const agent = doc.agents?.find((entry) => entry.agentId === agentId)?.permissions;
if (!project && !agent) return null;
const shadowed = permissionShadowReport(project, agent);
if (!project && !agent) return { effective: null, shadowed };
return {
rules: [...(project?.rules ?? []), ...(agent?.rules ?? [])],
fallback: mostRestrictive(project?.fallback, agent?.fallback),
effective: {
rules: [...(project?.rules ?? []), ...(agent?.rules ?? [])],
fallback: mostRestrictive(project?.fallback, agent?.fallback),
},
shadowed,
};
}
@ -3466,6 +3498,47 @@ function mostRestrictive(
return rank[agent] >= rank[fallback] ? agent : fallback;
}
function permissionShadowReport(project?: PermissionSet, agent?: PermissionSet) {
const empty = {
read: false,
write: false,
delete: false,
executeBash: false,
fallback: false,
};
if (!agent) return empty;
const shadowed = (capability: "read" | "write" | "delete" | "executeBash") =>
blanketEffect(project, capability, "deny") === "deny" &&
blanketEffect(agent, capability, "allow") === "allow";
return {
read: shadowed("read"),
write: shadowed("write"),
delete: shadowed("delete"),
executeBash: shadowed("executeBash"),
fallback: agent.fallback !== mostRestrictive(project?.fallback, agent.fallback),
};
}
function blanketEffect(
set: PermissionSet | undefined,
capability: "read" | "write" | "delete" | "executeBash",
wins: "allow" | "deny",
) {
let found: "allow" | "deny" | undefined;
for (const rule of set?.rules ?? []) {
if (rule.capability !== capability || !isBlanketRule(rule)) continue;
if (rule.effect === wins) return rule.effect;
found = rule.effect;
}
return found;
}
function isBlanketRule(rule: PermissionSet["rules"][number]) {
if (rule.capability === "executeBash") return (rule.commands ?? []).length === 0;
const paths = rule.paths ?? [];
return paths.length === 1 && paths[0] === "**";
}
/**
* In-memory plugin store (ticket #43, F1). Mirrors the carnet contract closely
* enough to develop/test F1-F4 without the backend (B1-B4, landing in
@ -4016,6 +4089,38 @@ export class MockPluginConfigGateway implements PluginConfigGateway {
}
}
/**
* In-memory plugin-owned storage gateway for plugin runtime tests/dev.
*/
export class MockPluginStorageGateway implements PluginStorageGateway {
private readonly values = new Map<string, JsonValue>();
private storageKey(input: PluginStorageGetInput): string {
if (!input.pluginId.trim()) {
const err: GatewayError = { code: "INVALID", message: "pluginId must not be empty" };
throw err;
}
if (!input.key.trim()) {
const err: GatewayError = { code: "INVALID", message: "key must not be empty" };
throw err;
}
return `${input.pluginId}:${input.key}`;
}
async get(input: PluginStorageGetInput): Promise<JsonValue | null> {
const value = this.values.get(this.storageKey(input));
return value === undefined ? null : cloneJson(value);
}
async set(input: PluginStorageSetInput): Promise<void> {
this.values.set(this.storageKey(input), cloneJson(input.value));
}
async delete(input: PluginStorageGetInput): Promise<boolean> {
return this.values.delete(this.storageKey(input));
}
}
/** Builds the full set of mock gateways. */
export function createMockGateways(): Gateways {
const agentGateway = new MockAgentGateway();
@ -4050,6 +4155,7 @@ export function createMockGateways(): Gateways {
pluginToolchain: new MockPluginToolchainGateway(),
pluginEvents: new MockPluginEventGateway(),
pluginConfig: new MockPluginConfigGateway(),
pluginStorage: new MockPluginStorageGateway(),
};
}