Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .github/workflows/raptor-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,18 @@ jobs:
- name: Typecheck
run: vpr typecheck

# UN MODULE QUE SEUL SON PROPRE TEST CONSOMME.
#
# `orchestrationRecovery.ts` porte une machine de reprise complète,
# couverte par quarante assertions, verte à chaque CI — et appelée par
# RIEN depuis que #2978 a supprimé son unique consommateur. Un lecteur
# ouvre le fichier, voit la logique ET les tests verts, et conclut que
# le comportement est livré. C'est pire que du code mort franc.
#
# Knip ne le voit pas : pour lui, l'import du test EST un usage.
- name: Aucun module orphelin de plus
run: node scripts/garde-orphelins.mjs

# LE BUDGET PAR TEST, RELEVÉ — SANS TOUCHER À L'ISOLATION.
#
# `apps/web/vite.config.ts` fixe 15 s par test : confortable sur un Mac,
Expand Down
41 changes: 30 additions & 11 deletions apps/server/src/modeActif.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import * as Effect from "effect/Effect";
import * as Schema from "effect/Schema";
import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http";

import { compterLaPortee, type PoseDeMode, type SautDeMode } from "@t3tools/shared/porteeDuMode";

import { appliquerModeAuHome, lireModeDuHome } from "./provider/Drivers/ClaudeModePermissions.ts";
import { resolveClaudeHomePath } from "./provider/Drivers/ClaudeHome.ts";
import { ServerSettingsService } from "./serverSettings.ts";
Expand Down Expand Up @@ -153,7 +155,10 @@ export const modePoserRouteLayer = HttpRouter.add(
(demande.instanceId === undefined || cle === demande.instanceId),
);

let appliques = 0;
/** UNE issue par compte visé — c'est le compte qui la traduit, pas nous. */
const issues: Array<PoseDeMode | SautDeMode> = [];
/** Le détail nommé des échecs, pour que le journal désigne le coupable. */
const echecs: Array<{ instanceId: string; cause: PoseDeMode }> = [];
for (const [cle, config] of vises) {
const brut = config.config;
const homePath =
Expand All @@ -164,27 +169,41 @@ export const modePoserRouteLayer = HttpRouter.add(
: "";
// Sans dossier propre, l'instance partage le `~/.claude` de l'humain :
// y écrire un refus toucherait sa CLI personnelle, hors de l'app.
if (homePath.trim().length === 0) continue;
if (homePath.trim().length === 0) {
issues.push("saute");
continue;
}
const resolu = yield* resolveClaudeHomePath({ homePath });
yield* appliquerModeAuHome(resolu, mode);
appliques += 1;
// On enregistre l'ISSUE, pas le fait d'avoir appelé. Confondre les deux
// faisait annoncer « appliqué, tous restreints » quand rien n'était posé.
const resultat = yield* appliquerModeAuHome(resolu, mode);
issues.push(resultat);
if (resultat !== "applique") {
echecs.push({ instanceId: cle, cause: resultat });
yield* Effect.logWarning("mode NON posé", {
instanceId: cle as ProviderInstanceId,
mode: mode?.slug ?? "libre",
cause: resultat,
});
continue;
}
yield* Effect.logInfo("mode posé", {
instanceId: cle as ProviderInstanceId,
mode: mode?.slug ?? "libre",
});
}
const portee = compterLaPortee(issues);

modeCourant = mode;
// La PORTÉE REELLE, pas seulement le nombre d'appliques. Un compte sans
// dossier propre est SAUTE — et sur cette machine c'est le compte
// principal, celui qui porte douze des quatorze fils actifs. Dire
// « 3 comptes » laissait croire a « partout » ; il faut dire sur combien.
// La PORTÉE REELLE, comptée une seule fois, par le module qui sait aussi
// la METTRE EN PHRASE. Le bug est né de sa dispersion : le serveur
// comptait ici, le client reformulait là-bas, et les deux moitiés ne se
// rencontraient qu'en production.
return HttpServerResponse.jsonUnsafe({
pose: true,
mode: mode === null ? null : mode.slug,
comptes: appliques,
comptesTotal: vises.length,
comptesSautes: vises.length - appliques,
...portee,
echecs,
});
}).pipe(
Effect.catchCause((cause) =>
Expand Down
185 changes: 184 additions & 1 deletion apps/server/src/provider/Drivers/ClaudeModePermissions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,15 @@ import * as NodeServices from "@effect/platform-node/NodeServices";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Path from "effect/Path";
import * as Schema from "effect/Schema";
import { assert, describe, it } from "@effect/vitest";

import { appliquerModeAuHome } from "./ClaudeModePermissions.ts";
import { appliquerModeAuHome, lireModeDuHome } from "./ClaudeModePermissions.ts";

/** Le même codec que le module testé : la lecture ne doit pas être un autre outil. */
const SETTINGS = Schema.Record(Schema.String, Schema.Unknown);
const lireSettings = Schema.decodeUnknownSync(Schema.fromJsonString(SETTINGS));
const ecrireSettings = Schema.encodeSync(Schema.fromJsonString(SETTINGS));

const modeParSlug = (slug: string): ModeTravail => {
const mode = MODES_LIVRES.find((candidat) => candidat.slug === slug);
Expand Down Expand Up @@ -105,3 +111,180 @@ describe("permissions d'un mode dans le dossier de l'instance", () => {
),
);
});

/**
* LA POSE DIT CE QU'ELLE A FAIT — et surtout ce qu'elle N'A PAS fait.
*
* Ces trois cas séparent « ne pas échouer » de « avoir réussi ». Avant le
* 03/08 la fonction était typée `Effect<void, never>` : les deux chemins de
* panne ci-dessous rendaient exactement la même chose qu'un succès, et
* l'appelant comptait chaque appel comme une application. La bannière ambre
* pouvait donc affirmer « tes agents ne peuvent NI écrire NI lancer de
* commande » sur un compte où rien n'avait été écrit.
*/
describe("la pose d'un mode rend son résultat réel", () => {
it.effect("rend « applique » quand le fichier est bien écrit", () =>
dansUnDossierNeuf((home) =>
Effect.gen(function* () {
const resultat = yield* appliquerModeAuHome(home, modeParSlug("revue"));
assert.strictEqual(resultat, "applique");
}),
),
);

it.effect("rend « settings-illisible » sur un settings.json abîmé, sans l'écraser", () =>
dansUnDossierNeuf((home) =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const fichier = path.join(home, "settings.json");
const abime = "{ ceci n'est pas du JSON";
yield* fs.writeFileString(fichier, abime);

const resultat = yield* appliquerModeAuHome(home, modeParSlug("revue"));

assert.strictEqual(resultat, "settings-illisible");
// Le fichier de l'utilisateur est INTACT : renoncer est le bon
// remède, écraser ses réglages pour poser un périmètre serait pire
// que le mal.
assert.strictEqual(yield* lire(fichier), abime);
}).pipe(Effect.orDie),
),
);

it.effect("rend « ecriture-refusee » quand le disque refuse d'écrire", () =>
dansUnDossierNeuf((home) =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
// Un dossier en lecture seule : c'est le cas réel d'un `~/.claude-*`
// monté par un autre utilisateur, ou d'un volume protégé.
yield* fs.chmod(home, 0o500);
const resultat = yield* appliquerModeAuHome(home, modeParSlug("revue")).pipe(
Effect.ensuring(fs.chmod(home, 0o700).pipe(Effect.orElseSucceed(() => undefined))),
);
assert.strictEqual(resultat, "ecriture-refusee");
assert.strictEqual(yield* lire(path.join(home, "settings.json")), "");
}).pipe(Effect.orDie),
),
);
});

/**
* LE FICHIER DE L'UTILISATEUR SURVIT — ce qu'on n'a pas posé, on n'y touche pas.
*
* Le mode Atelier n'impose aucune restriction, donc ne produit aucune règle,
* donc tombait dans la branche « rien à restreindre » et SUPPRIMAIT le bloc
* `permissions` entier : refus personnels, `defaultMode`,
* `additionalDirectories`. Le mode qui promet de ne rien restreindre était le
* plus destructeur du catalogue — et la perte était définitive, sur le disque.
*/
describe("poser un mode n'efface jamais les réglages de l'utilisateur", () => {
const reglagesPersonnels = {
permissions: {
deny: ["Bash(rm:*)", "Read(**/.env)"],
allow: ["Bash(git status:*)"],
ask: ["Bash(git push:*)"],
defaultMode: "acceptEdits",
additionalDirectories: ["/Users/enzo/Documents/Palenza"],
},
statusLine: { type: "command", command: "echo bonjour" },
};

const avecReglages = <A>(
corps: (
home: string,
fichier: string,
) => Effect.Effect<A, never, FileSystem.FileSystem | Path.Path>,
) =>
dansUnDossierNeuf((home) =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const fichier = path.join(home, "settings.json");
yield* fs.writeFileString(fichier, ecrireSettings(reglagesPersonnels));
return yield* corps(home, fichier);
}).pipe(Effect.orDie),
);

const permissionsDe = (contenu: string) =>
(lireSettings(contenu)["permissions"] as Record<string, unknown> | undefined) ?? {};

it.effect("le mode Atelier NE SUPPRIME PLUS le bloc permissions", () =>
avecReglages((home, fichier) =>
Effect.gen(function* () {
const resultat = yield* appliquerModeAuHome(home, modeParSlug("atelier"));
assert.strictEqual(resultat, "applique");

const perms = permissionsDe(yield* lire(fichier));
assert.deepStrictEqual(perms["deny"], ["Bash(rm:*)", "Read(**/.env)"]);
assert.deepStrictEqual(perms["allow"], ["Bash(git status:*)"]);
assert.deepStrictEqual(perms["ask"], ["Bash(git push:*)"]);
assert.strictEqual(perms["defaultMode"], "acceptEdits");
assert.deepStrictEqual(perms["additionalDirectories"], ["/Users/enzo/Documents/Palenza"]);
}),
),
);

it.effect("un mode restrictif AJOUTE ses refus sans jeter ceux de l'utilisateur", () =>
avecReglages((home, fichier) =>
Effect.gen(function* () {
yield* appliquerModeAuHome(home, modeParSlug("revue"));

const perms = permissionsDe(yield* lire(fichier));
const deny = perms["deny"] as ReadonlyArray<string>;
assert.include(deny, "Bash(rm:*)", "le refus personnel a été jeté");
assert.include(deny, "Read(**/.env)", "le refus personnel a été jeté");
assert.include(deny, "Edit", "le mode n'a pas posé son refus");
assert.strictEqual(perms["defaultMode"], "acceptEdits");
}),
),
);

it.effect("lever le mode rend le fichier EXACTEMENT à son état d'origine", () =>
avecReglages((home, fichier) =>
Effect.gen(function* () {
const origine = yield* lire(fichier);
yield* appliquerModeAuHome(home, modeParSlug("revue"));
yield* appliquerModeAuHome(home, modeParSlug("documentation"));
yield* appliquerModeAuHome(home, null);

// Aller-retour complet : le contenu doit se superposer à l'original.
// Une comparaison de VALEURS, pas de texte : le formatage appartient
// à l'encodeur, la matière appartient à l'utilisateur.
assert.deepStrictEqual(lireSettings(yield* lire(fichier)), lireSettings(origine));
}),
),
);

it.effect("sans bloc permissions, poser puis lever ne LAISSE aucune trace", () =>
dansUnDossierNeuf((home) =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const fichier = path.join(home, "settings.json");
yield* fs.writeFileString(fichier, ecrireSettings({ statusLine: { type: "none" } }));

yield* appliquerModeAuHome(home, modeParSlug("revue"));
yield* appliquerModeAuHome(home, null);

const relu = lireSettings(yield* lire(fichier));
assert.notProperty(relu, "permissions", "un bloc vide est resté derrière nous");
assert.property(relu, "statusLine");
}).pipe(Effect.orDie),
),
);

it.effect("le mode reste RECONNAISSABLE malgré les entrées de l'utilisateur", () =>
avecReglages((home) =>
Effect.gen(function* () {
// La reconnaissance comparait les listes ENTIÈRES. Depuis qu'on
// fusionne, elles contiennent aussi celles de l'utilisateur : sans
// filtrage, l'écran dirait « aucun mode » alors que le mode est posé.
yield* appliquerModeAuHome(home, modeParSlug("revue"));
const relu = yield* lireModeDuHome(home, MODES_LIVRES);
assert.strictEqual(relu?.slug, "revue");
}),
),
);
});
Loading