P1 spine: monorepo, shared WS protocol, server daemon

- npm workspaces (shared / server / web), TS strict, project refs
- @arboretum/shared: multiplexed WS protocol (JSON control + binary
  output frames: 1B type + u32le channel), flow-control constants
  (ACK 256K, HIGH 384K, LOW 128K, lagging 2M), REST types
- git-arboretum server: Fastify 5 + node:sqlite (single native dep:
  node-pty prebuilt), token auth (sha256 at rest, HMAC cookie, global
  login rate limit + backoff), strict Origin check on /api and /ws,
  PtyManager (2MiB ring with monotonic offset, resync replay = reset +
  256KiB tail, pause/resume only when ALL interactive clients exceed
  HIGH, observers never throttle, lagging clients resync), WS gateway
  (attach/stdin/resize/ack, heartbeat 30s), SIGTERM→SIGKILL 5s grace
- CLI: arboretum [--port 7317] [--bind 127.0.0.1] — non-loopback bind
  requires an explicit safety flag
- Smoke-tested: login/401/403-origin/spawn bash/kill/grace-SIGKILL all
  green
This commit is contained in:
2026-06-11 22:04:09 +02:00
parent 903d5deeb5
commit 4768b606e4
25 changed files with 4467 additions and 474 deletions

View File

@@ -0,0 +1,34 @@
{
"name": "git-arboretum",
"version": "0.1.0",
"description": "Self-hosted web dashboard for git worktrees and the Claude Code sessions running on them",
"license": "MIT",
"type": "module",
"bin": {
"arboretum": "./dist/index.js"
},
"main": "./dist/app.js",
"files": [
"dist",
"public"
],
"engines": {
"node": ">=22.16"
},
"scripts": {
"build": "tsc -b",
"dev": "tsc -b --watch & node --watch dist/index.js",
"test": "vitest run"
},
"dependencies": {
"@arboretum/shared": "0.1.0",
"@fastify/cookie": "^11.0.0",
"@fastify/static": "^8.0.0",
"@fastify/websocket": "^11.0.0",
"@homebridge/node-pty-prebuilt-multiarch": "^0.13.0",
"fastify": "^5.0.0"
},
"devDependencies": {
"@types/ws": "^8.5.0"
}
}

View File

@@ -0,0 +1,91 @@
import Fastify, { type FastifyInstance, type FastifyRequest } from 'fastify';
import fastifyCookie from '@fastify/cookie';
import fastifyWebsocket from '@fastify/websocket';
import fastifyStatic from '@fastify/static';
import { existsSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import type { Config } from './config.js';
import type { Db } from './db/index.js';
import { AuthService, LoginRateLimiter, type AuthContext } from './auth/service.js';
import { PtyManager } from './core/pty-manager.js';
import { registerAuthRoutes } from './routes/auth.js';
import { registerSessionRoutes } from './routes/sessions.js';
import { registerWsGateway } from './ws/gateway.js';
declare module 'fastify' {
interface FastifyRequest {
authContext: AuthContext | null;
}
interface FastifyContextConfig {
public?: boolean;
}
}
export interface AppBundle {
app: FastifyInstance;
auth: AuthService;
manager: PtyManager;
}
export function buildApp(config: Config, db: Db, serverVersion: string): AppBundle {
const app = Fastify({ logger: { level: process.env.ARBORETUM_LOG ?? 'info' } });
const auth = new AuthService(db);
const limiter = new LoginRateLimiter();
const manager = new PtyManager(db);
void app.register(fastifyCookie);
void app.register(fastifyWebsocket, {
options: { maxPayload: 1024 * 1024 },
});
const allowedOrigins = new Set<string>([
`http://127.0.0.1:${config.port}`,
`http://localhost:${config.port}`,
...config.allowedOrigins,
]);
const authenticate = (req: FastifyRequest): AuthContext | null => {
const bearer = req.headers.authorization;
if (bearer?.startsWith('Bearer ')) {
const ctx = auth.verifyRawToken(bearer.slice(7));
if (ctx) return ctx;
}
return auth.verifyCookie(req.cookies[auth.cookieName]);
};
// Garde globale : auth sur tout /api/** et /ws ; check Origin strict quand l'en-tête est présent
// (anti cross-site WebSocket hijacking — le cookie SameSite=Strict ne suffit pas pour les upgrades).
app.addHook('preValidation', async (req, reply) => {
const isApi = req.url.startsWith('/api/');
const isWs = req.url.startsWith('/ws');
if (!isApi && !isWs) return; // statique : public (la SPA gère son écran de login)
const origin = req.headers.origin;
if (origin && !allowedOrigins.has(origin)) {
return reply.status(403).send({ error: { code: 'BAD_ORIGIN', message: `Origin not allowed: ${origin}` } });
}
req.authContext = authenticate(req);
if (req.routeOptions.config.public) return;
if (!req.authContext) {
return reply.status(401).send({ error: { code: 'UNAUTHORIZED', message: 'Authentication required' } });
}
});
registerAuthRoutes(app, auth, limiter, serverVersion);
registerSessionRoutes(app, manager);
registerWsGateway(app, manager, serverVersion);
// SPA buildée embarquée dans le paquet npm (public/) — absente en dev (vite dev sert le front)
const publicDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'public');
if (existsSync(publicDir)) {
void app.register(fastifyStatic, { root: publicDir, wildcard: false });
app.setNotFoundHandler((req, reply) => {
if (req.url.startsWith('/api/') || req.url.startsWith('/ws')) {
return reply.status(404).send({ error: { code: 'NOT_FOUND', message: 'Route not found' } });
}
return reply.sendFile('index.html');
});
}
return { app, auth, manager };
}

View File

@@ -0,0 +1,124 @@
import { createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
import { type Db, getSetting, setSetting } from '../db/index.js';
const COOKIE_NAME = 'arb_session';
const COOKIE_TTL_MS = 30 * 24 * 3600 * 1000;
export interface AuthContext {
tokenId: string;
label: string;
}
export class AuthService {
private readonly secret: Buffer;
constructor(private readonly db: Db) {
let secretHex = getSetting(db, 'server_secret');
if (!secretHex) {
secretHex = randomBytes(32).toString('hex');
setSetting(db, 'server_secret', secretHex);
}
this.secret = Buffer.from(secretHex, 'hex');
}
get cookieName(): string {
return COOKIE_NAME;
}
/** Crée le token initial au premier démarrage et renvoie sa valeur en clair (affichée une seule fois). */
ensureBootstrapToken(): string | null {
const row = this.db
.prepare('SELECT COUNT(*) AS n FROM auth_tokens WHERE revoked_at IS NULL')
.get() as { n: number };
if (row.n > 0) return null;
return this.createToken('initial');
}
createToken(label: string): string {
const raw = `arb_${randomBytes(24).toString('hex')}`;
this.db
.prepare('INSERT INTO auth_tokens (id, label, token_hash, created_at) VALUES (?, ?, ?, ?)')
.run(randomUUID(), label, sha256(raw), new Date().toISOString());
return raw;
}
verifyRawToken(raw: string): AuthContext | null {
if (typeof raw !== 'string' || raw.length < 8 || raw.length > 128) return null;
const row = this.db
.prepare('SELECT id, label, token_hash FROM auth_tokens WHERE revoked_at IS NULL AND token_hash = ?')
.get(sha256(raw)) as { id: string; label: string; token_hash: string } | undefined;
if (!row) return null;
// double vérification en temps constant (le lookup par hash est déjà non-oracle, ceinture+bretelles)
if (!timingSafeEqual(Buffer.from(row.token_hash), Buffer.from(sha256(raw)))) return null;
this.db.prepare('UPDATE auth_tokens SET last_used_at = ? WHERE id = ?').run(new Date().toISOString(), row.id);
return { tokenId: row.id, label: row.label };
}
issueCookie(ctx: AuthContext): string {
const expires = Date.now() + COOKIE_TTL_MS;
const payload = `${ctx.tokenId}.${expires}`;
return `${payload}.${this.sign(payload)}`;
}
verifyCookie(value: string | undefined): AuthContext | null {
if (!value) return null;
const lastDot = value.lastIndexOf('.');
if (lastDot <= 0) return null;
const payload = value.slice(0, lastDot);
const sig = value.slice(lastDot + 1);
const expected = this.sign(payload);
if (sig.length !== expected.length || !timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) return null;
const [tokenId, expiresStr] = payload.split('.');
if (!tokenId || !expiresStr || Number(expiresStr) < Date.now()) return null;
const row = this.db
.prepare('SELECT id, label FROM auth_tokens WHERE id = ? AND revoked_at IS NULL')
.get(tokenId) as { id: string; label: string } | undefined;
return row ? { tokenId: row.id, label: row.label } : null;
}
private sign(payload: string): string {
return createHmac('sha256', this.secret).update(payload).digest('hex');
}
}
function sha256(s: string): string {
return createHash('sha256').update(s).digest('hex');
}
/**
* Rate limit GLOBAL du login (pas par IP : derrière Tailscale Serve toutes les
* requêtes arrivent de 127.0.0.1) + backoff exponentiel sur échecs consécutifs.
*/
export class LoginRateLimiter {
private attempts: number[] = [];
private consecutiveFailures = 0;
private lockedUntil = 0;
constructor(
private readonly maxPerMinute = 5,
private readonly now: () => number = Date.now,
) {}
/** null = autorisé ; sinon délai d'attente en ms */
check(): number | null {
const t = this.now();
if (t < this.lockedUntil) return this.lockedUntil - t;
this.attempts = this.attempts.filter((a) => t - a < 60_000);
if (this.attempts.length >= this.maxPerMinute) return 60_000 - (t - (this.attempts[0] ?? t));
this.attempts.push(t);
return null;
}
recordFailure(): void {
this.consecutiveFailures += 1;
if (this.consecutiveFailures >= 3) {
const backoff = Math.min(2 ** (this.consecutiveFailures - 3) * 30_000, 30 * 60_000);
this.lockedUntil = this.now() + backoff;
}
}
recordSuccess(): void {
this.consecutiveFailures = 0;
this.lockedUntil = 0;
}
}

View File

@@ -0,0 +1,50 @@
import { parseArgs } from 'node:util';
import { join } from 'node:path';
import { homedir } from 'node:os';
import { mkdirSync } from 'node:fs';
export interface Config {
port: number;
bind: string;
dbPath: string;
dataDir: string;
/** origins supplémentaires autorisées (ex. https://machine.tailnet.ts.net) */
allowedOrigins: string[];
printToken: boolean;
}
export function loadConfig(argv = process.argv.slice(2)): Config {
const { values } = parseArgs({
args: argv,
options: {
port: { type: 'string', default: '7317' },
bind: { type: 'string', default: '127.0.0.1' },
db: { type: 'string' },
'allow-origin': { type: 'string', multiple: true },
'print-token': { type: 'boolean', default: false },
'i-know-this-exposes-a-terminal': { type: 'boolean', default: false },
},
strict: true,
});
const bind = values.bind ?? '127.0.0.1';
const loopback = bind === '127.0.0.1' || bind === '::1' || bind === 'localhost';
if (!loopback && !values['i-know-this-exposes-a-terminal']) {
throw new Error(
`Refusing to bind to ${bind}: an Arboretum server is remote code execution by design.\n` +
`Use Tailscale Serve against the default 127.0.0.1 bind (recommended), or pass\n` +
`--i-know-this-exposes-a-terminal if you really know what you are doing.`,
);
}
const dataDir = join(process.env.XDG_DATA_HOME ?? join(homedir(), '.local', 'share'), 'arboretum');
mkdirSync(dataDir, { recursive: true });
return {
port: Number(values.port),
bind,
dbPath: values.db ?? join(dataDir, 'arboretum.db'),
dataDir,
allowedOrigins: values['allow-origin'] ?? [],
printToken: values['print-token'] ?? false,
};
}

View File

@@ -0,0 +1,34 @@
import { execFileSync } from 'node:child_process';
export interface SpawnSpec {
file: string;
args: string[];
env: NodeJS.ProcessEnv;
}
let cachedClaudeBin: string | null = null;
export function resolveClaudeBin(): string {
if (cachedClaudeBin) return cachedClaudeBin;
try {
cachedClaudeBin = execFileSync('which', ['claude'], { encoding: 'utf8' }).trim();
} catch {
throw new Error(
'Claude Code CLI not found in PATH. Install it first: https://code.claude.com/docs/en/quickstart',
);
}
return cachedClaudeBin;
}
/** Module volontairement abstrait : le plan B « BYO API key / Agent SDK » se brancherait ici. */
export function buildSpawnSpec(command: 'claude' | 'bash'): SpawnSpec {
const env: NodeJS.ProcessEnv = {
...process.env,
TERM: 'xterm-256color',
COLORTERM: 'truecolor',
};
if (command === 'bash') {
return { file: 'bash', args: ['--norc'], env };
}
return { file: resolveClaudeBin(), args: [], env };
}

View File

@@ -0,0 +1,271 @@
import { EventEmitter } from 'node:events';
import { existsSync, statSync } from 'node:fs';
import { randomUUID } from 'node:crypto';
import pty from '@homebridge/node-pty-prebuilt-multiarch';
import { FLOW, REPLAY_TAIL_BYTES, type SessionSummary } from '@arboretum/shared';
import { RingBuffer } from './ring-buffer.js';
import { buildSpawnSpec } from './claude-launcher.js';
import type { Db } from '../db/index.js';
const RING_CAPACITY = 2 * 1024 * 1024;
const KILL_GRACE_MS = 5000;
/** Lien entre un client WS attaché et une session. La gateway fournit les callbacks d'envoi. */
export interface ClientBinding {
channel: number;
mode: 'interactive' | 'observer';
controlling: boolean;
sentBytes: number;
ackedBytes: number;
lagging: boolean;
sendOutput: (payload: Buffer) => void;
sendResync: (payload: Buffer) => void;
onDetached: (reason: 'session_exit' | 'replaced') => void;
onControlChanged: (controlling: boolean) => void;
}
interface ManagedSession {
id: string;
cwd: string;
command: 'claude' | 'bash';
title: string | null;
createdAt: string;
proc: pty.IPty;
ring: RingBuffer;
clients: Set<ClientBinding>;
paused: boolean;
exited: { exitCode: number | null; signal: number | null } | null;
killTimer: NodeJS.Timeout | null;
}
export interface PtyManagerEvents {
session_update: [SessionSummary];
session_exit: [{ sessionId: string; exitCode: number | null; signal: number | null }];
}
export class PtyManager extends EventEmitter<PtyManagerEvents> {
private readonly live = new Map<string, ManagedSession>();
constructor(private readonly db: Db) {
super();
}
spawn(opts: { cwd: string; command?: 'claude' | 'bash' }): SessionSummary {
const cwd = opts.cwd;
if (!existsSync(cwd) || !statSync(cwd).isDirectory()) {
throw Object.assign(new Error(`Not a directory: ${cwd}`), { statusCode: 400 });
}
const command = opts.command ?? 'claude';
const spec = buildSpawnSpec(command);
const id = randomUUID();
const proc = pty.spawn(spec.file, spec.args, {
name: 'xterm-256color',
cols: 120,
rows: 32,
cwd,
env: spec.env as { [key: string]: string },
});
const session: ManagedSession = {
id,
cwd,
command,
title: null,
createdAt: new Date().toISOString(),
proc,
ring: new RingBuffer(RING_CAPACITY),
clients: new Set(),
paused: false,
exited: null,
killTimer: null,
};
this.live.set(id, session);
this.db
.prepare('INSERT INTO sessions (id, cwd, command, created_at) VALUES (?, ?, ?, ?)')
.run(id, cwd, command, session.createdAt);
proc.onData((data) => this.handleOutput(session, Buffer.from(data, 'utf8')));
proc.onExit(({ exitCode, signal }) => this.handleExit(session, exitCode, signal ?? null));
const summary = this.summarize(session);
this.emit('session_update', summary);
return summary;
}
list(): SessionSummary[] {
const liveSummaries = [...this.live.values()].map((s) => this.summarize(s));
const liveIds = new Set(this.live.keys());
const rows = this.db
.prepare('SELECT id, cwd, command, title, created_at, ended_at, exit_code FROM sessions ORDER BY created_at DESC LIMIT 100')
.all() as Array<{ id: string; cwd: string; command: string; title: string | null; created_at: string; ended_at: string | null; exit_code: number | null }>;
const historical: SessionSummary[] = rows
.filter((r) => !liveIds.has(r.id))
.map((r) => ({
id: r.id,
cwd: r.cwd,
command: r.command,
title: r.title,
status: 'exited',
live: false,
createdAt: r.created_at,
endedAt: r.ended_at,
exitCode: r.exit_code,
clients: 0,
}));
return [...liveSummaries, ...historical];
}
get(id: string): SessionSummary | null {
const s = this.live.get(id);
return s ? this.summarize(s) : null;
}
kill(id: string): boolean {
const s = this.live.get(id);
if (!s || s.exited) return false;
try {
process.kill(s.proc.pid, 'SIGTERM');
} catch {
return false;
}
s.killTimer ??= setTimeout(() => {
if (!s.exited) {
try {
process.kill(s.proc.pid, 'SIGKILL');
} catch {
/* déjà mort */
}
}
}, KILL_GRACE_MS);
return true;
}
/** Arrêt du daemon : SIGTERM à toutes les sessions (le CLI nettoie son registre sur SIGTERM — spike S1). */
shutdown(): void {
for (const id of this.live.keys()) this.kill(id);
}
// ---- attach / detach / io ----
attach(sessionId: string, binding: ClientBinding, cols: number, rows: number): { ok: true; controlling: boolean } | { ok: false; code: 'NOT_FOUND' | 'SESSION_EXITED' } {
const s = this.live.get(sessionId);
if (!s) return { ok: false, code: 'NOT_FOUND' };
if (s.exited) return { ok: false, code: 'SESSION_EXITED' };
const hasController = [...s.clients].some((c) => c.controlling);
binding.controlling = binding.mode === 'interactive' && !hasController;
s.clients.add(binding);
if (binding.controlling) s.proc.resize(cols, rows);
// Replay : reset terminal + queue du ring (l'écran TUI courant se reconstitue)
binding.sendResync(s.ring.tail(REPLAY_TAIL_BYTES));
binding.sentBytes = 0;
binding.ackedBytes = 0;
return { ok: true, controlling: binding.controlling };
}
detach(sessionId: string, binding: ClientBinding): void {
const s = this.live.get(sessionId);
if (!s) return;
s.clients.delete(binding);
if (binding.controlling) {
const next = [...s.clients].find((c) => c.mode === 'interactive');
if (next) {
next.controlling = true;
next.onControlChanged(true);
}
}
this.updateFlowControl(s);
}
write(sessionId: string, binding: ClientBinding, data: string): 'ok' | 'not_controlling' | 'gone' {
const s = this.live.get(sessionId);
if (!s || s.exited) return 'gone';
// Outil mono-utilisateur : tous les interactifs peuvent écrire ; les observers jamais.
if (binding.mode !== 'interactive') return 'not_controlling';
s.proc.write(data);
return 'ok';
}
resize(sessionId: string, binding: ClientBinding, cols: number, rows: number): void {
const s = this.live.get(sessionId);
if (!s || s.exited || !binding.controlling) return;
s.proc.resize(cols, rows);
}
ack(sessionId: string, binding: ClientBinding, bytes: number): void {
const s = this.live.get(sessionId);
if (!s) return;
binding.ackedBytes = Math.max(binding.ackedBytes, bytes);
if (binding.lagging && binding.sentBytes - binding.ackedBytes < FLOW.LOW_WATERMARK) {
binding.lagging = false;
binding.sendResync(s.ring.tail(REPLAY_TAIL_BYTES));
binding.sentBytes = 0;
binding.ackedBytes = 0;
}
this.updateFlowControl(s);
}
// ---- interne ----
private handleOutput(s: ManagedSession, chunk: Buffer): void {
s.ring.write(chunk);
for (const c of s.clients) {
if (c.lagging) continue;
c.sendOutput(chunk);
c.sentBytes += chunk.length;
if (c.sentBytes - c.ackedBytes > FLOW.LAGGING_BYTES) c.lagging = true;
}
this.updateFlowControl(s);
}
/**
* pause() seulement quand TOUS les clients interactifs non-lagging dépassent HIGH ;
* resume() quand le min repasse sous LOW. Les observers ne freinent jamais le PTY.
*/
private updateFlowControl(s: ManagedSession): void {
if (s.exited) return;
const interactive = [...s.clients].filter((c) => c.mode === 'interactive' && !c.lagging);
if (interactive.length === 0) {
if (s.paused) {
s.proc.resume();
s.paused = false;
}
return;
}
const outstandings = interactive.map((c) => c.sentBytes - c.ackedBytes);
const min = Math.min(...outstandings);
if (!s.paused && outstandings.every((o) => o > FLOW.HIGH_WATERMARK)) {
s.proc.pause();
s.paused = true;
} else if (s.paused && min < FLOW.LOW_WATERMARK) {
s.proc.resume();
s.paused = false;
}
}
private handleExit(s: ManagedSession, exitCode: number | null, signal: number | null): void {
s.exited = { exitCode, signal };
if (s.killTimer) clearTimeout(s.killTimer);
this.db
.prepare('UPDATE sessions SET ended_at = ?, exit_code = ? WHERE id = ?')
.run(new Date().toISOString(), exitCode, s.id);
for (const c of s.clients) c.onDetached('session_exit');
s.clients.clear();
this.live.delete(s.id);
this.emit('session_exit', { sessionId: s.id, exitCode, signal });
this.emit('session_update', { ...this.summarize(s), live: false, status: 'exited' });
}
private summarize(s: ManagedSession): SessionSummary {
return {
id: s.id,
cwd: s.cwd,
command: s.command,
title: s.title,
status: s.exited ? 'exited' : 'running',
live: !s.exited,
createdAt: s.createdAt,
endedAt: null,
exitCode: s.exited?.exitCode ?? null,
clients: s.clients.size,
};
}
}

View File

@@ -0,0 +1,67 @@
/**
* Ring buffer binaire à offset monotone.
* `totalOffset` compte tous les octets jamais écrits ; le ring ne garde que la
* fenêtre récente. Un lecteur qui demande un offset sorti de la fenêtre reçoit
* null et doit resync (reset terminal + queue du ring).
*/
export class RingBuffer {
private buf: Buffer;
private length = 0; // octets valides dans la fenêtre
private total = 0; // offset monotone (Number : sûr jusqu'à 2^53)
constructor(readonly capacity: number) {
this.buf = Buffer.alloc(capacity);
}
get totalOffset(): number {
return this.total;
}
get windowStart(): number {
return this.total - this.length;
}
write(chunk: Buffer): void {
if (chunk.length >= this.capacity) {
chunk.copy(this.buf, 0, chunk.length - this.capacity);
this.length = this.capacity;
} else {
const writePos = this.total % this.capacity;
const tailSpace = this.capacity - writePos;
if (chunk.length <= tailSpace) {
chunk.copy(this.buf, writePos);
} else {
chunk.copy(this.buf, writePos, 0, tailSpace);
chunk.copy(this.buf, 0, tailSpace);
}
this.length = Math.min(this.capacity, this.length + chunk.length);
}
this.total += chunk.length;
}
/** Octets depuis `offset` (inclus), ou null si l'offset est sorti de la fenêtre. */
readFrom(offset: number): Buffer | null {
if (offset > this.total) return null;
if (offset === this.total) return Buffer.alloc(0);
if (offset < this.windowStart) return null;
const count = this.total - offset;
return this.tail(count);
}
/** Les `count` derniers octets (bornés à la fenêtre), dans l'ordre. */
tail(count: number): Buffer {
const n = Math.min(count, this.length);
const out = Buffer.alloc(n);
const end = this.total % this.capacity;
const start = (end - n + this.capacity * 2) % this.capacity;
if (n === 0) return out;
if (start < end || end === 0) {
this.buf.copy(out, 0, start, start + n);
} else {
const firstPart = this.capacity - start;
this.buf.copy(out, 0, start, this.capacity);
this.buf.copy(out, firstPart, 0, end);
}
return out;
}
}

View File

@@ -0,0 +1,68 @@
import { DatabaseSync } from 'node:sqlite';
const MIGRATIONS: Array<{ id: number; sql: string }> = [
{
id: 1,
sql: `
CREATE TABLE settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE auth_tokens (
id TEXT PRIMARY KEY,
label TEXT NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
created_at TEXT NOT NULL,
last_used_at TEXT,
revoked_at TEXT
);
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
cwd TEXT NOT NULL,
command TEXT NOT NULL,
title TEXT,
created_at TEXT NOT NULL,
ended_at TEXT,
exit_code INTEGER
);
`,
},
];
export type Db = DatabaseSync;
export function openDb(path: string): Db {
const db = new DatabaseSync(path);
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
migrate(db);
return db;
}
function migrate(db: DatabaseSync): void {
db.exec('CREATE TABLE IF NOT EXISTS schema_migrations (id INTEGER PRIMARY KEY, applied_at TEXT NOT NULL)');
const applied = new Set(
(db.prepare('SELECT id FROM schema_migrations').all() as Array<{ id: number }>).map((r) => r.id),
);
for (const m of MIGRATIONS) {
if (applied.has(m.id)) continue;
db.exec('BEGIN');
try {
db.exec(m.sql);
db.prepare('INSERT INTO schema_migrations (id, applied_at) VALUES (?, ?)').run(m.id, new Date().toISOString());
db.exec('COMMIT');
} catch (err) {
db.exec('ROLLBACK');
throw err;
}
}
}
export function getSetting(db: Db, key: string): string | null {
const row = db.prepare('SELECT value FROM settings WHERE key = ?').get(key) as { value: string } | undefined;
return row?.value ?? null;
}
export function setSetting(db: Db, key: string, value: string): void {
db.prepare('INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value').run(key, value);
}

View File

@@ -0,0 +1,51 @@
#!/usr/bin/env node
import { readFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { loadConfig } from './config.js';
import { openDb } from './db/index.js';
import { buildApp } from './app.js';
const pkg = JSON.parse(
readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf8'),
) as { version: string };
async function main(): Promise<void> {
const config = loadConfig();
const db = openDb(config.dbPath);
const { app, auth, manager } = buildApp(config, db, pkg.version);
const bootstrapToken = auth.ensureBootstrapToken();
await app.listen({ port: config.port, host: config.bind });
const url = `http://${config.bind === '0.0.0.0' ? '127.0.0.1' : config.bind}:${config.port}`;
app.log.info(`Arboretum v${pkg.version}${url}`);
if (bootstrapToken) {
// Affiché une seule fois : le hash seul est stocké.
console.log('\n┌──────────────────────────────────────────────────────────────────┐');
console.log('│ First start — your access token (shown once, store it safely): │');
console.log('└──────────────────────────────────────────────────────────────────┘');
console.log(`\n ${bootstrapToken}\n`);
console.log(` Login at: ${url}/\n`);
} else if (config.printToken) {
console.log('Tokens are stored hashed and cannot be re-printed. Create a new one from Settings (or reset the db).');
}
let shuttingDown = false;
const shutdown = (signal: string): void => {
if (shuttingDown) return;
shuttingDown = true;
app.log.info(`${signal} received — draining sessions then exiting`);
manager.shutdown();
setTimeout(() => {
void app.close().then(() => process.exit(0));
}, 1000).unref();
};
process.on('SIGINT', () => shutdown('SIGINT'));
process.on('SIGTERM', () => shutdown('SIGTERM'));
}
main().catch((err) => {
console.error(err instanceof Error ? err.message : err);
process.exit(1);
});

View File

@@ -0,0 +1,42 @@
import type { FastifyInstance } from 'fastify';
import type { LoginRequest, LoginResponse, MeResponse } from '@arboretum/shared';
import type { AuthService, LoginRateLimiter } from '../auth/service.js';
export function registerAuthRoutes(
app: FastifyInstance,
auth: AuthService,
limiter: LoginRateLimiter,
serverVersion: string,
): void {
app.post('/api/v1/auth/login', { config: { public: true } }, async (req, reply) => {
const wait = limiter.check();
if (wait !== null) {
return reply.status(429).send({ error: { code: 'RATE_LIMITED', message: `Retry in ${Math.ceil(wait / 1000)}s` } });
}
const body = req.body as Partial<LoginRequest> | null;
const ctx = typeof body?.token === 'string' ? auth.verifyRawToken(body.token) : null;
if (!ctx) {
limiter.recordFailure();
return reply.status(401).send({ error: { code: 'BAD_TOKEN', message: 'Invalid token' } });
}
limiter.recordSuccess();
void reply.setCookie(auth.cookieName, auth.issueCookie(ctx), {
path: '/',
httpOnly: true,
sameSite: 'strict',
maxAge: 30 * 24 * 3600,
});
const res: LoginResponse = { ok: true, label: ctx.label };
return reply.send(res);
});
app.get('/api/v1/auth/me', async (req, reply) => {
const res: MeResponse = { ok: true, tokenLabel: req.authContext?.label ?? 'unknown', serverVersion };
return reply.send(res);
});
app.post('/api/v1/auth/logout', async (_req, reply) => {
void reply.clearCookie(auth.cookieName, { path: '/' });
return reply.send({ ok: true });
});
}

View File

@@ -0,0 +1,35 @@
import type { FastifyInstance } from 'fastify';
import type { CreateSessionRequest, SessionResponse, SessionsListResponse } from '@arboretum/shared';
import type { PtyManager } from '../core/pty-manager.js';
export function registerSessionRoutes(app: FastifyInstance, manager: PtyManager): void {
app.get('/api/v1/sessions', async (): Promise<SessionsListResponse> => {
return { sessions: manager.list() };
});
app.post('/api/v1/sessions', async (req, reply) => {
const body = req.body as Partial<CreateSessionRequest> | null;
if (!body || typeof body.cwd !== 'string' || !body.cwd.startsWith('/')) {
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'cwd (absolute path) is required' } });
}
if (body.command !== undefined && body.command !== 'claude' && body.command !== 'bash') {
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'command must be claude or bash' } });
}
try {
const session = manager.spawn({ cwd: body.cwd, ...(body.command ? { command: body.command } : {}) });
const res: SessionResponse = { session };
return reply.status(201).send(res);
} catch (err) {
const statusCode = (err as { statusCode?: number }).statusCode ?? 500;
return reply.status(statusCode).send({ error: { code: 'SPAWN_FAILED', message: (err as Error).message } });
}
});
app.delete('/api/v1/sessions/:id', async (req, reply) => {
const { id } = req.params as { id: string };
if (!manager.kill(id)) {
return reply.status(404).send({ error: { code: 'NOT_FOUND', message: 'No live session with this id' } });
}
return reply.send({ ok: true });
});
}

View File

@@ -0,0 +1,157 @@
import type { FastifyInstance } from 'fastify';
import type { WebSocket } from 'ws';
import {
BINARY_FRAME,
PROTOCOL_VERSION,
encodeBinaryFrame,
parseClientMessage,
type ServerMessage,
type SessionSummary,
} from '@arboretum/shared';
import type { ClientBinding, PtyManager } from '../core/pty-manager.js';
const HEARTBEAT_MS = 30_000;
interface ChannelState {
sessionId: string;
binding: ClientBinding;
}
export function registerWsGateway(app: FastifyInstance, manager: PtyManager, serverVersion: string): void {
app.get('/ws', { websocket: true }, (socket: WebSocket, req) => {
// L'auth + le check Origin ont eu lieu dans le preValidation global (app.ts).
const channels = new Map<number, ChannelState>();
let nextChannel = 1;
let helloDone = false;
let subscribedSessions = false;
let alive = true;
const send = (msg: ServerMessage): void => {
if (socket.readyState === socket.OPEN) socket.send(JSON.stringify(msg));
};
const sendBinary = (type: number, channel: number, payload: Buffer): void => {
if (socket.readyState === socket.OPEN) socket.send(encodeBinaryFrame(type, channel, payload));
};
const onSessionUpdate = (session: SessionSummary): void => {
if (subscribedSessions) send({ type: 'session_update', session });
};
const onSessionExit = (e: { sessionId: string; exitCode: number | null; signal: number | null }): void => {
if (subscribedSessions) send({ type: 'session_exit', ...e });
};
manager.on('session_update', onSessionUpdate);
manager.on('session_exit', onSessionExit);
const heartbeat = setInterval(() => {
if (!alive) {
socket.terminate();
return;
}
alive = false;
socket.ping();
}, HEARTBEAT_MS);
socket.on('pong', () => {
alive = true;
});
socket.on('message', (raw, isBinary) => {
if (isBinary) return; // P1 : aucune frame binaire attendue côté client
const msg = parseClientMessage(String(raw));
if (!msg) {
send({ type: 'error', code: 'BAD_MESSAGE', message: 'Unparseable or invalid message' });
return;
}
if (!helloDone && msg.type !== 'hello') {
send({ type: 'error', code: 'BAD_PROTOCOL', message: 'Expected hello first' });
return;
}
switch (msg.type) {
case 'hello': {
if (msg.protocol !== PROTOCOL_VERSION) {
send({ type: 'error', code: 'BAD_PROTOCOL', message: `Server speaks protocol ${PROTOCOL_VERSION}` });
socket.close();
return;
}
helloDone = true;
send({ type: 'hello_ok', protocol: PROTOCOL_VERSION, serverVersion });
return;
}
case 'sub': {
subscribedSessions = msg.topics.includes('sessions');
return;
}
case 'attach': {
const channel = nextChannel++;
const binding: ClientBinding = {
channel,
mode: msg.mode,
controlling: false,
sentBytes: 0,
ackedBytes: 0,
lagging: false,
sendOutput: (payload) => sendBinary(BINARY_FRAME.OUTPUT, channel, payload),
sendResync: (payload) => sendBinary(BINARY_FRAME.RESYNC, channel, payload),
onDetached: (reason) => {
channels.delete(channel);
send({ type: 'detached', channel, reason });
},
onControlChanged: (controlling) => send({ type: 'control_changed', channel, controlling }),
};
const res = manager.attach(msg.sessionId, binding, msg.cols, msg.rows);
if (!res.ok) {
send({ type: 'error', code: res.code, message: `Cannot attach: ${res.code}` });
return;
}
channels.set(channel, { sessionId: msg.sessionId, binding });
send({ type: 'attached', channel, sessionId: msg.sessionId, mode: msg.mode, controlling: res.controlling });
return;
}
case 'detach': {
const st = channels.get(msg.channel);
if (!st) return;
channels.delete(msg.channel);
manager.detach(st.sessionId, st.binding);
send({ type: 'detached', channel: msg.channel, reason: 'client' });
return;
}
case 'stdin': {
const st = channels.get(msg.channel);
if (!st) {
send({ type: 'error', code: 'NOT_ATTACHED', message: 'Unknown channel', channel: msg.channel });
return;
}
const r = manager.write(st.sessionId, st.binding, msg.data);
if (r === 'not_controlling') {
send({ type: 'error', code: 'NOT_CONTROLLING', message: 'Observer mode is read-only', channel: msg.channel });
} else if (r === 'gone') {
send({ type: 'error', code: 'SESSION_EXITED', message: 'Session has exited', channel: msg.channel });
}
return;
}
case 'resize': {
const st = channels.get(msg.channel);
if (st) manager.resize(st.sessionId, st.binding, msg.cols, msg.rows);
return;
}
case 'ack': {
const st = channels.get(msg.channel);
if (st) manager.ack(st.sessionId, st.binding, msg.bytes);
return;
}
case 'ping':
send({ type: 'pong' });
return;
}
});
socket.on('close', () => {
clearInterval(heartbeat);
manager.off('session_update', onSessionUpdate);
manager.off('session_exit', onSessionExit);
for (const [, st] of channels) manager.detach(st.sessionId, st.binding);
channels.clear();
});
void req;
});
}

View File

@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"types": ["node"]
},
"include": ["src"],
"references": [{ "path": "../shared" }]
}

File diff suppressed because one or more lines are too long