3 Commits

Author SHA1 Message Date
0f126cf911 fix(web): repeindre les terminaux au retour-visible + flush ACK ciblé
Some checks failed
CI / Build & test (Node 24) (push) Has been cancelled
CI / Pack & boot smoke (Node 22) (push) Has been cancelled
CI / Build & test (Node 22) (push) Has been cancelled
Un terminal caché dans une grille de groupe (ou hors-écran) n'était pas repeint
au retour — `visibilitychange` ne couvre que l'onglet — d'où l'écran noir avec
curseur ; et son ACK ne progressait plus (callback xterm.write throttlé) → le
serveur mettait le PTY en pause → sortie « par à-coups ». Ajoute un
IntersectionObserver (repaint + flushAck ciblé au retour-visible) et expose
Attachment.flushAck (factorisé avec flushAcks).
2026-06-24 10:15:29 +02:00
b5236b41c8 feat(settings): réglages Claude CLI (binaire + ~/.claude) & notif « disponible »
- Réglages → Claude CLI : override du chemin du binaire `claude` (effet à la
  prochaine session, fallback `which claude`), diagnostic de détection, et
  override de la racine ~/.claude (effet au redémarrage). Validateurs stricts.
- Push : notifie aussi sur le front busy→idle (session redevenue disponible).
- Renomme l'état affiché idle → « disponible » / « available » (web EN/FR + VS Code) ;
  l'enum SessionActivity du protocole reste inchangé.
2026-06-24 10:15:29 +02:00
2506dfb1f3 fix(cli): figer le PATH d'installation dans l'unit systemd / plist launchd
Un service systemd user / LaunchAgent démarre avec un PATH minimal qui n'inclut
pas ~/.local/bin (où vit le binaire `claude`) → resolveClaudeBin (`which claude`)
échouait avec « Claude Code CLI not found in PATH ». `arboretum install` fige
désormais le PATH de l'environnement d'installation dans l'unit/plist.
2026-06-24 10:15:29 +02:00
21 changed files with 657 additions and 37 deletions

View File

@@ -122,8 +122,13 @@ function quoteIfNeeded(token: string): string {
return /\s/.test(token) ? `"${token}"` : token;
}
export function renderSystemdUnit(input: { exec: string; scriptArgs: string[] }): string {
export function renderSystemdUnit(input: { exec: string; scriptArgs: string[]; pathEnv?: string | undefined }): string {
const execStart = [input.exec, ...input.scriptArgs].map(quoteIfNeeded).join(' ');
// Un service systemd user démarre avec un PATH minimal (typiquement /usr/bin:/bin…) qui n'inclut
// PAS ~/.local/bin ni le bin npm/nvm où vit le CLI `claude` → resolveClaudeBin() (`which claude`)
// échouerait. On fige donc le PATH de l'environnement d'installation, où `claude` est résolvable.
// Doubles quotes systemd : tolèrent un chemin du PATH contenant des espaces.
const pathLine = input.pathEnv ? `\nEnvironment="PATH=${input.pathEnv}"` : '';
// KillSignal=SIGTERM + TimeoutStopSec=10 collent au drain de runDaemon (SIGTERM → drain 1s → close).
return `[Unit]
Description=Arboretum — git worktree & Claude Code dashboard
@@ -136,7 +141,7 @@ Restart=on-failure
RestartSec=5
KillSignal=SIGTERM
TimeoutStopSec=10
Environment=NODE_ENV=production
Environment=NODE_ENV=production${pathLine}
[Install]
WantedBy=default.target
@@ -148,8 +153,14 @@ export function renderLaunchAgentPlist(input: {
programArguments: string[];
stdoutPath: string;
stderrPath: string;
pathEnv?: string | undefined;
}): string {
const args = input.programArguments.map((a) => ` <string>${xmlEscape(a)}</string>`).join('\n');
// launchd démarre un LaunchAgent avec un PATH minimal qui n'inclut pas ~/.local/bin ni le bin
// npm/nvm où vit le CLI `claude` → resolveClaudeBin() échouerait. On fige le PATH d'installation.
const pathEntry = input.pathEnv
? `\n <key>PATH</key>\n <string>${xmlEscape(input.pathEnv)}</string>`
: '';
// KeepAlive/SuccessfulExit=false ≈ Restart=on-failure (ne relance pas après un drain volontaire).
return `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
@@ -175,7 +186,7 @@ ${args}
<key>EnvironmentVariables</key>
<dict>
<key>NODE_ENV</key>
<string>production</string>
<string>production</string>${pathEntry}
</dict>
</dict>
</plist>
@@ -258,9 +269,12 @@ export async function runInstall(argv: string[]): Promise<void> {
const serviceArgs = buildServiceArgs(flags);
const { exec, args: binArgs } = resolveBin(flags);
const scriptArgs = [...binArgs, ...serviceArgs];
// PATH de l'environnement d'installation (shell interactif où `claude` est résolvable) : on le
// fige dans l'unit/plist car systemd/launchd démarrent le service avec un PATH minimal.
const pathEnv = process.env.PATH;
if (platform === 'linux') {
const unit = renderSystemdUnit({ exec, scriptArgs });
const unit = renderSystemdUnit({ exec, scriptArgs, pathEnv });
const unitPath = systemdUnitPath();
if (flags.dryRun) {
console.log(`# ${unitPath}\n${unit}\n# commands:`);
@@ -295,6 +309,7 @@ export async function runInstall(argv: string[]): Promise<void> {
programArguments,
stdoutPath: logs.out,
stderrPath: logs.err,
pathEnv,
});
const plistPath = launchAgentPlistPath(flags.label);
const uid = process.getuid?.() ?? 0;

View File

@@ -11,6 +11,10 @@ export interface Config {
/** origins supplémentaires autorisées (ex. https://machine.tailnet.ts.net) */
allowedOrigins: string[];
printToken: boolean;
/** racine effective de l'install Claude (~/.claude par défaut, --claude-home, ou réglage claude_home). */
claudeHome: string;
/** true si --claude-home a été passé explicitement → a priorité sur le réglage claude_home. */
claudeHomeFromFlag: boolean;
/** ~/.claude/projects (transcripts JSONL) — surchargeable via --claude-home (tests). */
claudeProjectsDir: string;
/** ~/.claude/sessions (registre des sessions CLI vivantes). */
@@ -61,7 +65,8 @@ export function loadConfig(argv = process.argv.slice(2)): Config {
} catch {
/* FS sans permissions POSIX : ignoré, cf. avertissement dans db.openDb */
}
const claudeHome = values['claude-home'] ?? join(homedir(), '.claude');
const claudeHomeFlag = values['claude-home'];
const claudeHome = claudeHomeFlag ?? join(homedir(), '.claude');
return {
port: Number(values.port),
bind,
@@ -69,6 +74,8 @@ export function loadConfig(argv = process.argv.slice(2)): Config {
dataDir,
allowedOrigins: values['allow-origin'] ?? [],
printToken: values['print-token'] ?? false,
claudeHome,
claudeHomeFromFlag: claudeHomeFlag !== undefined,
claudeProjectsDir: join(claudeHome, 'projects'),
claudeSessionsDir: join(claudeHome, 'sessions'),
vapidContact: values['vapid-contact'] ?? 'mailto:arboretum@localhost',

View File

@@ -1,4 +1,5 @@
import { execFileSync } from 'node:child_process';
import { accessSync, constants } from 'node:fs';
export interface SpawnSpec {
file: string;
@@ -12,22 +13,74 @@ export interface SpawnOptions {
resume?: { claudeSessionId: string; fork?: boolean };
/** répertoires supplémentaires à relier dans une seule session (P6) : `--add-dir <path>` répété. */
addDirs?: string[];
/** chemin explicite du binaire `claude` (réglage UI) ; sinon résolution via PATH (`which`). */
claudeBinPath?: string | null;
}
/** Diagnostic de résolution du binaire `claude` (exposé en lecture dans Réglages). */
export interface ClaudeBinDiagnostic {
/** chemin résolu du binaire, ou null si introuvable. */
path: string | null;
/** 'configured' = réglage explicite ; 'path' = trouvé via PATH ; null = introuvable. */
source: 'configured' | 'path' | null;
/** true si le binaire est présent et exécutable. */
ok: boolean;
}
let cachedClaudeBin: string | null = null;
export function resolveClaudeBin(): string {
if (cachedClaudeBin) return cachedClaudeBin;
/** Recherche `claude` dans le PATH (sans throw). null si absent. */
function findClaudeOnPath(): string | null {
try {
cachedClaudeBin = execFileSync('which', ['claude'], { encoding: 'utf8' }).trim();
return execFileSync('which', ['claude'], { encoding: 'utf8' }).trim() || null;
} catch {
return null;
}
}
function isExecutable(path: string): boolean {
try {
accessSync(path, constants.X_OK);
return true;
} catch {
return false;
}
}
/**
* Résout le binaire `claude`. Si `configuredPath` est fourni (réglage UI), il est utilisé tel quel
* — validé exécutable, message clair sinon — et JAMAIS mis en cache (modifiable à chaud). Sinon :
* `which claude`, mis en cache. Un service systemd/launchd démarre avec un PATH minimal sans
* ~/.local/bin → `which claude` y échoue ; d'où le réglage de chemin explicite (et le PATH figé par
* `arboretum install`).
*/
export function resolveClaudeBin(configuredPath?: string | null): string {
if (configuredPath) {
if (!isExecutable(configuredPath)) {
throw new Error(`Configured Claude CLI path is not executable: ${configuredPath}`);
}
return configuredPath;
}
if (cachedClaudeBin) return cachedClaudeBin;
const found = findClaudeOnPath();
if (!found) {
throw new Error(
'Claude Code CLI not found in PATH. Install it first: https://code.claude.com/docs/en/quickstart',
);
}
cachedClaudeBin = found;
return cachedClaudeBin;
}
/** Diagnostic non-throwing pour l'UI (Réglages) : recalculé à chaque appel, jamais caché. */
export function diagnoseClaudeBin(configuredPath?: string | null): ClaudeBinDiagnostic {
if (configuredPath) {
return { path: configuredPath, source: 'configured', ok: isExecutable(configuredPath) };
}
const found = findClaudeOnPath();
return found ? { path: found, source: 'path', ok: true } : { path: null, source: null, ok: false };
}
/** Module volontairement abstrait : le plan B « BYO API key / Agent SDK » se brancherait ici. */
export function buildSpawnSpec(opts: SpawnOptions): SpawnSpec {
const env: NodeJS.ProcessEnv = {
@@ -46,5 +99,5 @@ export function buildSpawnSpec(opts: SpawnOptions): SpawnSpec {
}
// Session de groupe : relie plusieurs repos/worktrees dans une seule session (P6).
for (const dir of opts.addDirs ?? []) args.push('--add-dir', dir);
return { file: resolveClaudeBin(), args, env };
return { file: resolveClaudeBin(opts.claudeBinPath), args, env };
}

View File

@@ -0,0 +1,63 @@
// Réglages liés au CLI Claude, persistés dans la table `settings` (clé/valeur), modifiables via
// l'onglet Réglages. Frontière de sécurité identique à scan-settings : clés NON sensibles, écrites
// uniquement via l'allow-list du PATCH /api/v1/settings et les validateurs ci-dessous.
//
// Motivation : un service systemd/launchd démarre avec un PATH minimal qui n'inclut pas ~/.local/bin
// (où vit le binaire `claude`) → `which claude` échoue. Plutôt que de bricoler le PATH du service,
// l'utilisateur peut pointer explicitement le binaire ici (effet : prochaine session, sans redémarrage).
import { accessSync, constants, statSync } from 'node:fs';
import { getSetting } from '../db/index.js';
import type { Db } from '../db/index.js';
import { isSafeAbsolutePath } from './git.js';
/** Chemin explicite du binaire `claude` (override de la résolution via PATH). */
export const CLAUDE_BIN_PATH_KEY = 'claude_bin_path';
/** Override de la racine ~/.claude (transcripts + registre des sessions). */
export const CLAUDE_HOME_KEY = 'claude_home';
/** Chemin configuré du binaire `claude`, ou null si non défini (⇒ auto-détection via PATH). */
export function readClaudeBinPath(db: Db): string | null {
const raw = getSetting(db, CLAUDE_BIN_PATH_KEY)?.trim();
return raw ? raw : null;
}
/** Racine ~/.claude configurée, ou null si non défini (⇒ défaut/flag). */
export function readClaudeHome(db: Db): string | null {
const raw = getSetting(db, CLAUDE_HOME_KEY)?.trim();
return raw ? raw : null;
}
/**
* Valide un chemin de binaire `claude`. Retourne `''` pour réinitialiser (auto-détection), le chemin
* normalisé s'il est absolu ET pointe vers un fichier exécutable, ou `null` si invalide (⇒ 400).
*/
export function normalizeClaudeBinPath(raw: unknown): string | null {
if (typeof raw !== 'string') return null;
const p = raw.trim();
if (p === '') return '';
if (!isSafeAbsolutePath(p)) return null;
try {
if (!statSync(p).isFile()) return null;
accessSync(p, constants.X_OK);
} catch {
return null;
}
return p;
}
/**
* Valide une racine ~/.claude. Retourne `''` pour réinitialiser (défaut), le chemin normalisé s'il
* est absolu ET pointe vers un répertoire existant, ou `null` si invalide (⇒ 400).
*/
export function normalizeClaudeHome(raw: unknown): string | null {
if (typeof raw !== 'string') return null;
const p = raw.trim();
if (p === '') return '';
if (!isSafeAbsolutePath(p)) return null;
try {
if (!statSync(p).isDirectory()) return null;
} catch {
return null;
}
return p;
}

View File

@@ -7,6 +7,7 @@ import pty from '@homebridge/node-pty-prebuilt-multiarch';
import { FLOW, REPLAY_TAIL_BYTES, type SessionActivity, type SessionSummary } from '@arboretum/shared';
import { RingBuffer } from './ring-buffer.js';
import { buildSpawnSpec } from './claude-launcher.js';
import { readClaudeBinPath } from './claude-settings.js';
import { findByPid } from './session-registry.js';
import { SessionActivityTracker } from './claude-adapter.js';
import type { PushService } from './push-service.js';
@@ -111,8 +112,11 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
}
// Un resume/fork est toujours une session claude (le cwd d'origine est garanti par l'appelant — S1).
const command = opts.resume ? 'claude' : (opts.command ?? 'claude');
// Override de chemin du binaire claude (réglage UI) lu à chaque spawn → effet sans redémarrage.
const claudeBinPath = command === 'claude' ? readClaudeBinPath(this.db) : null;
const spec = buildSpawnSpec({
command,
...(claudeBinPath ? { claudeBinPath } : {}),
...(opts.resume ? { resume: opts.resume } : {}),
...(addedDirs.length ? { addDirs: addedDirs } : {}),
});
@@ -403,30 +407,35 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
// ---- interne ----
/**
* Push P4-B : notifie sur le FRONT MONTANT vers `waiting` uniquement (le tracker réémet
* souvent le même état), avec un debounce annulable — un `waiting` ultra-bref (Claude répond
* tout seul) ne déclenche pas de notif. Cible tous les abonnements (un seul utilisateur).
* Push P4-B : notifie sur le FRONT MONTANT vers un état qui requiert l'attention — `waiting`
* (un dialogue bloque) ou `idle` atteint depuis `busy` (Claude a terminé sa réponse, la session
* redevient disponible). Le tracker réémet souvent le même état → on ne réagit qu'au changement.
* Debounce annulable : un état ultra-bref (Claude repart tout seul) ne déclenche pas de notif —
* à l'échéance on revérifie l'état réel. Cible tous les abonnements (un seul utilisateur).
*/
private maybeNotify(s: ManagedSession, next: SessionActivity | null): void {
const prev = s.prevActivity;
s.prevActivity = next;
if (!this.push) return;
if (next === 'waiting' && prev !== 'waiting') {
const notifiable =
(next === 'waiting' && prev !== 'waiting') || (next === 'idle' && prev === 'busy');
if (notifiable) {
const target = next; // 'waiting' | 'idle'
if (s.notifyTimer) clearTimeout(s.notifyTimer);
s.notifyTimer = setTimeout(() => {
s.notifyTimer = null;
const act = s.tracker?.snapshot();
if (s.exited || act?.activity !== 'waiting') return; // faux positif : annulé
void this.push?.notify({
sessionId: s.id,
title: basename(s.cwd) || s.cwd,
body: act.dialog?.waitingFor ?? act.waitingFor ?? 'waiting for your input',
kind: act.dialog?.kind ?? null,
url: `/sessions/${s.id}`,
});
if (s.exited || act?.activity !== target) return; // faux positif : l'état a déjà changé
const base = { sessionId: s.id, title: basename(s.cwd) || s.cwd, url: `/sessions/${s.id}` };
void this.push?.notify(
target === 'idle'
? { ...base, body: 'available for new instructions', kind: null }
: { ...base, body: act.dialog?.waitingFor ?? act.waitingFor ?? 'waiting for your input', kind: act.dialog?.kind ?? null },
);
}, NOTIFY_DEBOUNCE_MS);
s.notifyTimer.unref();
} else if (next !== 'waiting' && s.notifyTimer) {
} else if (s.notifyTimer && next !== 'waiting' && next !== 'idle') {
// On quitte un état notifiable avant l'échéance (ex. Claude repart en busy) → annule.
clearTimeout(s.notifyTimer);
s.notifyTimer = null;
}

View File

@@ -3,17 +3,33 @@ import { readFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { loadConfig, type Config } from './config.js';
import { openDb } from './db/index.js';
import { openDb, type Db } from './db/index.js';
import { buildApp } from './app.js';
import { readClaudeHome } from './core/claude-settings.js';
import { runInstall, runUninstall, runStatus, printUsage, printTokenBanner } from './cli/install.js';
const pkg = JSON.parse(
readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf8'),
) as { version: string };
/**
* Applique l'override `claude_home` (réglage UI) sur la config runtime, sauf si --claude-home a été
* passé explicitement (le flag gagne). Lu une seule fois au démarrage → un changement via l'UI prend
* effet au redémarrage du daemon (comme l'intervalle de scan). Mute `config`.
*/
function applyClaudeHomeOverride(config: Config, db: Db): void {
if (config.claudeHomeFromFlag) return;
const override = readClaudeHome(db);
if (!override) return;
config.claudeHome = override;
config.claudeProjectsDir = join(override, 'projects');
config.claudeSessionsDir = join(override, 'sessions');
}
/** Démarre le daemon : écoute HTTP, scan des sessions, drain propre au SIGTERM/SIGINT. */
export async function runDaemon(config: Config): Promise<void> {
const db = openDb(config.dbPath);
applyClaudeHomeOverride(config, db); // override claude_home (réglage UI) avant de câbler les services
const { app, auth, manager, discovery, repoDiscovery } = buildApp(config, db, pkg.version);
const bootstrapToken = auth.ensureBootstrapToken();

View File

@@ -7,6 +7,7 @@ import type { Config } from '../config.js';
import { type Db, setSetting } from '../db/index.js';
import type { PushService } from '../core/push-service.js';
import { recordAudit } from '../core/audit-log.js';
import { diagnoseClaudeBin } from '../core/claude-launcher.js';
import {
SCAN_INTERVAL_KEY,
SCAN_ROOTS_KEY,
@@ -15,6 +16,14 @@ import {
readScanIntervalMin,
readScanRoots,
} from '../core/scan-settings.js';
import {
CLAUDE_BIN_PATH_KEY,
CLAUDE_HOME_KEY,
normalizeClaudeBinPath,
normalizeClaudeHome,
readClaudeBinPath,
readClaudeHome,
} from '../core/claude-settings.js';
// Réglages modifiables via l'API (allow-list stricte). Les secrets ne figurent JAMAIS ici.
export function registerSettingsRoutes(
@@ -32,11 +41,16 @@ export function registerSettingsRoutes(
dataDir: config.dataDir,
vapidPublicKey: push.publicKey() || null,
vapidContact: config.vapidContact,
claudeHome: config.claudeHome,
// Diagnostic recalculé à chaque GET : reflète l'état réel (override exécutable ? `which claude` ?).
claudeBin: diagnoseClaudeBin(readClaudeBinPath(db)),
});
const snapshot = (): SettingsResponse => ({
settings: {
scanRoots: readScanRoots(db),
scanIntervalMin: readScanIntervalMin(db),
claudeBinPath: readClaudeBinPath(db),
claudeHome: readClaudeHome(db),
},
server: serverInfo(),
});
@@ -59,6 +73,20 @@ export function registerSettingsRoutes(
}
setSetting(db, SCAN_INTERVAL_KEY, String(interval));
}
if ('claudeBinPath' in body) {
const value = normalizeClaudeBinPath(body.claudeBinPath);
if (value === null) {
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'claudeBinPath must be an absolute path to an executable file (or "" to reset)' } });
}
setSetting(db, CLAUDE_BIN_PATH_KEY, value);
}
if ('claudeHome' in body) {
const value = normalizeClaudeHome(body.claudeHome);
if (value === null) {
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'claudeHome must be an absolute path to an existing directory (or "" to reset)' } });
}
setSetting(db, CLAUDE_HOME_KEY, value);
}
recordAudit(db, {
actor: req.authContext?.tokenId ?? 'unknown',
action: 'settings.update',

View File

@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest';
import { buildSpawnSpec } from '../src/core/claude-launcher.js';
import { buildSpawnSpec, diagnoseClaudeBin, resolveClaudeBin } 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' }));
@@ -27,3 +27,28 @@ describe('buildSpawnSpec — session de groupe multi-repo (P6)', () => {
expect(spec.args).toEqual(['--norc']);
});
});
describe('resolveClaudeBin / diagnoseClaudeBin — override de chemin (réglage UI)', () => {
// process.execPath (node) est un fichier exécutable réel garanti sur la machine de test.
const realBin = process.execPath;
it('buildSpawnSpec utilise le chemin configuré tel quel quand fourni', () => {
const spec = buildSpawnSpec({ command: 'claude', claudeBinPath: realBin });
expect(spec.file).toBe(realBin);
});
it('resolveClaudeBin retombe sur le PATH (which) sans override', () => {
expect(resolveClaudeBin()).toBe('/usr/bin/claude');
expect(resolveClaudeBin(null)).toBe('/usr/bin/claude');
});
it('resolveClaudeBin throw si le chemin configuré nest pas exécutable', () => {
expect(() => resolveClaudeBin('/no/such/claude')).toThrow(/not executable/);
});
it('diagnoseClaudeBin : configuré+exécutable → ok ; configuré absent → !ok ; sinon PATH', () => {
expect(diagnoseClaudeBin(realBin)).toEqual({ path: realBin, source: 'configured', ok: true });
expect(diagnoseClaudeBin('/no/such/claude')).toEqual({ path: '/no/such/claude', source: 'configured', ok: false });
expect(diagnoseClaudeBin()).toEqual({ path: '/usr/bin/claude', source: 'path', ok: true });
});
});

View File

@@ -0,0 +1,89 @@
// Réglages CLI Claude (chemin du binaire + override ~/.claude) : validateurs + lecture/écriture DB.
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { openDb, setSetting, type Db } from '../src/db/index.js';
import {
CLAUDE_BIN_PATH_KEY,
CLAUDE_HOME_KEY,
normalizeClaudeBinPath,
normalizeClaudeHome,
readClaudeBinPath,
readClaudeHome,
} from '../src/core/claude-settings.js';
let dir: string;
let execFile: string;
let plainFile: string;
let subDir: string;
beforeAll(() => {
dir = mkdtempSync(join(tmpdir(), 'arboretum-claude-settings-'));
execFile = join(dir, 'claude');
writeFileSync(execFile, '#!/bin/sh\n');
chmodSync(execFile, 0o755); // exécutable
plainFile = join(dir, 'notexec');
writeFileSync(plainFile, 'x');
chmodSync(plainFile, 0o644); // non exécutable
subDir = join(dir, 'home');
mkdirSync(subDir);
});
afterAll(() => rmSync(dir, { recursive: true, force: true }));
describe('normalizeClaudeBinPath', () => {
it('accepte un chemin absolu vers un fichier exécutable', () => {
expect(normalizeClaudeBinPath(execFile)).toBe(execFile);
});
it('"" = réinitialisation (auto-détection)', () => {
expect(normalizeClaudeBinPath('')).toBe('');
expect(normalizeClaudeBinPath(' ')).toBe('');
});
it('rejette un fichier non exécutable, un répertoire, un chemin relatif ou avec ".." (null)', () => {
expect(normalizeClaudeBinPath(plainFile)).toBeNull();
expect(normalizeClaudeBinPath(subDir)).toBeNull(); // répertoire, pas un fichier
expect(normalizeClaudeBinPath('relative/claude')).toBeNull();
expect(normalizeClaudeBinPath(join(dir, '..', 'x'))).toBeNull();
expect(normalizeClaudeBinPath(join(dir, 'missing'))).toBeNull();
expect(normalizeClaudeBinPath(42)).toBeNull();
});
});
describe('normalizeClaudeHome', () => {
it('accepte un chemin absolu vers un répertoire existant', () => {
expect(normalizeClaudeHome(subDir)).toBe(subDir);
});
it('"" = réinitialisation (défaut)', () => {
expect(normalizeClaudeHome('')).toBe('');
});
it('rejette un fichier, un chemin relatif/inexistant (null)', () => {
expect(normalizeClaudeHome(execFile)).toBeNull(); // fichier, pas un répertoire
expect(normalizeClaudeHome('relative/.claude')).toBeNull();
expect(normalizeClaudeHome(join(dir, 'missing'))).toBeNull();
expect(normalizeClaudeHome(null)).toBeNull();
});
});
describe('readClaudeBinPath / readClaudeHome', () => {
let db: Db;
beforeAll(() => {
db = openDb(join(dir, 'settings.db'));
});
afterAll(() => db.close());
it('null quand non défini', () => {
expect(readClaudeBinPath(db)).toBeNull();
expect(readClaudeHome(db)).toBeNull();
});
it('lit la valeur stockée et traite "" stockée comme null', () => {
setSetting(db, CLAUDE_BIN_PATH_KEY, execFile);
setSetting(db, CLAUDE_HOME_KEY, subDir);
expect(readClaudeBinPath(db)).toBe(execFile);
expect(readClaudeHome(db)).toBe(subDir);
setSetting(db, CLAUDE_BIN_PATH_KEY, ''); // réinitialisé
setSetting(db, CLAUDE_HOME_KEY, '');
expect(readClaudeBinPath(db)).toBeNull();
expect(readClaudeHome(db)).toBeNull();
});
});

View File

@@ -79,6 +79,21 @@ describe('cli install — renderSystemdUnit', () => {
expect(unit).toContain('ExecStart="/path with space/node" /s.js');
});
it('fige le PATH d\'installation quand fourni (le service systemd a un PATH minimal)', () => {
const unit = renderSystemdUnit({
exec: '/usr/bin/node',
scriptArgs: ['/s.js'],
pathEnv: '/home/me/.local/bin:/usr/bin',
});
// Doubles quotes systemd : un chemin du PATH peut contenir un espace.
expect(unit).toContain('Environment="PATH=/home/me/.local/bin:/usr/bin"');
});
it("n'émet aucune ligne PATH sans pathEnv (rétrocompat)", () => {
const unit = renderSystemdUnit({ exec: '/usr/bin/node', scriptArgs: ['/s.js'] });
expect(unit).not.toContain('PATH=');
});
it('snapshot du unit pour un jeu de flags fixe', () => {
const unit = renderSystemdUnit({
exec: '/usr/bin/node',
@@ -133,6 +148,19 @@ describe('cli install — renderLaunchAgentPlist', () => {
expect(plist).toContain('https://a?b&amp;c=d');
expect(plist).not.toContain('b&c=d');
});
it('ajoute la clé PATH dans EnvironmentVariables quand fournie (launchd a un PATH minimal)', () => {
const plist = renderLaunchAgentPlist({ ...base, pathEnv: '/Users/me/.local/bin:/usr/bin' });
expect(plist).toContain('<key>PATH</key>');
expect(plist).toContain('<string>/Users/me/.local/bin:/usr/bin</string>');
// Reste dans le dict EnvironmentVariables, juste après NODE_ENV.
expect(plist.indexOf('<key>PATH</key>')).toBeGreaterThan(plist.indexOf('<key>NODE_ENV</key>'));
});
it("n'ajoute aucune clé PATH sans pathEnv (rétrocompat)", () => {
const plist = renderLaunchAgentPlist(base);
expect(plist).not.toContain('<key>PATH</key>');
});
});
describe('cli install — xmlEscape', () => {

View File

@@ -414,6 +414,44 @@ describe('PtyManager (pty mocké)', () => {
rmSync(sessDir, { recursive: true, force: true });
}
});
it('une notif sur le front montant busy→idle (Claude a terminé → disponible), après le debounce', async () => {
const sessDir = mkdtempSync(join(tmpdir(), 'arb-push-idle-'));
const notifies: PushPayload[] = [];
const fakePush = {
notify: async (p: PushPayload) => {
notifies.push(p);
},
} as unknown as PushService;
const m = new PtyManager(db, sessDir, fakePush);
try {
const summary = m.spawn({ cwd, command: 'claude' });
const p = lastPty();
const regFile = join(sessDir, `${p.pid}.json`);
const writeReg = (status: string): void =>
writeFileSync(regFile, JSON.stringify({ pid: p.pid, procStart: '1', sessionId: 'sid', cwd, status }));
// busy d'abord : front montant vers busy, pas de notif
writeReg('busy');
p.emitData('working…');
await sleep(260);
expect(notifies).toHaveLength(0);
// idle : Claude a terminé → front busy→idle → planifie la notif (debounce 1500ms)
writeReg('idle');
p.emitData('\x1b[2J\x1b[HDone.\r\n');
await sleep(260);
expect(notifies).toHaveLength(0); // pas encore : debounce en cours
await sleep(1500); // dépasse le debounce
expect(notifies).toHaveLength(1);
expect(notifies[0]).toMatchObject({ sessionId: summary.id, kind: null, url: `/sessions/${summary.id}` });
expect(notifies[0].body).toMatch(/available/i);
} finally {
m.shutdown();
rmSync(sessDir, { recursive: true, force: true });
}
});
});
describe('flow control', () => {

View File

@@ -48,6 +48,8 @@ beforeAll(() => {
dataDir: dir,
allowedOrigins: ['https://host.tailnet.ts.net'],
printToken: false,
claudeHome: join(dir, 'claude'),
claudeHomeFromFlag: false,
claudeProjectsDir: join(dir, 'claude', 'projects'),
claudeSessionsDir: join(dir, 'claude', 'sessions'),
vapidContact: 'mailto:test@localhost',
@@ -77,6 +79,11 @@ describe('GET /api/v1/settings', () => {
// défauts de découverte : AUCUNE racine (clean install → pas de scan) + intervalle 5 min
expect(body.settings.scanRoots).toEqual([]);
expect(body.settings.scanIntervalMin).toBe(5);
// Claude CLI : aucun override par défaut + diagnostic via PATH (execFileSync mocké → /usr/bin/claude).
expect(body.settings.claudeBinPath).toBeNull();
expect(body.settings.claudeHome).toBeNull();
expect(body.server.claudeHome).toBe(join(dir, 'claude'));
expect(body.server.claudeBin).toEqual({ path: '/usr/bin/claude', source: 'path', ok: true });
});
it('nexpose AUCUN secret (server_secret, clé privée VAPID)', async () => {
@@ -137,3 +144,46 @@ describe('PATCH /api/v1/settings — découverte des dépôts', () => {
expect(neg.statusCode).toBe(400);
});
});
describe('PATCH /api/v1/settings — Claude CLI', () => {
// process.execPath (node) est un fichier exécutable réel garanti sur la machine de test.
const realBin = process.execPath;
it('enregistre un chemin de binaire exécutable et reflète le diagnostic (source=configured)', async () => {
const res = await bundle.app.inject({ method: 'PATCH', url: '/api/v1/settings', headers: auth(), payload: { claudeBinPath: realBin } });
expect(res.statusCode).toBe(200);
const body = res.json() as SettingsResponse;
expect(body.settings.claudeBinPath).toBe(realBin);
expect(body.server.claudeBin).toEqual({ path: realBin, source: 'configured', ok: true });
});
it('réinitialise le chemin avec "" (retour à lauto-détection via PATH)', async () => {
await bundle.app.inject({ method: 'PATCH', url: '/api/v1/settings', headers: auth(), payload: { claudeBinPath: realBin } });
const res = await bundle.app.inject({ method: 'PATCH', url: '/api/v1/settings', headers: auth(), payload: { claudeBinPath: '' } });
const body = res.json() as SettingsResponse;
expect(body.settings.claudeBinPath).toBeNull();
expect(body.server.claudeBin.source).toBe('path'); // de nouveau via PATH (mock → /usr/bin/claude)
});
it('rejette un chemin non absolu ou non exécutable (400)', async () => {
for (const claudeBinPath of ['relative/claude', '/a/../b', dir /* répertoire, pas un fichier */]) {
const res = await bundle.app.inject({ method: 'PATCH', url: '/api/v1/settings', headers: auth(), payload: { claudeBinPath } });
expect(res.statusCode).toBe(400);
}
});
it('enregistre un override claude_home (répertoire existant) et le réinitialise avec ""', async () => {
const set = await bundle.app.inject({ method: 'PATCH', url: '/api/v1/settings', headers: auth(), payload: { claudeHome: dir } });
expect(set.statusCode).toBe(200);
expect((set.json() as SettingsResponse).settings.claudeHome).toBe(dir);
const reset = await bundle.app.inject({ method: 'PATCH', url: '/api/v1/settings', headers: auth(), payload: { claudeHome: '' } });
expect((reset.json() as SettingsResponse).settings.claudeHome).toBeNull();
});
it('rejette un claude_home non absolu ou inexistant/fichier (400)', async () => {
for (const claudeHome of ['relative/.claude', join(dir, 'settings.db') /* fichier */, join(dir, 'nope')]) {
const res = await bundle.app.inject({ method: 'PATCH', url: '/api/v1/settings', headers: auth(), payload: { claudeHome } });
expect(res.statusCode).toBe(400);
}
});
});

View File

@@ -296,6 +296,19 @@ export interface ServerInfo {
/** clé publique VAPID (sûre à exposer) ; null si push indisponible. */
vapidPublicKey: string | null;
vapidContact: string;
/** racine effective de l'install Claude (~/.claude, --claude-home, ou réglage claude_home). */
claudeHome: string;
/** diagnostic de résolution du binaire `claude` (présence + exécutabilité). */
claudeBin: ClaudeBinDiagnostic;
}
/** Diagnostic de résolution du CLI `claude` exposé en lecture dans les Réglages. */
export interface ClaudeBinDiagnostic {
/** chemin résolu du binaire, ou null si introuvable. */
path: string | null;
/** 'configured' = réglage explicite ; 'path' = trouvé via PATH ; null = introuvable. */
source: 'configured' | 'path' | null;
/** true si le binaire est présent et exécutable. */
ok: boolean;
}
export interface SettingsResponse {
/** réglages modifiables à chaud (allow-list serveur — jamais les secrets). */
@@ -304,6 +317,10 @@ export interface SettingsResponse {
scanRoots: string[];
/** intervalle du re-scan périodique en minutes ; 0 = périodique désactivé. */
scanIntervalMin: number;
/** chemin explicite du binaire `claude` ; null = auto-détection via PATH. Effet : prochaine session. */
claudeBinPath: string | null;
/** override de la racine ~/.claude ; null = défaut. Effet : au redémarrage du daemon. */
claudeHome: string | null;
};
server: ServerInfo;
}
@@ -312,6 +329,10 @@ export interface UpdateSettingsRequest {
scanRoots?: string[];
/** intervalle du re-scan périodique en minutes (01440 ; 0 désactive). */
scanIntervalMin?: number;
/** chemin absolu du binaire `claude` (fichier exécutable) ; '' pour réinitialiser à l'auto-détection. */
claudeBinPath?: string;
/** override de la racine ~/.claude (répertoire existant) ; '' pour réinitialiser au défaut. */
claudeHome?: string;
}
// ---- Journal d'audit (conformité entreprise) ----

View File

@@ -69,7 +69,8 @@ function sessionIcon(s: SessionSummary): vscode.ThemeIcon {
function sessionDescription(s: SessionSummary): string {
const parts: string[] = [];
if (s.live && s.activity) parts.push(s.activity);
// Affiche 'available' pour idle (cohérent avec le libellé web) ; l'enum reste 'idle' côté protocole.
if (s.live && s.activity) parts.push(s.activity === 'idle' ? 'available' : s.activity);
else if (!s.live) parts.push('exited');
if (s.source === 'discovered') parts.push('external');
if (s.groupId) parts.push('group');

View File

@@ -38,6 +38,7 @@ const ended = ref(false);
let term: Terminal | null = null;
let attachment: Attachment | null = null;
let resizeObserver: ResizeObserver | null = null;
let intersectionObserver: IntersectionObserver | null = null;
let onVisible: (() => void) | null = null;
let disposed = false;
@@ -130,6 +131,20 @@ onMounted(async () => {
activeTerm.onData((data) => attachment?.sendStdin(data));
resizeObserver = new ResizeObserver(refit);
resizeObserver.observe(container.value);
// Re-révélation RÉELLE du conteneur : cellule de grille démontée/remontée (maximize↔grille), scroll
// hors/dans le viewport, ou navigation SPA — autant de cas que `visibilitychange` (onglet only) ne
// couvre PAS. Au retour visible, on repeint (le rendu était gelé hors-écran) ET on flushe l'ACK pour
// sortir d'une éventuelle pause serveur (le callback de xterm.write était throttlé tant que caché).
intersectionObserver = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
// évite un fit() sur conteneur de taille 0 (remontage transitoire) → déforme le TUI
if (entry.intersectionRect.width === 0 || entry.intersectionRect.height === 0) continue;
refit();
attachment?.flushAck();
}
});
intersectionObserver.observe(container.value);
// au retour en avant-plan, forcer un repaint (le renderer WebGL peut avoir « gelé » l'écran pendant
// que l'onglet était caché) ; le ws-client flushe en parallèle les ACK pour relancer le flux.
onVisible = (): void => {
@@ -142,6 +157,7 @@ onMounted(async () => {
onBeforeUnmount(() => {
disposed = true;
resizeObserver?.disconnect();
intersectionObserver?.disconnect();
if (onVisible) document.removeEventListener('visibilitychange', onVisible);
attachment?.detach();
term?.dispose();

View File

@@ -56,7 +56,7 @@ export default {
},
registryStatus: {
busy: 'busy',
idle: 'idle',
idle: 'available',
waiting: 'waiting',
},
dialog: {
@@ -283,7 +283,7 @@ export default {
live: 'Live',
waiting: 'Waiting',
busy: 'Busy',
idle: 'Idle',
idle: 'Available',
exited: 'Exited',
resumable: 'Resumable',
},
@@ -413,6 +413,20 @@ export default {
removeRoot: 'Remove',
scanInterval: 'Re-scan interval (minutes)',
scanIntervalHint: '0 disables periodic scanning. Changes apply on the next daemon restart.',
// Claude CLI
claudeCli: 'Claude CLI',
claudeCliHint: 'Where Arboretum finds the `claude` binary and its data. Handy when the daemon runs as a service with a minimal PATH that does not include ~/.local/bin.',
claudeBinStatusOk: 'Detected',
claudeBinStatusNotFound: 'Not found — sessions cannot start until this is resolved.',
claudeBinStatusBadPath: 'The configured path is not an executable file.',
claudeBinSourceConfigured: 'configured',
claudeBinSourcePath: 'PATH',
claudeBinPathLabel: 'Binary path override',
claudeBinPathHint: 'Absolute path to the `claude` executable. Leave empty to auto-detect via PATH. Takes effect on the next session.',
claudeBinPathPlaceholder: 'e.g. /home/you/.local/bin/claude',
claudeHomeLabel: 'Claude home directory',
claudeHomeHint: 'Override the ~/.claude location (transcripts & session registry). Applies on the next daemon restart.',
claudeHomePlaceholder: 'e.g. /home/you/.claude',
// Serveur (lecture seule)
server: 'Server',
serverHint: 'Set at startup via CLI flags — changing them requires restarting the daemon.',

View File

@@ -58,7 +58,7 @@ const fr: typeof en = {
},
registryStatus: {
busy: 'occupée',
idle: 'inactive',
idle: 'disponible',
waiting: 'en attente',
},
dialog: {
@@ -286,7 +286,7 @@ const fr: typeof en = {
live: 'Active',
waiting: 'En attente',
busy: 'Occupée',
idle: 'Au repos',
idle: 'Disponible',
exited: 'Terminée',
resumable: 'Reprenable',
},
@@ -416,6 +416,20 @@ const fr: typeof en = {
removeRoot: 'Retirer',
scanInterval: 'Intervalle de re-scan (minutes)',
scanIntervalHint: '0 désactive le scan périodique. Les changements prennent effet au prochain redémarrage du daemon.',
// Claude CLI
claudeCli: 'CLI Claude',
claudeCliHint: 'Où Arboretum trouve le binaire `claude` et ses données. Utile quand le daemon tourne en service avec un PATH minimal qui ninclut pas ~/.local/bin.',
claudeBinStatusOk: 'Détecté',
claudeBinStatusNotFound: 'Introuvable — impossible de lancer une session tant que ce nest pas résolu.',
claudeBinStatusBadPath: 'Le chemin configuré nest pas un fichier exécutable.',
claudeBinSourceConfigured: 'configuré',
claudeBinSourcePath: 'PATH',
claudeBinPathLabel: 'Chemin du binaire (override)',
claudeBinPathHint: 'Chemin absolu vers lexécutable `claude`. Laisser vide pour lauto-détection via le PATH. Prend effet à la prochaine session.',
claudeBinPathPlaceholder: 'ex. /home/vous/.local/bin/claude',
claudeHomeLabel: 'Répertoire ~/.claude',
claudeHomeHint: 'Surcharge lemplacement de ~/.claude (transcripts & registre des sessions). Prend effet au redémarrage du daemon.',
claudeHomePlaceholder: 'ex. /home/vous/.claude',
// Serveur (lecture seule)
server: 'Serveur',
serverHint: 'Définis au démarrage via des flags CLI — les modifier nécessite de redémarrer le daemon.',

View File

@@ -100,6 +100,12 @@ export class Attachment {
this.pending = null;
this.client.releaseAttachment(this);
}
/** Flush ACK ciblé : relance immédiatement le flux si le serveur avait mis ce PTY en pause
* (ex. retour-visible de la cellule, dont le rendu était gelé hors-écran). N'ACK que du traité. */
flushAck(): void {
this.client.flushAck(this);
}
}
export class WsClient {
@@ -357,15 +363,25 @@ export class WsClient {
/** ACK immédiat du reliquat traité pour tous les terminaux ouverts — au retour en avant-plan,
* pour rattraper sans attendre le débounce un serveur éventuellement en pause. */
private flushAcks(): void {
for (const att of this.attachments) {
if (att.closed || att.channel < 0) continue;
if (att.processedBytes > att.lastAckBytes) {
att.lastAckBytes = att.processedBytes;
this.sendControl({ type: 'ack', channel: att.channel, bytes: att.processedBytes });
}
for (const att of this.attachments) this.flushAttachmentAck(att);
}
/** ACK immédiat du reliquat traité d'UN attachment — n'ACK que du réellement traité (invariant
* préservé). Factorisé : utilisé par flushAcks() et par le flush ciblé flushAck(). */
private flushAttachmentAck(att: Attachment): void {
if (att.closed || att.channel < 0) return;
if (att.processedBytes > att.lastAckBytes) {
att.lastAckBytes = att.processedBytes;
this.sendControl({ type: 'ack', channel: att.channel, bytes: att.processedBytes });
}
}
/** Flush ACK ciblé d'un seul attachment : relance un PTY mis en pause par le serveur quand la
* cellule était cachée (cf. Attachment.flushAck, appelé au retour-visible). */
flushAck(att: Attachment): void {
this.flushAttachmentAck(att);
}
private handleServerMessage(msg: ServerMessage): void {
switch (msg.type) {
case 'hello_ok': {

View File

@@ -9,6 +9,8 @@ export const useSettingsStore = defineStore('settings', () => {
const server = ref<ServerInfo | null>(null);
const scanRoots = ref<string[]>([]);
const scanIntervalMin = ref(0);
const claudeBinPath = ref<string | null>(null);
const claudeHome = ref<string | null>(null);
const loaded = ref(false);
const saving = ref(false);
@@ -16,6 +18,8 @@ export const useSettingsStore = defineStore('settings', () => {
server.value = res.server;
scanRoots.value = res.settings.scanRoots;
scanIntervalMin.value = res.settings.scanIntervalMin;
claudeBinPath.value = res.settings.claudeBinPath;
claudeHome.value = res.settings.claudeHome;
loaded.value = true;
}
@@ -32,5 +36,5 @@ export const useSettingsStore = defineStore('settings', () => {
}
}
return { server, scanRoots, scanIntervalMin, loaded, saving, fetch, save };
return { server, scanRoots, scanIntervalMin, claudeBinPath, claudeHome, loaded, saving, fetch, save };
});

View File

@@ -137,6 +137,44 @@
</div>
</section>
<!-- Claude CLI -->
<section class="card flex flex-col gap-3">
<h2 class="flex items-center gap-2 text-sm font-semibold text-zinc-100">
<Terminal :size="16" /> {{ t('settings.claudeCli') }}
</h2>
<p class="text-xs text-zinc-500">{{ t('settings.claudeCliHint') }}</p>
<!-- Diagnostic de détection -->
<div v-if="settings.server" class="card-inset flex flex-wrap items-center gap-x-2 gap-y-1 text-xs">
<component :is="claudeBin.ok ? CheckCircle2 : AlertCircle" :size="15" :class="claudeBin.ok ? 'text-emerald-400' : 'text-rose-400'" />
<span :class="claudeBin.ok ? 'text-zinc-300' : 'text-rose-300'">
{{ claudeBin.ok ? t('settings.claudeBinStatusOk') : (claudeBin.source === 'configured' ? t('settings.claudeBinStatusBadPath') : t('settings.claudeBinStatusNotFound')) }}
</span>
<code v-if="claudeBin.path" class="min-w-0 truncate font-mono text-zinc-200" :title="claudeBin.path">{{ claudeBin.path }}</code>
<span v-if="claudeBin.source" class="badge bg-zinc-700/40 text-zinc-300">
{{ claudeBin.source === 'configured' ? t('settings.claudeBinSourceConfigured') : t('settings.claudeBinSourcePath') }}
</span>
</div>
<label class="flex flex-col gap-1">
<span class="text-xs text-zinc-400">{{ t('settings.claudeBinPathLabel') }}</span>
<input v-model.trim="claudeBinDraft" class="input font-mono" :placeholder="t('settings.claudeBinPathPlaceholder')" />
<span class="text-xs text-zinc-500">{{ t('settings.claudeBinPathHint') }}</span>
</label>
<label class="flex flex-col gap-1">
<span class="text-xs text-zinc-400">{{ t('settings.claudeHomeLabel') }}</span>
<input v-model.trim="claudeHomeDraft" class="input font-mono" :placeholder="t('settings.claudeHomePlaceholder')" />
<span class="text-xs text-zinc-500">{{ t('settings.claudeHomeHint') }}</span>
</label>
<div>
<BaseButton variant="primary" :loading="settings.saving" :disabled="!claudeDirty" @click="saveClaude">
{{ t('settings.save') }}
</BaseButton>
</div>
</section>
<!-- Sécurité & conformité -->
<section class="card flex flex-col gap-3">
<h2 class="flex items-center gap-2 text-sm font-semibold text-zinc-100">
@@ -202,7 +240,7 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { Bell, BellOff, Check, Coffee, Copy, Download, FolderOpen, KeyRound, Plus, RefreshCw, ScanSearch, Send, Server, Shield, SlidersHorizontal, Trash2, X } from '@lucide/vue';
import { AlertCircle, Bell, BellOff, Check, CheckCircle2, Coffee, Copy, Download, FolderOpen, KeyRound, Plus, RefreshCw, ScanSearch, Send, Server, Shield, SlidersHorizontal, Terminal, Trash2, X } from '@lucide/vue';
import type {
AuditLogEntry,
AuditLogsResponse,
@@ -328,6 +366,27 @@ async function saveDiscovery(): Promise<void> {
}
}
// ---- Claude CLI ----
const claudeBinDraft = ref('');
const claudeHomeDraft = ref('');
const claudeBin = computed(() => settings.server?.claudeBin ?? { path: null, source: null, ok: false });
const claudeDirty = computed(
() => claudeBinDraft.value !== (settings.claudeBinPath ?? '') || claudeHomeDraft.value !== (settings.claudeHome ?? ''),
);
function syncClaudeDraft(): void {
claudeBinDraft.value = settings.claudeBinPath ?? '';
claudeHomeDraft.value = settings.claudeHome ?? '';
}
async function saveClaude(): Promise<void> {
try {
await settings.save({ claudeBinPath: claudeBinDraft.value, claudeHome: claudeHomeDraft.value });
syncClaudeDraft();
toasts.success(t('settings.saved'));
} catch (e) {
toasts.error(e);
}
}
// ---- Sécurité & conformité (audit / RGPD) ----
const audit = ref<AuditLogEntry[]>([]);
const auditing = ref(false);
@@ -378,6 +437,7 @@ async function deleteMyData(): Promise<void> {
onMounted(async () => {
await Promise.all([loadTokens(), settings.loaded ? Promise.resolve() : settings.fetch()]);
syncDiscoveryDraft();
syncClaudeDraft();
void push.refresh();
});
</script>

View File

@@ -0,0 +1,53 @@
// Flush ACK ciblé (Attachment.flushAck) : relance un PTY mis en pause au retour-visible d'une cellule.
// On teste la logique d'ACK sans WebSocket réel (sendControl est public → stubbable).
import { describe, it, expect, vi } from 'vitest';
import { WsClient, Attachment, type TerminalSink } from '../src/lib/ws-client';
const sink: TerminalSink = {
write: () => {},
reset: () => {},
onDetached: () => {},
onControlChanged: () => {},
};
describe('ws-client — flush ACK ciblé (Attachment.flushAck)', () => {
it('envoie un unique ACK cumulatif du reliquat traité, puis est idempotent', () => {
const client = new WsClient();
const send = vi.spyOn(client, 'sendControl').mockImplementation(() => {});
const att = new Attachment(client, 'sid', 'interactive', sink, 80, 24);
att.channel = 2;
att.processedBytes = 1000;
att.flushAck();
expect(send).toHaveBeenCalledTimes(1);
expect(send).toHaveBeenCalledWith({ type: 'ack', channel: 2, bytes: 1000 });
expect(att.lastAckBytes).toBe(1000);
// rien de neuf traité → aucun ACK supplémentaire (n'ACK QUE du réellement traité)
att.flushAck();
expect(send).toHaveBeenCalledTimes(1);
// nouveau reliquat traité → ACK cumulatif mis à jour
att.processedBytes = 1500;
att.flushAck();
expect(send).toHaveBeenCalledTimes(2);
expect(send).toHaveBeenLastCalledWith({ type: 'ack', channel: 2, bytes: 1500 });
});
it('no-op si le canal nest pas attaché (-1) ou si lattachment est fermé', () => {
const client = new WsClient();
const send = vi.spyOn(client, 'sendControl').mockImplementation(() => {});
const notAttached = new Attachment(client, 'a', 'interactive', sink, 80, 24); // channel = -1
notAttached.processedBytes = 500;
notAttached.flushAck();
const closed = new Attachment(client, 'b', 'interactive', sink, 80, 24);
closed.channel = 3;
closed.processedBytes = 500;
closed.closed = true;
closed.flushAck();
expect(send).not.toHaveBeenCalled();
});
});