Files
caldav/sidecar/radicale.ts
T
pastilhasandClaude Opus 5 f12e56aa80 the radicale config stops claiming caldav is single-user
the setting was right and the sentence beside it was not, which is the worse of
the two: anyone reading this config would conclude caldav is single-user and
either build around a limit that is not there or "fix" a line that was never
broken. it had been false since multi-user landed on 2026-08-07.

owner_only is what makes it per-user, together with the sync door resolving an
app password to one account and sending that id as X-Remote-User. verified on
alpha: collections/collection-root/1/{personal,contacts,tasks}.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-27 04:53:58 +00:00

189 lines
7.9 KiB
TypeScript

import { spawn, spawnSync } from 'node:child_process';
import type { ChildProcess } from 'node:child_process';
import { mkdirSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
// Supervises Radicale, the CalDAV/CardDAV server this sidecar fronts.
//
// WHY A REAL SERVER AND NOT OUR OWN. See docs/nextcloud-replacement.md. The short version: iCalendar and
// vCard are easy, but `sync-collection` REPORT, RRULE expansion, VTIMEZONE and ctag/etag semantics are
// not — and when they are subtly wrong a phone does not error, it silently stops syncing or silently
// duplicates every event. Radicale has been getting that right since 2008.
//
// AUTH. Radicale runs with `type = http_x_remote_user`, meaning it trusts an `X-Remote-User` header
// completely and does no authentication of its own. That is safe here for exactly the reason every
// other sidecar's `X-Officer-User` is safe: it binds LOOPBACK ONLY, so nothing but this sidecar can
// reach it, and this sidecar is only reachable through the platform, which is the thing that
// authenticates. If Radicale is ever bound to a non-loopback interface this becomes an open door —
// hence the explicit hostname below rather than a default.
export type Launcher = { command: string; args: string[] };
export type RadicaleHandle = {
port: number;
url: string;
stop: () => void;
isRunning: () => boolean;
/** Why it never started, when it never started. Null once it has launched at least once. */
startupError: string | null;
};
// ── Finding radicale ──
//
// `spawn('radicale')` resolves against the PATH THIS PROCESS STARTED WITH, and under PM2 that is a
// login-less minimum. The same trap has now bitten `tea`, the plugin dependency probe and this — so the
// search path is explicit rather than inherited.
//
// `/usr/bin` is listed even though it is almost always in PATH already: this is the apt package's
// location, it is the supported install, and a PATH mangled by a shell profile should not be able to
// break the normal case.
const searchPath = (): string =>
[join(homedir(), '.local', 'bin'), '/usr/local/bin', '/usr/bin', '/bin', process.env.PATH ?? '']
.filter(Boolean)
.join(':');
/**
* How to start radicale here, or null if it is not installed at all.
*
* Falls back to `python3 -m radicale` because radicale is a python package: a `pip install --user` in an
* environment without `~/.local/bin` on PATH, or a distribution that ships the module without a wrapper
* script, both leave it importable and unfindable. The module is what actually matters.
*/
export function resolveRadicale(pathOverride?: string): Launcher | null {
const PATH = pathOverride ?? searchPath();
const direct = Bun.which('radicale', { PATH });
if (direct) return { command: direct, args: [] };
const python = Bun.which('python3', { PATH });
if (python) {
const probe = spawnSync(python, ['-c', 'import radicale'], { stdio: 'ignore', env: { PATH } });
if (probe.status === 0) return { command: python, args: ['-m', 'radicale'] };
}
return null;
}
type StartParams = {
/** Where collections live. Created if absent. */
storagePath: string;
/** Where the generated config is written. Never hand-edit it; it is rewritten on every boot. */
configPath: string;
port: number;
};
// Radicale's own config format. Regenerated at every boot on purpose: the config is derived state, and
// a hand-edit that silently disagreed with what this file thinks is true would be very hard to debug.
const configFor = (storagePath: string, port: number) => `# GENERATED by officer-caldav on every boot.
# Hand edits are lost. See src/servers/sidecar/caldav/radicale.ts.
[server]
hosts = 127.0.0.1:${port}
# The platform terminates TLS; this hop is loopback.
ssl = False
[auth]
# Trusts X-Remote-User outright. Safe ONLY because of the loopback bind above — see the note in
# radicale.ts before changing either.
type = http_x_remote_user
[storage]
type = multifilesystem
filesystem_folder = ${storagePath}
[rights]
# Every account is its own principal, and owns only its own collections.
#
# This said "the owner is the only principal that exists; single-user is a platform invariant" until
# 2026-08-27, which had been false since multi-user landed on 2026-08-07 — the SETTING was right the whole
# time and the sentence explaining it was not, which is the worse of the two failures: anyone reading this
# config would conclude caldav is single-user and either build around a limit that is not there or
# "fix" a line that was never broken.
#
# What makes it per-user is upstream: the sync door resolves an app password to one account and sends that
# account's id as X-Remote-User, so `owner_only` scopes each principal to its own tree under
# `collections/collection-root/<id>/`.
type = owner_only
[logging]
level = warning
`;
export function startRadicale({ storagePath, configPath, port }: StartParams): RadicaleHandle {
mkdirSync(storagePath, { recursive: true });
writeFileSync(configPath, configFor(storagePath, port));
let child: ChildProcess | null = null;
let stopped = false;
let restarts = 0;
// Resolved ONCE, before anything can throw. This used to be a bare `spawn('radicale')` at module top
// level, so on a machine without it the ENOENT killed the sidecar and PM2 crash-looped it — which is
// the opposite of what the backoff below is for, and it took `/_health` down with it. A missing
// dependency should be a sidecar that answers "radicale is not installed", not a restart loop whose
// logs say ENOENT and nothing about the fix.
const launcher = resolveRadicale();
if (!launcher) {
const message =
'radicale is not installed. It ships with machine-setup; install it with ' +
'`sudo apt install radicale` (Debian/Ubuntu), `brew install radicale` (macOS), ' +
'or `pip install --user radicale`. Calendar and contacts are unavailable until then.';
console.error(`[caldav] ${message}`);
return {
port,
url: `http://127.0.0.1:${port}`,
isRunning: () => false,
startupError: message,
stop: () => {},
};
}
const launch = () => {
if (stopped) return;
child = spawn(launcher.command, [...launcher.args, '--config', configPath], {
stdio: ['ignore', 'pipe', 'pipe'],
// Radicale must not inherit the platform's environment wholesale — Bun auto-loads .env into every
// process started in the platform directory, and there is no reason for a calendar server to hold
// an Anthropic key or a database URL.
// The augmented PATH, not the inherited one — `python3 -m radicale` needs to find its own
// interpreter's neighbours, and a wrapper script may shell out.
env: { PATH: searchPath(), HOME: process.env.HOME ?? '' },
});
child.stdout?.on('data', (buf: Buffer) => process.stdout.write(`[radicale] ${buf}`));
child.stderr?.on('data', (buf: Buffer) => process.stderr.write(`[radicale] ${buf}`));
child.on('exit', (code, signal) => {
child = null;
if (stopped) return;
// Backoff, capped. A crash loop should be visible in the logs rather than a busy spin, and the
// sidecar itself stays up so /_health can keep reporting the truth.
restarts += 1;
const delay = Math.min(30_000, 500 * 2 ** Math.min(restarts, 6));
console.error(`[caldav] radicale exited (code=${code} signal=${signal}); restarting in ${delay}ms`);
setTimeout(launch, delay);
});
};
launch();
return {
port,
url: `http://127.0.0.1:${port}`,
isRunning: () => child !== null,
startupError: null,
stop: () => {
stopped = true;
child?.kill('SIGTERM');
child = null;
},
};
}
/** Resolve the paths this sidecar owns under DATA_PATH. The platform never opens any of them. */
export function davPaths(dataPath: string) {
const root = join(dataPath, 'dav');
return { root, storagePath: join(root, 'collections'), configPath: join(root, 'radicale.conf') };
}