From beaa24754c1e668cc113f63051f65f6f24c9de9e Mon Sep 17 00:00:00 2001 From: claude Date: Fri, 7 Aug 2026 01:19:33 +0400 Subject: [PATCH 1/2] Read the CalDAV password from a file, not from argv (V-644) mavcaldav took -pass and -render-pass as flag values, so enabling it would have put his calendar password in `ps` inside the container, in the compose file, and in shell history. mavpoll and mavmaild both read their secret from a file for exactly that reason. readSecret reads once at start, trims, and refuses an empty or missing file. An empty file is a deployment mistake, not a password, and basic auth would otherwise send "" and collect a 401 every poll. A rotated password means a restart, which is cheaper than re-reading the credential every five minutes. Nothing called the old flags: no compose service, no systemd unit, no test. So they are replaced rather than kept beside the new ones. --- cmd/mavcaldav/main.go | 43 +++++++++++++++++++++++++++++++------- cmd/mavcaldav/main_test.go | 26 +++++++++++++++++++++++ 2 files changed, 61 insertions(+), 8 deletions(-) diff --git a/cmd/mavcaldav/main.go b/cmd/mavcaldav/main.go index c59fa3b..fbd74a8 100644 --- a/cmd/mavcaldav/main.go +++ b/cmd/mavcaldav/main.go @@ -50,10 +50,10 @@ func run(args []string) error { socket := fs.String("socket", "", "core IPC socket path (required)") url := fs.String("url", "", "CalDAV calendar URL, e.g. http://localhost:5232/kami/personal (required)") user := fs.String("user", "", "CalDAV basic-auth username (required)") - pass := fs.String("pass", "", "CalDAV basic-auth password (required)") + passFile := fs.String("pass-file", "", "file holding the CalDAV basic-auth password (required — never passed as a flag value)") renderURL := fs.String("render-url", "", "CalDAV collection maven publishes her own reminders to; empty disables rendering") renderUser := fs.String("render-user", "", "basic-auth username for -render-url (defaults to -user)") - renderPass := fs.String("render-pass", "", "basic-auth password for -render-url (defaults to -pass)") + renderPassFile := fs.String("render-pass-file", "", "file holding the password for -render-url (defaults to -pass-file)") renderDur := fs.Duration("render-duration", calendar.DefaultReminderDuration, "how long a rendered reminder occupies") interval := fs.Duration("interval", 5*time.Minute, "poll cadence") timeout := fs.Duration("timeout", 10*time.Second, "per-request HTTP timeout") @@ -63,13 +63,22 @@ func run(args []string) error { if *socket == "" { return fmt.Errorf("-socket is required") } - if *url == "" || *user == "" || *pass == "" { - return fmt.Errorf("-url, -user, -pass are required") + if *url == "" || *user == "" || *passFile == "" { + return fmt.Errorf("-url, -user, -pass-file are required") } if err := checkRenderTarget([]string{*url}, *renderURL); err != nil { return err } + // The password is read from a file, never taken as a flag value: an argv + // secret is visible in `ps` to every user on the box and lands in the compose + // file and the shell history. Same rule mavmaild and mavpoll follow. Read + // once at start, so a rotated password means a restart. + pass, err := readSecret(*passFile) + if err != nil { + return err + } + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() @@ -85,17 +94,20 @@ func run(args []string) error { http: hc, url: strings.TrimRight(*url, "/"), user: *user, - pass: *pass, + pass: pass, } var rend *renderer if *renderURL != "" { - ru, rp := *renderUser, *renderPass + ru, rp := *renderUser, pass if ru == "" { ru = *user } - if rp == "" { - rp = *pass + if *renderPassFile != "" { + rp, err = readSecret(*renderPassFile) + if err != nil { + return err + } } rend = newRenderer(core, hc, *renderURL, ru, rp, *renderDur) log.Printf("mavcaldav: rendering reminders to %s", *renderURL) @@ -131,6 +143,21 @@ func run(args []string) error { // It takes the whole read set, not one URL. The guarantee in the package // comment is about every calendar maven reads, and a second read target added // later must not quietly fall outside the check. +// readSecret reads one credential from a file and refuses an empty one. An +// empty file is a deployment mistake, not a password, and CalDAV basic auth +// would send it and get a 401 every poll. +func readSecret(path string) (string, error) { + raw, err := os.ReadFile(path) + if err != nil { + return "", fmt.Errorf("read password file: %w", err) + } + secret := strings.TrimSpace(string(raw)) + if secret == "" { + return "", fmt.Errorf("password file %s is empty", path) + } + return secret, nil +} + func checkRenderTarget(readURLs []string, renderURL string) error { if renderURL == "" { return nil diff --git a/cmd/mavcaldav/main_test.go b/cmd/mavcaldav/main_test.go index c3d82d3..435e145 100644 --- a/cmd/mavcaldav/main_test.go +++ b/cmd/mavcaldav/main_test.go @@ -5,12 +5,38 @@ import ( "fmt" "net/http" "net/http/httptest" + "os" + "path/filepath" "testing" "time" "github.com/kami/maven/internal/ipc" ) +// The password comes from a file so it never reaches argv. An empty or missing +// file must fail at start rather than authenticate as "" against his calendar. +func TestReadSecret(t *testing.T) { + dir := t.TempDir() + good := filepath.Join(dir, "ok") + if err := os.WriteFile(good, []byte(" hunter2\n"), 0o600); err != nil { + t.Fatal(err) + } + if got, err := readSecret(good); err != nil || got != "hunter2" { + t.Fatalf("readSecret(good) = %q, %v; want \"hunter2\", nil", got, err) + } + + empty := filepath.Join(dir, "empty") + if err := os.WriteFile(empty, []byte("\n \n"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := readSecret(empty); err == nil { + t.Fatal("readSecret(empty) = nil error, want refusal") + } + if _, err := readSecret(filepath.Join(dir, "absent")); err == nil { + t.Fatal("readSecret(absent) = nil error, want refusal") + } +} + type fakeCore struct { ipc.UnimplementedCoreAPI facts map[string]ipc.Fact // composite key "key|source" → Fact From b55e68f98d3d63f720fefba4fdd61b51c38bdb57 Mon Sep 17 00:00:00 2001 From: claude Date: Fri, 7 Aug 2026 01:19:44 +0400 Subject: [PATCH 2/2] Say in compose that the calendar is off, and why (V-644) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mavcaldav was built, in `make build`, listed in CLAUDE.md's daemon table, and deployed nowhere. Not commented out the way mavmaild is, which at least records the decision and the enable steps. Built and mentioned nowhere is the worst of the three states, so this writes the decision down. The box has no CalDAV account, so the block stays commented. It names what the absence costs, because both costs are invisible from the daemon table. Agenda questions route correctly and answer from nothing: stage 0 sends "что у меня сегодня" to IntentQuery (V-498) and the calendar query source then reads facts nobody writes. And loop.State.CalendarBusy is fed by those same facts, so the gate's "do not nag mid-meeting" is permanently false. CLAUDE.md said the absence was an oversight. It is a decision now. --- CLAUDE.md | 12 +++++++++--- docker-compose.yml | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f69a422..dbab7d0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -101,9 +101,15 @@ protocol; the config in `deploy/mavend.json` (with `${VAR}` env expansion from g Count against compose, not against the table. Four of the nine daemons are absent, and each absence has a different reason. -`mavmaild` is commented out in compose, with the reason written beside it: it needs a mail -account and this box has none. `mavcaldav` appears nowhere at all, and unlike the other -three that is an oversight rather than a decision (V-644). +`mavmaild` and `mavcaldav` are commented out in compose, each with the reason written +beside it: the first needs a mail account, the second a CalDAV account, and this box has +neither. `mavcaldav` used to appear nowhere at all, which was an oversight; it became a +recorded decision on 07-08-2026 (V-644). Two things ride on that absence and the block +names them. Agenda questions route to `IntentQuery` at stage 0 (V-498) and the `calendar` +query source then reads a table nobody writes. And `loop.State.CalendarBusy` is fed by the +same facts, so the gate's "do not nag mid-meeting" is permanently false. Its password is +read from a file (`-pass-file`, and `-render-pass-file` for the render collection), never +taken as a flag value, which is the rule `mavpoll` and `mavmaild` follow too. **`mavwaked` and `mavenclient` are absent by decision, not oversight** (Vikunja #463, `docs/plans/17-where-the-voice-loop-runs.md`). diff --git a/docker-compose.yml b/docker-compose.yml index eb8a7aa..2eedef6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -157,6 +157,44 @@ services: # - maildata:/var/lib/mavmaild # - ./deploy/imap.password:/run/secrets/imap.password:ro + # The calendar reader (Vikunja #644) is OFF and commented out: it needs a + # CalDAV account, and there is none on this box. It was built, listed in + # `make build`, and deployed nowhere, which is the worst of the three states — + # this block records the decision instead. + # + # What its absence costs, so the cost is visible from here: + # - Agenda questions route correctly and answer from nothing. Stage 0 sends + # "что у меня сегодня" to IntentQuery (V-498) and the `calendar` query + # source reads facts(kind=env, source=caldav:*) that nobody writes. + # - The nudge gate loses a suppressor. loop.State.CalendarBusy is fed by + # those same facts, so "do not nag mid-meeting" is permanently false. + # + # Core never sees the CalDAV password: the reader polls the collection itself + # and hands core one fact per event over WriteFact. Nothing here can create a + # reminder, so a misread event cannot fire. + # + # The password is read from a FILE, so it never appears in `ps`, in this file, + # or in shell history — the same rule mavpoll and mavmaild follow. + # + # To enable: write the password to deploy/caldav.password (0600, gitignored), + # point -url at the collection, and uncomment this service. No mavend.json + # block is needed — events arrive over IPC as facts. -render-url is optional + # and OFF here: it publishes Maven's own reminders back as events, and it must + # not name the collection -url reads, or the poller reads its own writes back + # in (checkRenderTarget refuses that). It takes -render-pass-file, and falls + # back to this password when that is not given. + # mavcaldav: + # <<: *image + # command: ["mavcaldav", "-socket", "/run/maven/mavend.sock", + # "-url", "http://localhost:5232/kami/personal", + # "-user", "kami", + # "-pass-file", "/run/secrets/caldav.password", + # "-interval", "5m"] + # depends_on: [mavend] + # volumes: + # - sockets:/run/maven + # - ./deploy/caldav.password:/run/secrets/caldav.password:ro + volumes: dbdata: sockets: