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.
This commit is contained in:
2026-08-05 11:35:51 +02:00
parent dc8c7c9534
commit 17e95754b1
6 changed files with 289 additions and 14 deletions
+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;
}
});
});