L'extraction de champs vivait dans un heredoc à l'intérieur d'email-inspect.sh : impossible à exécuter isolément, donc jamais mesurée, donc fausse sans que personne puisse le voir. Sur la facture Darnis F1048 elle renvoyait le numéro de TVA d'Arcodange comme référence de facture, et aucune date. - extract_fields.py : l'extraction sort du shell et devient un module. - test_extract.py : régression contre les 16 factures hand-vérifiées de fleet/golden/invoice-extract/. Score par champ, et une valeur FAUSSE pèse plus qu'une valeur absente — un humain recopie ce qui s'affiche. Valeurs fausses : 4 → 0. Exactitude ref 62,5 → 75 %, date 62,5 → 75 %, HT 68,8 → 75 %, TTC 81,2 → 93,8 %. Cinq bugs réels, dont trois invisibles sans test : - « Nº » sur les factures françaises est U+00BA (ordinal masculin), pas le signe degré. La classe [°o] le rate, le motif principal échoue, et le repli attrape le premier jeton ref-shaped du document — très souvent un numéro de TVA. - Le filtre anti-TVA rejetait « FR73261832 », qui est la vraie référence OVH : un numéro FR fait exactement 11 caractères après le préfixe. - « Montant total (HT) » était lu comme un TTC. - Une référence coupée par la colonne (« 06-01-26- » / « payment-366753 ») était renvoyée amputée : le recollage doit précéder le scan, sinon la queue seule est trouvée en premier. - Un `\b` après `€` ne peut jamais matcher en fin de ligne (€ n'est pas un caractère de mot) — la TVA n'était jamais extraite. adc-008 : une facture fournisseur s'enregistre à SA date, même future, tant que l'exercice (année civile) ne bascule pas. Le document fait foi ; altérer sa date ferait diverger l'écriture de sa pièce justificative (CGI art. 289 VII). Registre validé : 8 règles, 8 ADC, 0 erreur. scopes.ts : 1232 (factures fournisseur) ajouté à prod-write — oubli initial, révélé par un 403 en production sur F1048. Le pipeline s'est arrêté sans écrire. Appliqué en production via le pipeline gated : FAF2026014 (Darnis F1048), 218,50 HT + 43,70 TVA = 262,20 TTC, validée, non réglée. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01VRShc4QhLLU73FLHx9vskh
201 lines
8.4 KiB
TypeScript
201 lines
8.4 KiB
TypeScript
/*
|
||
Provision an AI agent user with a declared scope — one user per (environment × scope).
|
||
|
||
Replaces the single hardcoded grant in provisionSandbox.ts. Scopes live in
|
||
scopes.ts so a reader can audit what an agent may do without opening Dolibarr,
|
||
and so a sandbox refresh can restore the exact same rights instead of losing
|
||
hand-granted ones.
|
||
|
||
Usage:
|
||
# audit what a user actually holds vs what its scope declares (no writes)
|
||
deno run -A test/provisionAiUser.ts --scope read --env sandbox --audit
|
||
|
||
# create (or align) the user and emit its API key
|
||
DOLIBARR_ADDRESS=https://erp-sandbox.arcodange.lab \
|
||
deno run -A test/provisionAiUser.ts --scope sandbox-write --env sandbox
|
||
|
||
# production requires the explicit opt-in from guard.ts, twice over
|
||
DOLIBARR_ADDRESS=https://erp.arcodange.lab \
|
||
ARCO_ALLOW_PRODUCTION=erp.arcodange.lab \
|
||
ARCO_PROD_CONFIRM=I-UNDERSTAND-THIS-WRITES-PROD \
|
||
deno run -A test/provisionAiUser.ts --scope prod-write --env production
|
||
|
||
The API key is written to a gitignored file, never printed.
|
||
*/
|
||
import "load_dotenv";
|
||
import { chromium } from "playwright";
|
||
import login from "./scripts/login.ts";
|
||
import userSetup from "./scripts/admin/userSetup.ts";
|
||
import { assertSandbox } from "./scripts/guard.ts";
|
||
import { loginFor, PERMISSION_LABELS, resolveScope } from "./scopes.ts";
|
||
|
||
const argv = Deno.args;
|
||
const pick = (flag: string, dflt = "") =>
|
||
argv.includes(flag) ? argv[argv.indexOf(flag) + 1] : dflt;
|
||
|
||
const scopeName = pick("--scope");
|
||
const env = pick("--env", "sandbox") as "sandbox" | "production";
|
||
const auditOnly = argv.includes("--audit");
|
||
const userLogin = pick("--login") || loginFor(scopeName, env);
|
||
|
||
if (!scopeName) {
|
||
console.error("--scope is required (see test/scopes.ts for the declared scopes)");
|
||
Deno.exit(2);
|
||
}
|
||
const scope = resolveScope(scopeName, env);
|
||
|
||
// guard.ts refuses anything that is not the sandbox unless the production
|
||
// opt-in is set explicitly — provisioning a prod writer is a deliberate act.
|
||
const dolibarrAddress = assertSandbox();
|
||
if (env === "production" && dolibarrAddress.includes("erp-sandbox")) {
|
||
console.error("--env production but the address is the sandbox — refusing.");
|
||
Deno.exit(2);
|
||
}
|
||
|
||
console.log(`scope : ${scopeName} — ${scope.purpose}`);
|
||
console.log(`target : ${dolibarrAddress} (${env})`);
|
||
console.log(`user : ${userLogin}`);
|
||
console.log(`rights : ${scope.rights.length} declared`);
|
||
for (const r of scope.rights) {
|
||
console.log(` ${String(r).padStart(4)} ${PERMISSION_LABELS[r] ?? "(libellé inconnu — vérifier)"}`);
|
||
}
|
||
|
||
const browser = await chromium.launch({ headless: true });
|
||
const context = await browser.newContext({ locale: "fr-FR" });
|
||
const page = await context.newPage();
|
||
const globalCtx = {
|
||
page,
|
||
dolibarrAddress,
|
||
imgFolderPath: "", // required by UserCtx (forms.fillForm signature); no file input here
|
||
adminCredentials: {
|
||
username: Deno.env.get("DOLI_ADMIN_LOGIN") || "undefined",
|
||
password: Deno.env.get("DOLI_ADMIN_PASSWORD") || "undefined",
|
||
},
|
||
};
|
||
|
||
/**
|
||
* Find a user id by login, from the user list. Implemented here rather than
|
||
* imported: the trunk's userSetup.ts has a findUserId, but it is uncommitted
|
||
* work-in-progress — a provisioning script must not depend on someone's WIP.
|
||
*/
|
||
async function findUserId(userLogin: string): Promise<number | undefined> {
|
||
// Two traps, both observed live on Dolibarr 22:
|
||
// - the list TRUNCATES long logins with an ellipsis, so
|
||
// `ai_agent_sandbox_sandbox_write` renders as `ai_agent_sandbox_sandbox…`
|
||
// and an exact-text match silently fails;
|
||
// - a miss makes the caller CREATE a duplicate privileged user, which is the
|
||
// worst failure mode a provisioning script has.
|
||
// So: filter server-side with search_login, then accept an exact match or a
|
||
// truncated prefix of the login we asked for.
|
||
await page.goto(
|
||
`${dolibarrAddress}/user/list.php?search_login=${encodeURIComponent(userLogin)}`,
|
||
);
|
||
const href = await page.locator('a[href*="/user/card.php?id="]').evaluateAll(
|
||
(as: { textContent: string | null; getAttribute(n: string): string | null }[], want: string) => {
|
||
for (const a of as) {
|
||
const text = (a.textContent ?? "").trim();
|
||
const bare = text.replace(/[…\.]+$/, "");
|
||
if (text === want || (bare.length >= 8 && want.startsWith(bare))) {
|
||
return a.getAttribute("href");
|
||
}
|
||
}
|
||
return null;
|
||
},
|
||
userLogin,
|
||
);
|
||
const m = href?.match(/id=(\d+)/);
|
||
return m ? Number(m[1]) : undefined;
|
||
}
|
||
|
||
/**
|
||
* What the user actually holds right now, read off the perms page — id AND the
|
||
* live label. Reading the label matters: a catalogue in code goes stale across
|
||
* Dolibarr versions, and an audit that cannot name what it found is not
|
||
* actionable. The live page is the authority.
|
||
*/
|
||
async function readGrantedRights(userId: number): Promise<Map<number, string>> {
|
||
await page.goto(`${dolibarrAddress}/user/perms.php?id=${userId}`);
|
||
const rows = await page.locator('tr:has(a[href*="action=delrights"])').evaluateAll(
|
||
(trs: { textContent: string | null; querySelector(s: string): { getAttribute(n: string): string | null } | null }[]) =>
|
||
trs.map((tr) => ({
|
||
href: tr.querySelector('a[href*="action=delrights"]')?.getAttribute("href") ?? "",
|
||
text: (tr.textContent ?? "").replace(/\s+/g, " ").trim(),
|
||
})),
|
||
);
|
||
const held = new Map<number, string>();
|
||
for (const r of rows) {
|
||
const m = r.href.match(/rights=(\d+)&/);
|
||
if (!m) continue;
|
||
// strip the trailing action words Dolibarr renders in the same row
|
||
const label = r.text.replace(/(Tout \/ Aucun|Retirer|Ajouter)\s*$/i, "").trim();
|
||
held.set(Number(m[1]), label.slice(0, 80) || "(libellé illisible)");
|
||
}
|
||
return held;
|
||
}
|
||
|
||
try {
|
||
await login.doAdminLogin(globalCtx);
|
||
console.log(`\nconnecté comme ${await login.whoAmI({ page })}`);
|
||
|
||
let userId = await findUserId(userLogin);
|
||
|
||
if (auditOnly) {
|
||
if (!userId) {
|
||
console.log(`\nAUDIT — l'utilisateur '${userLogin}' n'existe pas sur ${env}.`);
|
||
Deno.exit(0);
|
||
}
|
||
const held = await readGrantedRights(userId);
|
||
const declared = new Set(scope.rights);
|
||
const missing = [...declared].filter((r) => !held.has(r));
|
||
const extra = [...held.keys()].filter((r) => !declared.has(r));
|
||
console.log(`\nAUDIT — utilisateur ${userLogin} (id=${userId})`);
|
||
console.log(` détenus : ${held.size} · déclarés : ${declared.size}`);
|
||
if (missing.length) {
|
||
console.log(` MANQUANTS (le scope les déclare, l'utilisateur ne les a pas) :`);
|
||
for (const r of missing) console.log(` ${r} ${PERMISSION_LABELS[r] ?? "?"}`);
|
||
}
|
||
if (extra.length) {
|
||
console.log(` EN TROP (détenus hors scope — c'est ce qui rend la doctrine fausse) :`);
|
||
for (const r of extra) console.log(` ${String(r).padStart(6)} ${held.get(r)}`);
|
||
}
|
||
if (!missing.length && !extra.length) console.log(" conforme au scope déclaré.");
|
||
Deno.exit(missing.length || extra.length ? 1 : 0);
|
||
}
|
||
|
||
if (!userId) {
|
||
const pwd = crypto.randomUUID();
|
||
userId = await userSetup.createUser(globalCtx, {
|
||
login: userLogin,
|
||
password: pwd,
|
||
lastname: `AI Agent (${env}/${scopeName})`,
|
||
admin: false,
|
||
});
|
||
console.log(`utilisateur créé : ${userLogin} (id=${userId})`);
|
||
} else {
|
||
console.log(`utilisateur existant : ${userLogin} (id=${userId})`);
|
||
}
|
||
|
||
await userSetup.assignRights(globalCtx, userId, [...scope.rights]);
|
||
const held = await readGrantedRights(userId);
|
||
const missing = scope.rights.filter((r) => !held.has(r));
|
||
const extra = [...held.keys()].filter((r) => !scope.rights.includes(r));
|
||
console.log(`\ndroits après application : ${held.size} détenus`);
|
||
if (missing.length) console.log(` ⚠️ non appliqués : ${missing.join(", ")}`);
|
||
if (extra.length) {
|
||
// Not auto-revoked: removing a right an operator granted on purpose is not
|
||
// this script's call. Surface it and let a human decide.
|
||
console.log(` ⚠️ hors scope, à examiner :`);
|
||
for (const r of extra) console.log(` ${String(r).padStart(6)} ${held.get(r)}`);
|
||
console.log(` retrait : deno run -A test/grantAgentRight.ts --user-id ${userId} --match "<libellé>" --revoke`);
|
||
}
|
||
|
||
const apiKey = await userSetup.generateApiKey(globalCtx, userId);
|
||
const keyFile = `.${userLogin}.key`;
|
||
await Deno.writeTextFile(keyFile, apiKey + "\n");
|
||
await Deno.chmod(keyFile, 0o600);
|
||
console.log(`\nclé API écrite dans test/${keyFile} (mode 600, gitignoré) — jamais affichée.`);
|
||
} finally {
|
||
await context.close();
|
||
await browser.close();
|
||
}
|