create-byan-agent 2.59.3 → 2.60.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -14,12 +14,23 @@ const { execSync } = require('child_process');
14
14
  const fs = require('fs');
15
15
  const os = require('os');
16
16
  const path = require('path');
17
+ const { commandExists: sharedCommandExists } = require('./resolve-binary');
17
18
 
18
19
  /**
19
20
  * commandExists(cmd) -> boolean. Is an executable resolvable on PATH?
20
- * Uses POSIX `command -v` (and `where` on Windows). The runner is injectable.
21
+ *
22
+ * La sonde par defaut ne passe plus par un shell. Elle delegue au resolveur
23
+ * partage (install/lib/resolve-binary.js), qui parcourt le PATH en JavaScript
24
+ * et connait PATHEXT sous Windows. Motif mesure le 2026-08-11 : trois sondes
25
+ * concurrentes coexistaient dans install/ et se contredisaient, et celle-ci
26
+ * lancait un shell pour repondre a une question que le systeme de fichiers
27
+ * suffit a trancher.
28
+ *
29
+ * Le lanceur reste injectable pour les tests qui simulent deja un shell : quand
30
+ * `run` est fourni explicitement, on garde l'ancien chemin.
21
31
  */
22
- function commandExists(cmd, { run = execSync, platform = process.platform } = {}) {
32
+ function commandExists(cmd, { run = null, platform = process.platform, env = process.env } = {}) {
33
+ if (!run) return sharedCommandExists(cmd, { platform, env });
23
34
  const probe = platform === 'win32' ? `where ${cmd}` : `command -v ${cmd}`;
24
35
  try {
25
36
  run(probe, { stdio: 'pipe' });
@@ -0,0 +1,508 @@
1
+ 'use strict';
2
+
3
+ // ownership.js -- reparation du proprietaire des fichiers poses par l installateur.
4
+ //
5
+ // Le probleme mesure le 2026-08-11 sur un serveur reel : le dossier parent du
6
+ // projet est en yan:docker, mais _byan/ cree sous elevation ressort en root:root.
7
+ // L utilisateur ne peut plus modifier ce que l installateur a ecrit.
8
+ //
9
+ // Quatre faits mesures sur cette machine (Node v26.2.0, Linux) portent le code :
10
+ // 1. sans le bit setgid sur le dossier, un fichier cree dedans prend le groupe
11
+ // PRIMAIRE de son createur, pas celui du dossier ;
12
+ // 2. avec le setgid pose, le fichier herite du groupe mais ressort en 644 car
13
+ // le umask 022 retire l ecriture au groupe -> il faut umask(0o002) ;
14
+ // 3. le setgid se propage aux sous-dossiers crees DANS un dossier qui le
15
+ // porte. La propagation ne vaut donc que pour ce qui est cree APRES la
16
+ // pose : sur une arborescence deja ecrite, il faut le donner a chaque
17
+ // dossier. Mesure du 2026-08-12 sur un serveur reel : avec le bit sur la
18
+ // seule racine, 1 dossier sur 1187 le portait et un sous-dossier cree
19
+ // ensuite ressortait au groupe primaire de son createur ;
20
+ // 4. fs.constants n expose aucune constante S_ISGID, d ou le litteral 0o2000.
21
+ //
22
+ // Toutes les dependances a effet de bord (fs, platform, exec, umask) sont
23
+ // injectees : c est ce qui rend le test possible sans privilege ni machine hote.
24
+
25
+ const path = require('path');
26
+
27
+ // Bit setgid. WHY un litteral : fs.constants n expose aucune constante
28
+ // S_ISGID / S_ISUID / S_ISVTX (verifie sur Node v26.2.0), donc rien a importer.
29
+ const SETGID = 0o2000;
30
+
31
+ // Umask a poser pour que les fichiers crees ensuite gardent l ecriture au
32
+ // groupe (fait mesure numero 2).
33
+ const GROUP_WRITABLE_UMASK = 0o002;
34
+
35
+ // Codes d erreur qui signalent une ABSENCE DE SUJET plutot qu une panne : le
36
+ // montage ne porte pas de proprietaire POSIX (exFAT, NTFS, /mnt/c en WSL sans
37
+ // metadata).
38
+ //
39
+ // EPERM N EN FAIT PLUS PARTIE. Il portait deux causes que rien ne distinguait :
40
+ // un montage qui refuse le concept de proprietaire, et un processus qui n a pas
41
+ // le privilege de donner le fichier. Mesure du 2026-08-11 : un chown vers uid 0
42
+ // depuis un compte ordinaire rendait outcome 'not-applicable' avec la raison
43
+ // 'montage-sans-proprietaire-posix' sur un tmpfs parfaitement POSIX. Le rapport
44
+ // disait "rien a faire" la ou il fallait dire "tu n as pas les droits".
45
+ const NO_POSIX_OWNER_CODES = ['ENOTSUP', 'EOPNOTSUPP', 'EINVAL', 'ENOSYS'];
46
+
47
+ /**
48
+ * De quelle nature est cette erreur : 'not-applicable' ou 'failed' ?
49
+ *
50
+ * EPERM se tranche sur le contexte, parce que le noyau rend le meme code pour
51
+ * les deux causes :
52
+ * - le processus n est pas root ET vise un autre uid que le sien -> le systeme
53
+ * lui refuse le geste. C est un ECHEC, avec une cause nommable.
54
+ * - le processus est root (ou vise son propre uid) et se fait quand meme
55
+ * refuser -> c est le montage qui n en veut pas. Absence de sujet.
56
+ */
57
+ function classifyError(err, { euid = null, targetUid = null } = {}) {
58
+ if (!err) return null;
59
+ if (NO_POSIX_OWNER_CODES.indexOf(err.code) !== -1) return 'not-applicable';
60
+ if (err.code !== 'EPERM') return 'failed';
61
+ if (euid !== null && euid !== 0 && targetUid !== null && targetUid !== euid) return 'failed';
62
+ return 'not-applicable';
63
+ }
64
+
65
+ function isNoPosixOwner(err) {
66
+ return !!err && NO_POSIX_OWNER_CODES.indexOf(err.code) !== -1;
67
+ }
68
+
69
+ function defaultFs() {
70
+ return require('fs');
71
+ }
72
+
73
+ // exec(fichier, args) -> stdout en utf8, leve si la commande echoue.
74
+ function defaultExec(file, args) {
75
+ const { execFileSync } = require('child_process');
76
+ return execFileSync(file, args, {
77
+ encoding: 'utf8',
78
+ timeout: 5000,
79
+ stdio: ['ignore', 'pipe', 'ignore'],
80
+ });
81
+ }
82
+
83
+ // WHY une enveloppe et pas process.umask directement : passer la methode
84
+ // detachee la coupe de son recepteur, et l injection doit rester remplacable
85
+ // par une fonction de test qui ne touche pas au processus jest.
86
+ function defaultUmask(mask) {
87
+ return process.umask(mask);
88
+ }
89
+
90
+ // ownershipOf(chemin) -> { uid, gid, mode } ou null quand le chemin est absent.
91
+ // Sert au court-circuit du parcours et aux verifications de test.
92
+ function ownershipOf(targetPath, options) {
93
+ const opts = options || {};
94
+ const fs = opts.fs || defaultFs();
95
+ try {
96
+ const st = fs.lstatSync(targetPath);
97
+ return { uid: st.uid, gid: st.gid, mode: st.mode & 0o7777 };
98
+ } catch (_err) {
99
+ return null;
100
+ }
101
+ }
102
+
103
+ function lstatOrNull(fs, p) {
104
+ try {
105
+ return fs.lstatSync(p);
106
+ } catch (_err) {
107
+ return null;
108
+ }
109
+ }
110
+
111
+ function notApplicable(reason, extra) {
112
+ const out = {
113
+ outcome: 'not-applicable',
114
+ changed: 0,
115
+ skipped: 0,
116
+ errors: [],
117
+ reason: reason,
118
+ };
119
+ if (extra) Object.assign(out, extra);
120
+ return out;
121
+ }
122
+
123
+ // ensureOwnership(racine, cible, options)
124
+ // cible = { uid, gid } (la sortie de install/lib/target-user.js)
125
+ // -> { outcome: 'ok' | 'not-applicable' | 'failed', changed, skipped, errors }
126
+ //
127
+ // options.recursive (defaut true) descend dans l arborescence
128
+ // options.dryRun (defaut false) compte sans appeler chown
129
+ // options.skipUnchanged (defaut true) court-circuit quand le proprietaire colle deja
130
+ function ensureOwnership(rootPath, target, options) {
131
+ const opts = options || {};
132
+ const fs = opts.fs || defaultFs();
133
+ const platform = opts.platform || process.platform;
134
+ const recursive = opts.recursive !== false;
135
+ const dryRun = opts.dryRun === true;
136
+ const skipUnchanged = opts.skipUnchanged !== false;
137
+ // Pose g+w (et g+x sur les dossiers) pendant le parcours. Utilise seulement
138
+ // quand un groupe partage est demande : sans lui, elargir les droits serait
139
+ // un effet de bord que personne n a demande.
140
+ const groupWritable = opts.groupWritable === true;
141
+ // L uid effectif sert a trancher EPERM. Injectable pour les tests ; null sur
142
+ // une plateforme sans geteuid, auquel cas EPERM retombe sur 'not-applicable'.
143
+ const euid = Object.prototype.hasOwnProperty.call(opts, 'euid')
144
+ ? opts.euid
145
+ : (typeof process.geteuid === 'function' ? process.geteuid() : null);
146
+
147
+ if (platform === 'win32') {
148
+ return notApplicable('windows-sans-proprietaire-posix');
149
+ }
150
+ if (!target || target.uid === null || target.uid === undefined) {
151
+ return notApplicable('cible-inconnue');
152
+ }
153
+
154
+ const uid = target.uid;
155
+ const result = { outcome: 'ok', changed: 0, skipped: 0, errors: [] };
156
+
157
+ function gidFor(st) {
158
+ // Cible sans gid : on garde le groupe en place et on ne corrige que l uid.
159
+ if (target.gid === null || target.gid === undefined) return st.gid;
160
+ return target.gid;
161
+ }
162
+
163
+ function record(err, p) {
164
+ result.errors.push({ path: p, code: err.code || null, message: err.message });
165
+ }
166
+
167
+ // Le groupe partage n a de sens que si le groupe peut ECRIRE. Le setgid pose
168
+ // a la racine gouverne ce qui sera cree ensuite ; l existant, lui, garde le
169
+ // mode qu il avait — 644 sur les fichiers, 755 sur les dossiers. Un groupe
170
+ // partage sans droit d ecriture sur l existant ne donne accces a rien.
171
+ function ouvrirAuGroupe(p, st) {
172
+ if (!groupWritable || typeof fs.chmodSync !== 'function') return;
173
+ const actuel = Number(st.mode) & 0o7777;
174
+ const estDossier = Boolean(st.isDirectory && st.isDirectory());
175
+ // g+w partout ; sur un dossier, g+x en plus (sinon le groupe ne peut pas le
176
+ // traverser meme en ayant le droit d'y ecrire) ET le setgid.
177
+ //
178
+ // LE SETGID VA SUR CHAQUE DOSSIER, PAS SEULEMENT SUR LA RACINE.
179
+ //
180
+ // Mesure du 2026-08-12 sur un serveur reel, avec --group docker : le gid
181
+ // etait bien pose sur les 7831 entrees, mais un seul dossier sur 1187
182
+ // portait le setgid — la racine. Un sous-dossier cree ensuite sous _byan/
183
+ // ressortait en gid 1003 (le groupe primaire de son createur) et mode 755.
184
+ // Le groupe partage tenait donc pour ce qui existait, et lachait pour tout
185
+ // ce qui serait ecrit apres : exactement ce dont un projet a plusieurs
186
+ // mains a besoin.
187
+ //
188
+ // Le bit ne se propage qu'AUX DOSSIERS CREES DANS un dossier qui le porte.
189
+ // Sur une arborescence deja posee, les sous-dossiers existent deja : il faut
190
+ // le leur donner un par un, pendant ce meme parcours.
191
+ const vise = actuel | 0o020 | (estDossier ? 0o010 | SETGID : 0);
192
+ if (vise === actuel) return;
193
+ try {
194
+ fs.chmodSync(p, vise);
195
+ } catch (err) {
196
+ record(err, p);
197
+ }
198
+ }
199
+
200
+ // Applique le proprietaire a une entree. Rend l erreur rencontree (ou null),
201
+ // pour que l appelant puisse decider d abandonner sur la sonde racine.
202
+ function applyOne(p, st) {
203
+ const gid = gidFor(st);
204
+ const isLinkTot = typeof st.isSymbolicLink === 'function' && st.isSymbolicLink();
205
+ // chmod suit le lien : on ne touche pas au mode au travers d un lien.
206
+ if (!isLinkTot) ouvrirAuGroupe(p, st);
207
+ if (skipUnchanged && st.uid === uid && st.gid === gid) {
208
+ result.skipped++;
209
+ return null;
210
+ }
211
+ if (dryRun) {
212
+ result.changed++;
213
+ return null;
214
+ }
215
+ const isLink = typeof st.isSymbolicLink === 'function' && st.isSymbolicLink();
216
+ // Un lien symbolique se corrige avec lchown, sinon chown suivrait le lien et
217
+ // sortirait de l arborescence installee.
218
+ if (isLink && typeof fs.lchownSync !== 'function') {
219
+ result.skipped++;
220
+ return null;
221
+ }
222
+ try {
223
+ if (isLink) fs.lchownSync(p, uid, gid);
224
+ else fs.chownSync(p, uid, gid);
225
+ result.changed++;
226
+ return null;
227
+ } catch (err) {
228
+ record(err, p);
229
+ return err;
230
+ }
231
+ }
232
+
233
+ const rootStat = lstatOrNull(fs, rootPath);
234
+ if (!rootStat) {
235
+ result.outcome = 'failed';
236
+ result.errors.push({ path: rootPath, code: 'ENOENT', message: 'chemin absent : ' + rootPath });
237
+ return result;
238
+ }
239
+
240
+ // UNE RACINE SYMBOLIQUE EST REFUSEE, PAS TRAITEE A MOITIE.
241
+ //
242
+ // lstat d'un lien rend isDirectory() faux : le parcours ne demarrait pas, et
243
+ // la fonction rendait 'ok' apres avoir corrige une seule entree — le lien.
244
+ // L'arborescence reelle, celle ou les etapes d'installation ont ecrit,
245
+ // gardait son proprietaire. Un 'ok' portant sur une entree quand on en attend
246
+ // des milliers est le pire des deux mondes : ni corrige, ni signale.
247
+ // L'appelant resout le chemin (fs.realpathSync) avant d'appeler.
248
+ if (typeof rootStat.isSymbolicLink === 'function' && rootStat.isSymbolicLink()) {
249
+ result.outcome = 'failed';
250
+ result.reason = 'racine-symbolique';
251
+ result.errors.push({
252
+ path: rootPath,
253
+ code: 'ESYMLINK',
254
+ message: 'la racine est un lien symbolique : resoudre le chemin avant la reprise (fs.realpathSync)',
255
+ });
256
+ return result;
257
+ }
258
+
259
+ // Sonde : la racine sert de test du montage. Une erreur de la famille
260
+ // "pas de proprietaire POSIX" ici vaut pour toute l arborescence, on arrete
261
+ // avant de lancer des milliers d appels qui echoueront de la meme facon.
262
+ const rootErr = applyOne(rootPath, rootStat);
263
+ if (rootErr) {
264
+ const nature = classifyError(rootErr, { euid: euid, targetUid: uid });
265
+ if (nature === 'not-applicable') {
266
+ return notApplicable('montage-sans-proprietaire-posix', {
267
+ errors: result.errors.slice(),
268
+ });
269
+ }
270
+ if (rootErr.code === 'EPERM') {
271
+ // Privilege insuffisant, et ca se dit. Poursuivre le parcours ne ferait
272
+ // qu accumuler la meme erreur sur chaque entree.
273
+ result.outcome = 'failed';
274
+ result.reason = 'privilege-insuffisant';
275
+ return result;
276
+ }
277
+ }
278
+
279
+ if (recursive && rootStat.isDirectory()) {
280
+ const stack = [rootPath];
281
+ while (stack.length > 0) {
282
+ const dir = stack.pop();
283
+ let names;
284
+ try {
285
+ names = fs.readdirSync(dir);
286
+ } catch (err) {
287
+ record(err, dir);
288
+ continue;
289
+ }
290
+ for (let i = 0; i < names.length; i++) {
291
+ const child = path.join(dir, names[i]);
292
+ const st = lstatOrNull(fs, child);
293
+ // Entree disparue en cours de parcours : aucun proprietaire a corriger,
294
+ // ce n est pas une panne.
295
+ if (!st) {
296
+ result.skipped++;
297
+ continue;
298
+ }
299
+ // Une erreur ponctuelle est collectee et le parcours continue.
300
+ applyOne(child, st);
301
+ const isLink = typeof st.isSymbolicLink === 'function' && st.isSymbolicLink();
302
+ if (st.isDirectory() && !isLink) stack.push(child);
303
+ }
304
+ }
305
+ }
306
+
307
+ if (result.errors.length === 0) {
308
+ result.outcome = 'ok';
309
+ return result;
310
+ }
311
+ const natures = result.errors.map(function (e) {
312
+ return classifyError({ code: e.code }, { euid: euid, targetUid: uid });
313
+ });
314
+ if (natures.every(function (n) { return n === 'not-applicable'; })) {
315
+ result.outcome = 'not-applicable';
316
+ result.reason = 'montage-sans-proprietaire-posix';
317
+ } else {
318
+ result.outcome = 'failed';
319
+ if (result.errors.some(function (e) { return e.code === 'EPERM'; })) {
320
+ result.reason = 'privilege-insuffisant';
321
+ }
322
+ }
323
+ return result;
324
+ }
325
+
326
+ // resolveGroupGid(nom, options) -> { gid, source, warnings }
327
+ // Node n expose pas getgrnam : le nom se resout par la base du systeme.
328
+ function resolveGroupGid(groupName, options) {
329
+ const opts = options || {};
330
+ const fs = opts.fs || defaultFs();
331
+ const platform = opts.platform || process.platform;
332
+ const exec = opts.exec || defaultExec;
333
+ const warnings = [];
334
+
335
+ if (platform === 'darwin') {
336
+ // Les groupes locaux macOS vivent dans OpenDirectory, pas dans /etc/group.
337
+ try {
338
+ const out = String(exec('dscl', ['.', '-read', '/Groups/' + groupName, 'PrimaryGroupID']));
339
+ const m = /PrimaryGroupID:\s*(\d+)/.exec(out);
340
+ if (m) return { gid: Number(m[1]), source: 'dscl', warnings: warnings };
341
+ warnings.push('dscl a repondu sans PrimaryGroupID pour ' + groupName);
342
+ } catch (err) {
343
+ warnings.push('dscl indisponible ou en echec : ' + err.message);
344
+ }
345
+ } else {
346
+ // getent interroge nsswitch.conf, donc aussi la seconde source du systeme
347
+ // (systemd, LDAP ou SSSD selon la machine).
348
+ try {
349
+ const out = String(exec('getent', ['group', groupName])).trim();
350
+ const line = out.split('\n')[0] || '';
351
+ const fields = line.split(':');
352
+ if (fields.length >= 3 && /^\d+$/.test(fields[2])) {
353
+ return { gid: Number(fields[2]), source: 'getent', warnings: warnings };
354
+ }
355
+ warnings.push('getent a repondu sans gid exploitable pour ' + groupName);
356
+ } catch (err) {
357
+ warnings.push('getent indisponible ou en echec : ' + err.message);
358
+ }
359
+ }
360
+
361
+ // Dernier recours. WHY seulement en recours : le nsswitch.conf de la machine
362
+ // de reference porte "group: files [SUCCESS=merge] systemd", donc une seconde
363
+ // source existe et /etc/group ne suffit pas a lui seul.
364
+ try {
365
+ const content = String(fs.readFileSync('/etc/group', 'utf8'));
366
+ const lines = content.split('\n');
367
+ for (let i = 0; i < lines.length; i++) {
368
+ const fields = lines[i].split(':');
369
+ if (fields[0] === groupName && fields.length >= 3 && /^\d+$/.test(fields[2])) {
370
+ warnings.push('gid resolu par le recours /etc/group (source secondaire du systeme non consultee)');
371
+ return { gid: Number(fields[2]), source: '/etc/group', warnings: warnings };
372
+ }
373
+ }
374
+ warnings.push('groupe ' + groupName + ' absent de /etc/group');
375
+ } catch (err) {
376
+ warnings.push('/etc/group illisible : ' + err.message);
377
+ }
378
+
379
+ return { gid: null, source: null, warnings: warnings };
380
+ }
381
+
382
+ function createGroupHint(groupName, platform) {
383
+ if (platform === 'darwin') {
384
+ return 'dseditgroup -o create ' + groupName;
385
+ }
386
+ return 'groupadd ' + groupName;
387
+ }
388
+
389
+ // ensureSharedGroup(racine, nomDeGroupe, options)
390
+ // -> { outcome, gid, mode, warnings }
391
+ // Pose le groupe partage et le bit setgid a la racine, puis abaisse le umask.
392
+ // La creation du groupe reste une action d administrateur : on ne la fait pas.
393
+ function ensureSharedGroup(rootPath, groupName, options) {
394
+ const opts = options || {};
395
+ const fs = opts.fs || defaultFs();
396
+ const platform = opts.platform || process.platform;
397
+ const exec = opts.exec || defaultExec;
398
+ const umask = opts.umask || defaultUmask;
399
+ const euid = Object.prototype.hasOwnProperty.call(opts, 'euid')
400
+ ? opts.euid
401
+ : (typeof process.geteuid === 'function' ? process.geteuid() : null);
402
+ const warnings = [];
403
+
404
+ if (platform === 'win32') {
405
+ warnings.push(
406
+ 'droits de groupe a poser a la main : icacls ' + rootPath +
407
+ ' /grant "' + groupName + ':(OI)(CI)M" /T'
408
+ );
409
+ return { outcome: 'not-applicable', gid: null, mode: null, warnings: warnings, reason: 'windows-sans-setgid' };
410
+ }
411
+
412
+ const resolved = resolveGroupGid(groupName, { fs: fs, platform: platform, exec: exec });
413
+ for (let i = 0; i < resolved.warnings.length; i++) warnings.push(resolved.warnings[i]);
414
+
415
+ if (resolved.gid === null) {
416
+ const hint = createGroupHint(groupName, platform);
417
+ warnings.push('groupe ' + groupName + ' introuvable ; le creer avec : ' + hint);
418
+ return {
419
+ outcome: 'failed',
420
+ gid: null,
421
+ mode: null,
422
+ warnings: warnings,
423
+ message: 'groupe ' + groupName + ' introuvable. Creer le groupe puis relancer : ' + hint,
424
+ };
425
+ }
426
+
427
+ const st = lstatOrNull(fs, rootPath);
428
+
429
+ // MEME REFUS QUE ensureOwnership, ET POUR UNE RAISON PLUS DURE ENCORE.
430
+ //
431
+ // chmod suit le lien (Linux n'a pas de lchmod), mais lstat rend le mode DU
432
+ // LIEN, qui vaut 0o777. Le calcul (st.mode & 0o7777) | SETGID posait donc
433
+ // 2777 — drwxrwsrwx — sur le vrai dossier d'installation : ouvert en ecriture
434
+ // au monde entier. Mesure de la revue du 2026-08-12.
435
+ if (st && typeof st.isSymbolicLink === 'function' && st.isSymbolicLink()) {
436
+ warnings.push('racine symbolique : resoudre le chemin avant de poser le groupe (fs.realpathSync)');
437
+ return {
438
+ outcome: 'failed',
439
+ gid: resolved.gid,
440
+ mode: null,
441
+ warnings: warnings,
442
+ reason: 'racine-symbolique',
443
+ message: 'la racine ' + rootPath + ' est un lien symbolique ; le mode lu serait celui du lien (0777), pas celui du dossier',
444
+ };
445
+ }
446
+
447
+ if (!st) {
448
+ return {
449
+ outcome: 'failed',
450
+ gid: resolved.gid,
451
+ mode: null,
452
+ warnings: warnings,
453
+ message: 'chemin absent : ' + rootPath,
454
+ };
455
+ }
456
+
457
+ try {
458
+ fs.chmodSync(rootPath, (st.mode & 0o7777) | SETGID);
459
+ // On ne change que le groupe : l uid en place est reconduit tel quel.
460
+ fs.chownSync(rootPath, st.uid, resolved.gid);
461
+ } catch (err) {
462
+ // Meme partage qu'ensureOwnership : EPERM depuis un compte ordinaire est un
463
+ // privilege manquant, pas un montage sans proprietaire.
464
+ const outcome = classifyError(err, { euid: euid, targetUid: st.uid });
465
+ warnings.push('pose du groupe partage refusee (' + (err.code || 'sans code') + ')');
466
+ return {
467
+ outcome: outcome,
468
+ gid: resolved.gid,
469
+ mode: null,
470
+ warnings: warnings,
471
+ message: err.message,
472
+ reason: outcome === 'not-applicable'
473
+ ? 'montage-sans-proprietaire-posix'
474
+ : (err.code === 'EPERM' ? 'privilege-insuffisant' : undefined),
475
+ };
476
+ }
477
+
478
+ // Le setgid seul rend un fichier en 644 sous umask 022 (fait mesure numero 2).
479
+ // On rend l ancienne valeur pour que l appelant la restaure apres l install.
480
+ let previousUmask = null;
481
+ try {
482
+ previousUmask = umask(GROUP_WRITABLE_UMASK);
483
+ } catch (err) {
484
+ warnings.push('umask non modifiable : ' + err.message);
485
+ }
486
+
487
+ const after = lstatOrNull(fs, rootPath);
488
+ return {
489
+ outcome: 'ok',
490
+ gid: resolved.gid,
491
+ mode: after ? after.mode & 0o7777 : null,
492
+ warnings: warnings,
493
+ source: resolved.source,
494
+ previousUmask: previousUmask,
495
+ umask: GROUP_WRITABLE_UMASK,
496
+ };
497
+ }
498
+
499
+ module.exports = {
500
+ ensureOwnership,
501
+ classifyError,
502
+ ensureSharedGroup,
503
+ ownershipOf,
504
+ resolveGroupGid,
505
+ SETGID,
506
+ GROUP_WRITABLE_UMASK,
507
+ NO_POSIX_OWNER_CODES,
508
+ };
@@ -16,6 +16,7 @@ const logger = require('../utils/logger');
16
16
  // no secret, byan-channel inert). installDirectMCP delegates to it instead of
17
17
  // hand-building a divergent entry with an absolute path that overwrote siblings.
18
18
  const { mcpConfig } = require('../../packages/platform-config');
19
+ const apiDefaults = require('../api-defaults');
19
20
 
20
21
  const PLATFORM_NAME = 'Claude Code';
21
22
  // Relative path discipline: the byan server entry args[0] is repo-relative
@@ -172,10 +173,13 @@ async function installDirectMCP(projectRoot, agents, config) {
172
173
  // READ-MERGE-WRITE through the shared pure merge so this writer is byte-for-byte
173
174
  // identical to the canonical .mcp.json writer: relative server path, no secret,
174
175
  // existing mcpServers.* preserved, and the inert byan-channel entry added.
175
- // apiUrl is irrelevant to the entry shape (the server resolves its own config),
176
- // but the merge signature accepts it; a localhost placeholder keeps it valid.
176
+ // apiUrl is irrelevant to the entry shape (the server resolves its own config,
177
+ // and mergeByanEntry strips BYAN_API_URL from the env it writes), but the merge
178
+ // signature accepts it. On passe quand meme la source unique plutot qu'un
179
+ // localhost code en dur : une valeur de remplissage qui ressemble a un defaut
180
+ // finit par etre lue comme un defaut.
177
181
  const existingConfig = await fileUtils.readJson(configPath);
178
- const merged = mcpConfig.mergeByanEntry(existingConfig, { apiUrl: 'http://localhost:3737' });
182
+ const merged = mcpConfig.mergeByanEntry(existingConfig, { apiUrl: apiDefaults.PROD_API_URL });
179
183
 
180
184
  // Write updated config
181
185
  await fileUtils.writeJson(configPath, merged, { spaces: 2 });