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
189 lines
7.9 KiB
TypeScript
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') };
|
|
}
|