Compare commits

...
Author SHA1 Message Date
johanleroy 0c191b1296 release: git-arboretum 3.7.1 (transcripts préservés), desktop 0.2.5 (mise à jour automatique)
CI / No em/en dashes (push) Successful in 3s
CI / Build & test (Node 24) (push) Successful in 11m5s
Release / Publish to Gitea npm registry (push) Successful in 11m10s
CI / Build & test (Node 22) (push) Successful in 11m24s
Desktop Release / Build Linux (AppImage + deb) (push) Successful in 13m57s
Desktop Release / Publish floating desktop-latest release (push) Successful in 10s
CI / Pack & boot smoke (Node 22) (push) Successful in 9m54s
2026-08-05 11:35:59 +02:00
johanleroy 17e95754b1 fix(server, desktop): plus de transcript perdu, et la mise à jour s'applique toute seule
Deux défauts vécus sur le poste, tous deux « invisibles » jusqu'à ce qu'on regarde.

1. Une console Claude ouverte depuis l'app affichait « Transcript saving is off,
   inherited CLAUDE_CODE_CHILD_SESSION marker ». Le daemon avait été lancé depuis
   une session Claude Code, il héritait donc de ses marqueurs d'exécution et les
   repassait à CHAQUE session qu'il lance. Le CLI se croyait sous-session et
   coupait la sauvegarde de son transcript : plus d'historique, plus de --resume,
   claudeSessionId restant null (et avec lui l'état fin busy/waiting/idle, ce qui
   explique les sessions sans activité détectée). L'environnement des PTY est
   desormais assaini de ces marqueurs, pour `claude` comme pour les shells (un
   `claude` tapé à la main en héritait aussi). La configuration légitime de
   l'utilisateur (CLAUDE_CONFIG_DIR, ANTHROPIC_*, proxies) passe intacte.
   Vérifié par acceptance-p17 : le daemon de test est lancé avec un environnement
   volontairement pollué, et le PTY n'en voit plus rien.

2. Une mise à jour installée à chaud demandait encore une manipulation. La 0.2.4
   détectait le remplacement du binaire et proposait un dialogue « Restart now » :
   le travail restait à la charge de l'utilisateur. L'app redémarre maintenant
   d'elle-même quand cela ne coûte rien, c'est-à-dire le cas courant, et ne
   demande que s'il y a quelque chose à perdre : des sessions vivantes à
   interrompre (le dialogue dit combien) ou un daemon injoignable. Un « Later »
   reste définitif pour cette version : rien ne redémarre dans le dos de
   personne. La détection ne dépend plus d'un retour par le tray ou le Dock : un
   `stat` toutes les 30 s la couvre même fenêtre ouverte, par poll et non par
   `fs.watch`, qui ne voit souvent rien quand un paquet remplace un binaire ou
   tout un répertoire.
2026-08-05 11:35:51 +02:00
12 changed files with 324 additions and 19 deletions
+1 -1
View File
@@ -7933,7 +7933,7 @@
},
"packages/server": {
"name": "@johanleroy/git-arboretum",
"version": "3.7.0",
"version": "3.7.1",
"license": "MIT",
"dependencies": {
"@fastify/cookie": "^11.0.0",
+16
View File
@@ -4,6 +4,22 @@ Notable changes to the Arboretum desktop app (`packages/desktop`). The daemon an
extension keep their own changelogs in `packages/server/CHANGELOG.md` and
`packages/vscode/CHANGELOG.md`.
## 0.2.5
Ships the daemon 3.7.1, and finishes the job started in 0.2.4: an update installed while the app runs
now applies **itself**.
- **The restart after an update no longer needs you.** 0.2.4 detected that the binary had been
replaced and offered a *Restart now* dialog. That still made the user do the work. The app now
restarts on its own when it costs nothing, which is the common case, and only asks when there is
something to lose: the dialog appears when live sessions would be interrupted (it says how many),
or when the daemon cannot be reached to find out. A previous *Later* is final for that version, so
nothing ever restarts behind your back.
- **The update is now noticed while the window is open.** Detection used to run only when the window
was re-shown (tray, Dock, second launch), so an update installed during a working session could go
unnoticed indefinitely. A cheap `stat` every 30 s covers it, by polling rather than `fs.watch`,
because a package replacing the binary or a whole directory often produces no watch event at all.
## 0.2.4
Ships the daemon 3.7.0: terminals no longer go black, they can sit side by side in resizable columns,
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@arboretum/desktop",
"version": "0.2.4",
"version": "0.2.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@arboretum/desktop",
"version": "0.2.4",
"version": "0.2.5",
"license": "MIT",
"devDependencies": {
"@types/node": "^22.10.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@arboretum/desktop",
"private": true,
"version": "0.2.4",
"version": "0.2.5",
"description": "Self-hosted multi-project AI IDE for git worktrees and Claude Code sessions",
"homepage": "https://git-arboretum.com",
"repository": {
+61 -8
View File
@@ -18,7 +18,7 @@ import { registerClipboardBridge } from './clipboard';
import { initUpdater } from './updater';
import { resolveIconPath } from './paths';
import { describeStartFailure } from './start-failure';
import { installChanged, readInstallStamp, type InstallStamp } from './upgrade-watch';
import { decideUpgradeAction, installChanged, pollInstall, readInstallStamp, type InstallStamp } from './upgrade-watch';
// WM_CLASS / app_id déterministe, posé AVANT app.whenReady(). Sous Wayland (défaut Debian/GNOME)
// l'option `icon:` de BrowserWindow est ignorée : l'icône de fenêtre/dock vient du fichier .desktop
@@ -44,6 +44,11 @@ let serverReady = false;
const bootStamp = readInstallStamp(process.execPath);
let dismissedStamp: InstallStamp | null = null;
let restartPromptOpen = false;
/** Surveillance de l'installation : une mise à jour posée à chaud doit se voir sans que l'utilisateur
* ait à toucher à quoi que ce soit, fenêtre ouverte comprise. */
let installPoll: { stop: () => void } | null = null;
/** Intervalle du poll : un `stat` toutes les 30 s est indolore et suffit largement. */
const INSTALL_POLL_MS = 30_000;
// Instance unique : deux instances = deux daemons/ports en conflit.
if (!app.requestSingleInstanceLock()) {
@@ -103,6 +108,16 @@ async function bootstrap(): Promise<void> {
installAppMenu({ url: daemonUrl(), onQuit: quitApp });
if (!tray) tray = createTray({ show: showWindow, quit: quitApp });
initUpdater();
// Détection CONTINUE : sans elle, une mise à jour installée pendant que la fenêtre est ouverte
// n'était remarquée qu'au prochain passage par le tray ou le Dock, donc parfois jamais.
if (app.isPackaged && !installPoll) {
installPoll = pollInstall({
path: process.execPath,
intervalMs: INSTALL_POLL_MS,
boot: bootStamp,
onChanged: () => void handleUpgradeInstalled(),
});
}
}
/** Daemon + cookie de session : le strict nécessaire pour charger la SPA (aussi utilisé au redémarrage). */
@@ -205,33 +220,71 @@ app.on('activate', showWindow);
function showWindow(): void {
// Tray, second-instance et Dock passent tous ici : c'est le moment où l'utilisateur redemande
// l'app, donc le bon moment pour signaler une mise à jour installée entre-temps.
void maybePromptRestartAfterUpgrade();
// l'app, donc le bon moment pour traiter une mise à jour installée entre-temps.
void handleUpgradeInstalled();
if (!win) return;
if (win.isMinimized()) win.restore();
win.show();
win.focus();
}
/**
* Nombre de sessions VIVANTES hébergées par le daemon : ce sont les seules choses qu'un redémarrage
* détruit. `null` quand on n'a pas pu savoir (daemon injoignable) : l'appelant demandera alors.
* Pas d'en-tête Origin sur ce fetch, donc le check Origin strict du serveur ne s'y applique pas.
*/
async function countLiveSessions(): Promise<number | null> {
const handle = daemon;
if (!handle || !serverReady) return null;
try {
const res = await fetch(`${handle.url}/api/v1/sessions`, { headers: { Authorization: `Bearer ${handle.token}` } });
if (!res.ok) return null;
const body = (await res.json()) as { sessions?: Array<{ live?: boolean }> };
return (body.sessions ?? []).filter((s) => s.live).length;
} catch {
return null;
}
}
/**
* Mise à jour installée pendant que l'app tournait : le lock d'instance unique renvoie les lancements
* suivants sur la fenêtre de l'ANCIENNE version, sans un mot, et l'utilisateur croit avoir migré.
*
* Objectif : zéro manipulation. Quand un redémarrage ne coûte RIEN (aucune session vivante), on
* redémarre tout seul. On ne demande que s'il y a du travail en cours à interrompre, ou si le daemon
* ne répond pas. Un « Later » précédent est définitif pour cette version.
*/
async function maybePromptRestartAfterUpgrade(): Promise<void> {
if (restartPromptOpen || isQuitting || !app.isPackaged) return;
async function handleUpgradeInstalled(): Promise<void> {
if (restartPromptOpen || isQuitting || shuttingDown || !app.isPackaged) return;
const current = readInstallStamp(process.execPath);
if (!installChanged(bootStamp, current)) return;
if (dismissedStamp && !installChanged(dismissedStamp, current)) return;
if (!current || !installChanged(bootStamp, current)) return;
const dismissed = !!dismissedStamp && !installChanged(dismissedStamp, current);
const liveSessions = dismissed ? 0 : await countLiveSessions();
const action = decideUpgradeAction({ changed: true, liveSessions, dismissed });
if (action === 'none') return;
if (action === 'restart') {
// Rien à perdre : on applique la mise à jour sans rien demander. C'est le cas courant.
installPoll?.stop();
installPoll = null;
relaunchAfterQuit = true;
quitApp();
return;
}
restartPromptOpen = true;
try {
const running = liveSessions ?? 0;
const { response } = await dialog.showMessageBox({
type: 'info',
title: 'Arboretum',
message: 'A new version of Arboretum has been installed',
detail:
`This window still runs version ${app.getVersion()}, started before the update. ` +
'Restart to load the installed version. Running sessions will be stopped.',
(running > 0
? `Restarting stops ${running} running session${running > 1 ? 's' : ''}. They can be resumed afterwards.`
: 'Restart to load the installed version.'),
buttons: ['Restart now', 'Later'],
defaultId: 0,
cancelId: 1,
@@ -26,3 +26,46 @@ export function installChanged(boot: InstallStamp | null, current: InstallStamp
if (!boot || !current) return false;
return boot.ino !== current.ino || boot.mtimeMs !== current.mtimeMs || boot.size !== current.size;
}
/**
* Ce qu'il faut faire d'une mise à jour installée à chaud.
*
* Le but est de ne PAS faire porter la manipulation à l'utilisateur : quand redémarrer ne coûte rien,
* on redémarre. La seule chose qu'un redémarrage détruit, ce sont les sessions vivantes hébergées par
* le daemon (des agents en train de travailler) : là seulement, on demande.
*
* `liveSessions === null` = on n'a pas pu le savoir (daemon injoignable) : on demande, par prudence.
* Un refus précédent (« Later ») est respecté, y compris si les sessions se terminent ensuite : on ne
* redémarre jamais dans le dos de quelqu'un qui a dit non.
*/
export function decideUpgradeAction(input: {
changed: boolean;
liveSessions: number | null;
dismissed: boolean;
}): 'none' | 'restart' | 'ask' {
if (!input.changed || input.dismissed) return 'none';
return input.liveSessions === 0 ? 'restart' : 'ask';
}
/**
* Surveillance de l'installation par POLL, et non par `fs.watch` : un paquet remplace le binaire
* (nouvel inode) ou tout un répertoire, et selon le gestionnaire de paquets et le système de
* fichiers, `fs.watch` sur le fichier ne voit alors plus rien. Un `stat` périodique est trivial en
* coût et se comporte pareil partout. Le premier changement suffit : on arrête de surveiller.
*/
export function pollInstall(opts: {
path: string;
intervalMs: number;
boot: InstallStamp | null;
onChanged: (current: InstallStamp) => void;
}): { stop: () => void } {
const timer = setInterval(() => {
const current = readInstallStamp(opts.path);
if (!current || !installChanged(opts.boot, current)) return;
clearInterval(timer);
opts.onChanged(current);
}, opts.intervalMs);
// Ne retient pas la boucle d'événements : ce timer ne doit jamais empêcher l'app de quitter.
timer.unref?.();
return { stop: () => clearInterval(timer) };
}
+69 -2
View File
@@ -1,8 +1,8 @@
import { describe, expect, it } from 'vitest';
import { mkdtempSync, utimesSync, writeFileSync } from 'node:fs';
import { mkdtempSync, rmSync, utimesSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { installChanged, readInstallStamp } from '../src/main/upgrade-watch';
import { decideUpgradeAction, installChanged, pollInstall, readInstallStamp } from '../src/main/upgrade-watch';
describe('upgrade-watch', () => {
it('lit une empreinte de fichier, et rien pour un chemin absent', () => {
@@ -31,3 +31,70 @@ describe('upgrade-watch', () => {
expect(installChanged(null, null)).toBe(false);
});
});
// Objectif produit : ZÉRO manipulation quand c'est sans risque. Un redémarrage ne détruit qu'une
// chose, les sessions vivantes hébergées par le daemon : elles seules justifient de demander.
describe('decideUpgradeAction', () => {
it('rien à faire si l’installation n’a pas changé', () => {
expect(decideUpgradeAction({ changed: false, liveSessions: 0, dismissed: false })).toBe('none');
expect(decideUpgradeAction({ changed: false, liveSessions: 3, dismissed: false })).toBe('none');
});
it('aucune session vivante : redémarrage automatique, sans dialogue', () => {
expect(decideUpgradeAction({ changed: true, liveSessions: 0, dismissed: false })).toBe('restart');
});
it('des sessions tournent : on demande avant de les interrompre', () => {
expect(decideUpgradeAction({ changed: true, liveSessions: 1, dismissed: false })).toBe('ask');
expect(decideUpgradeAction({ changed: true, liveSessions: 9, dismissed: false })).toBe('ask');
});
it('état inconnu (daemon injoignable) : on demande, par prudence', () => {
expect(decideUpgradeAction({ changed: true, liveSessions: null, dismissed: false })).toBe('ask');
});
it('un « Later » est définitif : jamais de redémarrage dans le dos de l’utilisateur', () => {
expect(decideUpgradeAction({ changed: true, liveSessions: 0, dismissed: true })).toBe('none');
expect(decideUpgradeAction({ changed: true, liveSessions: null, dismissed: true })).toBe('none');
});
});
describe('pollInstall', () => {
it('signale le premier changement, puis s’arrête de lui-même', async () => {
const dir = mkdtempSync(join(tmpdir(), 'arb-poll-'));
const file = join(dir, 'app');
try {
writeFileSync(file, 'v1');
const boot = readInstallStamp(file);
const seen: number[] = [];
const handle = pollInstall({ path: file, intervalMs: 10, boot, onChanged: (c) => seen.push(c.size) });
await new Promise((r) => setTimeout(r, 40));
expect(seen).toHaveLength(0); // rien n'a bougé
writeFileSync(file, 'v2-plus-long');
await new Promise((r) => setTimeout(r, 80));
expect(seen).toHaveLength(1);
// un second changement ne doit PAS rappeler : le poll s'arrête au premier
writeFileSync(file, 'v3-encore-plus-long');
await new Promise((r) => setTimeout(r, 60));
expect(seen).toHaveLength(1);
handle.stop();
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it('un chemin illisible ne déclenche rien', async () => {
const seen: string[] = [];
const handle = pollInstall({
path: '/definitely/not/here',
intervalMs: 10,
boot: { ino: 1, mtimeMs: 1, size: 1 },
onChanged: () => seen.push('x'),
});
await new Promise((r) => setTimeout(r, 50));
expect(seen).toHaveLength(0);
handle.stop();
});
});
+14
View File
@@ -3,6 +3,20 @@
Notable changes to `@johanleroy/git-arboretum` (the Arboretum daemon). The VS Code
extension keeps its own changelog in `packages/vscode/CHANGELOG.md`.
## 3.7.1
- **Sessions launched from Arboretum lost their transcript.** When the daemon itself was started from a
Claude Code session (an agent launching the desktop app, or `arboretum` started from a Claude
terminal), it inherited that session's runtime markers and passed them on to every session it
spawned. `CLAUDE_CODE_CHILD_SESSION=1` makes the CLI believe it is a sub-session, so it turns
transcript saving off: no history, no `--resume`, `claudeSessionId` stays null, and with it the fine
busy/waiting/idle state. The visible symptom was a warning in the terminal: *Transcript saving is
off, inherited CLAUDE_CODE_CHILD_SESSION marker*. The PTY environment is now stripped of those
markers (`CLAUDECODE`, `CLAUDE_CODE_CHILD_SESSION`, `CLAUDE_CODE_SESSION_ID`,
`CLAUDE_CODE_ENTRYPOINT`, `CLAUDE_CODE_EXECPATH`, `CLAUDE_PID`, `CLAUDE_EFFORT`) for `claude` **and**
for shells, since a `claude` typed by hand in a terminal would inherit them too. Legitimate user
configuration (`CLAUDE_CONFIG_DIR`, `ANTHROPIC_*`, proxies) is untouched.
## 3.7.0
Terminals stop going black, and they now sit side by side. Files and commits follow the terminal you
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@johanleroy/git-arboretum",
"version": "3.7.0",
"version": "3.7.1",
"description": "Self-hosted multi-project AI IDE for git worktrees and the Claude Code sessions running on them",
"license": "MIT",
"type": "module",
+29 -2
View File
@@ -29,8 +29,20 @@ const check = (name, ok, detail = '') => {
};
const tmp = mkdtempSync(join(tmpdir(), 'arb-p17-'));
// Daemon lancé avec un environnement POLLUÉ, exactement comme lorsqu'il est démarré depuis une
// session Claude Code (cas vécu : l'app de bureau lancée par un agent). Ces marqueurs ne doivent
// JAMAIS atteindre les sessions qu'il lance, sinon le CLI se croit sous-session et coupe la
// sauvegarde de son transcript (plus d'historique, plus de --resume).
const srv = spawn('node', [join(serverDir, 'dist', 'index.js'), '--port', String(PORT), '--db', join(tmp, 'a.db'), '--no-discover'], {
env: { ...process.env, ARBORETUM_LOG: 'warn' },
env: {
...process.env,
ARBORETUM_LOG: 'warn',
CLAUDECODE: '1',
CLAUDE_CODE_CHILD_SESSION: '1',
CLAUDE_CODE_SESSION_ID: 'parent-session-id',
CLAUDE_PID: '424242',
ARB_MARQUEUR_LEGITIME: 'conserve-moi',
},
stdio: ['ignore', 'pipe', 'pipe'],
});
let srvOut = '';
@@ -181,7 +193,22 @@ try {
check('épilogue : le code de sortie est écrit dans le terminal', epilogue.includes('[arboretum]') && epilogue.includes('exited with code 3'), epilogue.slice(-60).replace(/[\r\n]+/g, ' '));
check('épilogue reçu AVANT le detached', !!detached5);
// --- 6. Le PTY n'a pas été redimensionné par les attaches sans écran ---
// --- 6. L'environnement du PTY est assaini des marqueurs de la session parente ---
// Le nom du marqueur est CONCATÉNÉ dans la commande ('EN' + 'V:') pour que l'écho local du terminal
// ne ressemble pas au résultat : sinon on relit sa propre frappe et le test passe toujours.
c2.send({ type: 'stdin', channel: att2.channel, data: 'echo "EN""V:[$CLAUDECODE][$CLAUDE_CODE_CHILD_SESSION][$CLAUDE_CODE_SESSION_ID][$CLAUDE_PID]"\r' });
await sleep(900);
const envLine = /ENV:\[[^\]]*\]\[[^\]]*\]\[[^\]]*\]\[[^\]]*\]/.exec(c2.outputOf(att2.channel).replace(/\r?\n/g, ''))?.[0] ?? '';
check('les marqueurs de session parente ne sont pas transmis au PTY', envLine === 'ENV:[][][][]', envLine || 'non observé');
c2.send({ type: 'stdin', channel: att2.channel, data: 'echo "GARDE:[$ARB_MARQUEUR_LEGITIME]"\r' });
await sleep(900);
check(
'le reste de l’environnement est bien transmis',
c2.outputOf(att2.channel).includes('GARDE:[conserve-moi]'),
);
// --- 7. Le PTY n'a pas été redimensionné par les attaches sans écran ---
const listed = await (await api('/api/v1/sessions')).json();
check('session toujours vivante après tout ça', listed.sessions.some((s) => s.id === sid && s.live));
+33 -1
View File
@@ -146,11 +146,43 @@ export function resolvePlainShell(platform: NodeJS.Platform = process.platform):
return { file: 'bash', args: ['--norc'] };
}
/**
* Marqueurs d'EXÉCUTION que le CLI claude pose dans l'environnement de ses processus enfants. Si le
* daemon a lui-même été lancé depuis une session Claude Code (ce qui arrive : `arboretum` démarré
* depuis un terminal Claude, ou l'app de bureau lancée par un agent), il les hérite et les
* retransmettait à CHAQUE session qu'il lance. Conséquences observées :
* - `CLAUDE_CODE_CHILD_SESSION=1` fait croire au CLI qu'il est une sous-session : il DÉSACTIVE la
* sauvegarde du transcript (« Transcript saving is off »), donc plus d'historique, plus de
* `--resume`, et `claudeSessionId` reste null (l'état fin busy/waiting/idle tombe avec lui) ;
* - `CLAUDE_CODE_SESSION_ID` / `CLAUDE_PID` désignent la session PARENTE, pas celle qu'on lance.
* On ne retire QUE ces marqueurs : la configuration légitime de l'utilisateur (`CLAUDE_CONFIG_DIR`,
* `ANTHROPIC_*`, proxies...) doit passer telle quelle, sinon on casserait son installation.
*/
export const INHERITED_CLAUDE_MARKERS = [
'CLAUDECODE',
'CLAUDE_CODE_CHILD_SESSION',
'CLAUDE_CODE_SESSION_ID',
'CLAUDE_CODE_ENTRYPOINT',
'CLAUDE_CODE_EXECPATH',
'CLAUDE_PID',
'CLAUDE_EFFORT',
] as const;
/**
* Environnement assaini pour un PTY : pur et testable. Appliqué aussi au shell (`bash`), car un
* `claude` lancé à la main dans ce terminal hériterait des mêmes marqueurs.
*/
export function sanitizeInheritedEnv(source: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = { ...source };
for (const key of INHERITED_CLAUDE_MARKERS) delete env[key];
return env;
}
/** Module volontairement abstrait : le plan B « BYO API key / Agent SDK » se brancherait ici. */
export function buildSpawnSpec(opts: SpawnOptions): SpawnSpec {
const platform = opts.platform ?? process.platform;
const env: NodeJS.ProcessEnv = {
...process.env,
...sanitizeInheritedEnv(process.env),
TERM: 'xterm-256color',
COLORTERM: 'truecolor',
};
+54 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest';
import { buildSpawnSpec, diagnoseClaudeBin, resolveClaudeBin } from '../src/core/claude-launcher.js';
import { buildSpawnSpec, diagnoseClaudeBin, INHERITED_CLAUDE_MARKERS, resolveClaudeBin, sanitizeInheritedEnv } from '../src/core/claude-launcher.js';
// resolveClaudeBin() fait `which claude` : on le stub pour ne pas dépendre d'un claude réel en PATH.
vi.mock('node:child_process', () => ({ execFileSync: () => '/usr/bin/claude\n' }));
@@ -52,3 +52,56 @@ describe('resolveClaudeBin / diagnoseClaudeBin · override de chemin (réglage U
expect(diagnoseClaudeBin()).toEqual({ path: '/usr/bin/claude', source: 'path', ok: true });
});
});
// Régression vécue : l'app de bureau avait été lancée depuis une session Claude Code, donc le daemon
// héritait de `CLAUDE_CODE_CHILD_SESSION=1` et le repassait à CHAQUE session lancée. Le CLI se croyait
// sous-session et coupait la sauvegarde du transcript : « Transcript saving is off », plus aucun
// historique, plus de `--resume`, et `claudeSessionId` restait null (l'état fin tombe avec lui).
describe('sanitizeInheritedEnv · marqueurs de session parente', () => {
const polluted = {
PATH: '/usr/bin',
CLAUDECODE: '1',
CLAUDE_CODE_CHILD_SESSION: '1',
CLAUDE_CODE_SESSION_ID: 'afad72f8-a987-462a-9406-7fd144e05905',
CLAUDE_CODE_ENTRYPOINT: 'cli',
CLAUDE_CODE_EXECPATH: '/home/u/.local/share/claude/versions/2.1.222',
CLAUDE_PID: '1813704',
CLAUDE_EFFORT: 'xhigh',
CLAUDE_CONFIG_DIR: '/home/u/.claude',
ANTHROPIC_API_KEY: 'sk-test',
HTTPS_PROXY: 'http://proxy:3128',
};
it('retire les marqueurs d’exécution hérités', () => {
const env = sanitizeInheritedEnv(polluted);
for (const key of INHERITED_CLAUDE_MARKERS) expect(env[key]).toBeUndefined();
});
it('conserve la configuration légitime de l’utilisateur', () => {
const env = sanitizeInheritedEnv(polluted);
expect(env.CLAUDE_CONFIG_DIR).toBe('/home/u/.claude');
expect(env.ANTHROPIC_API_KEY).toBe('sk-test');
expect(env.HTTPS_PROXY).toBe('http://proxy:3128');
expect(env.PATH).toBe('/usr/bin');
});
it('ne mute pas la source', () => {
const copy = { ...polluted };
sanitizeInheritedEnv(copy);
expect(copy.CLAUDE_CODE_CHILD_SESSION).toBe('1');
});
it('buildSpawnSpec assainit l’env, pour claude ET pour le shell', () => {
const previous = process.env.CLAUDE_CODE_CHILD_SESSION;
process.env.CLAUDE_CODE_CHILD_SESSION = '1';
try {
expect(buildSpawnSpec({ command: 'claude' }).env.CLAUDE_CODE_CHILD_SESSION).toBeUndefined();
// un `claude` tapé à la main dans ce shell hériterait sinon du même marqueur
expect(buildSpawnSpec({ command: 'bash' }).env.CLAUDE_CODE_CHILD_SESSION).toBeUndefined();
expect(buildSpawnSpec({ command: 'claude' }).env.TERM).toBe('xterm-256color');
} finally {
if (previous === undefined) delete process.env.CLAUDE_CODE_CHILD_SESSION;
else process.env.CLAUDE_CODE_CHILD_SESSION = previous;
}
});
});