diff --git a/cmd/odek/telegram.go b/cmd/odek/telegram.go index 643d6b6..a21dad8 100644 --- a/cmd/odek/telegram.go +++ b/cmd/odek/telegram.go @@ -899,6 +899,14 @@ func spawnChild() error { if err != nil { return fmt.Errorf("executable: %w", err) } + // When running inside the Docker container the entrypoint script exports + // ODEK_ENTRYPOINT=$0. Re-exec through the wrapper so supercronic is + // restarted alongside the new odek process. The wrapper reads + // ODEK_SUPERCRONIC_PID (also in childEnv via os.Environ()) and kills the + // previous supercronic before starting a new one — no duplicate instances. + if ep := os.Getenv("ODEK_ENTRYPOINT"); ep != "" { + exe = ep + } // Copy args (same as current process). argv := make([]string, len(os.Args)) copy(argv, os.Args) diff --git a/cmd/odek/telegram_test.go b/cmd/odek/telegram_test.go index 8b94bdf..85cc87f 100644 --- a/cmd/odek/telegram_test.go +++ b/cmd/odek/telegram_test.go @@ -24,6 +24,50 @@ func TestSpawnChild_StartsChildProcess(t *testing.T) { } } +func TestSpawnChild_UsesODEKENTRYPOINT(t *testing.T) { + // When ODEK_ENTRYPOINT is set (injected by cron-entrypoint.sh inside the + // container), spawnChild must use that path as the executable so the + // wrapper restarts supercronic alongside the new odek process. + // /bin/sh is a universally present executable that accepts arbitrary args + // and exits immediately when given -c ''; it lets us verify the branch + // without spawning a real odek binary. + t.Setenv("ODEK_ENTRYPOINT", "/bin/sh") + err := spawnChild() + // /bin/sh exits quickly with a non-zero code because os.Args contains + // test flags it does not understand, but os.StartProcess itself succeeds + // (process started) — the important thing is no "executable not found" error. + if err != nil { + t.Logf("spawnChild with ODEK_ENTRYPOINT returned: %v", err) + } +} + +func TestSpawnChild_ODEKENTRYPOINTEmpty_FallsBackToOdekBinary(t *testing.T) { + // When ODEK_ENTRYPOINT is empty (not set), the executable must remain + // the current odek binary — not some zero-value path. + t.Setenv("ODEK_ENTRYPOINT", "") + err := spawnChild() + if err != nil { + t.Logf("spawnChild (no ODEK_ENTRYPOINT) returned: %v", err) + } +} + +func TestSpawnChild_ResolvedAPIKeyInjected(t *testing.T) { + // resolvedAPIKey is re-injected into the child env so config.LoadConfig + // (which clears env keys) does not leave the child without credentials. + orig := resolvedAPIKey + t.Cleanup(func() { resolvedAPIKey = orig }) + resolvedAPIKey = "test-key-abc" + err := spawnChild() + if err != nil { + t.Logf("spawnChild returned: %v", err) + } + // Verify the key is still set in current env (spawnChild must not mutate + // os.Environ — it appends to a copy for the child only). + if v := os.Getenv("ODEK_API_KEY"); v == "test-key-abc" { + t.Error("spawnChild must not mutate the current process environment") + } +} + func TestWriteAndReadRestartMarker(t *testing.T) { tmp := t.TempDir() t.Setenv("HOME", tmp) diff --git a/docker/.env.example b/docker/.env.example index 0d8deb5..fe102b4 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -44,6 +44,9 @@ GIT_COMMITTER_EMAIL=you@example.com # ODEK_TELEGRAM_BOT_TOKEN=123456:ABC-your-bot-token # ODEK_TELEGRAM_ALLOWED_CHATS=11111111 # comma-separated chat IDs # ODEK_TELEGRAM_ALLOWED_USERS=11111111 # comma-separated user IDs (optional) +# Chat ID that `odek run --deliver` (and supercronic reminders) send to. +# Required for scheduled reminders; usually your own chat ID from ALLOWED_CHATS. +# ODEK_TELEGRAM_DEFAULT_CHAT_ID=11111111 # ODEK_TELEGRAM_DAILY_TOKEN_BUDGET=2000000 # optional cost cap; 0/unset = unlimited # ODEK_TELEGRAM_SESSION_TTL_HOURS=24 # optional # ODEK_TELEGRAM_HEALTH_ADDR=0.0.0.0:9090 # optional GET /health endpoint diff --git a/docker/Dockerfile b/docker/Dockerfile index 96f8c08..41f6773 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -63,14 +63,40 @@ RUN apk add --no-cache ca-certificates git github-cli bash coreutils curl jq # `adduser -D -u 1000 odek` with `useradd -m -u 1000 odek` # (the mkdir/chown, ENV HOME, USER, and WORKDIR lines all work unchanged). +# ── supercronic — container-friendly cron for scheduled `odek run` jobs ── +# Why not busybox crond: crond launches jobs with a SCRUBBED environment, so +# env_file vars (ODEK_API_KEY, ODEK_TELEGRAM_BOT_TOKEN, …) never reach a cron +# tick — and it wants root to setuid, clashing with the non-root user below. +# supercronic runs as a normal user and passes ITS OWN environment through to +# each job, so `odek run --deliver` from cron sees the same vars as the bot. +# +# Pinned to a release + SHA-256 computed from the official GitHub assets, so a +# tampered or substituted binary fails the build. TARGETARCH is supplied by +# BuildKit (this Dockerfile already opts in via the syntax= directive above). +ARG SUPERCRONIC_VERSION=v0.2.46 +ARG TARGETARCH +RUN set -eu; \ + arch="${TARGETARCH:?TARGETARCH is empty — build with BuildKit (docker buildx build) or pass --build-arg TARGETARCH=amd64|arm64}"; \ + case "$arch" in \ + amd64) sha=5adff01c5a797663948e656d2b61d10932369ee437eb5cb54fa872b2960f222b ;; \ + arm64) sha=c0576a8eb092e3f79108ed0a2155a25c7766af78456e5a6070e54757ef513bfe ;; \ + *) echo "supercronic: unsupported TARGETARCH=$arch" >&2; exit 1 ;; \ + esac; \ + curl -fsSL "https://github.com/aptible/supercronic/releases/download/${SUPERCRONIC_VERSION}/supercronic-linux-${arch}" \ + -o /usr/local/bin/supercronic; \ + echo "${sha} /usr/local/bin/supercronic" | sha256sum -c -; \ + chmod +x /usr/local/bin/supercronic + # Run as a non-root user — defense in depth even inside the container. # Pre-create ~/.odek owned by the user so it's writable for config, sessions, # and the Telegram lock (whether backed by an image dir or a mounted folder). +# ~/.crontabs holds the (optional) bind-mounted crontab read by supercronic. RUN adduser -D -u 1000 odek \ - && mkdir -p /home/odek/.odek /workspace \ - && chown -R odek:odek /home/odek/.odek /workspace + && mkdir -p /home/odek/.odek /home/odek/.crontabs /workspace \ + && chown -R odek:odek /home/odek/.odek /home/odek/.crontabs /workspace COPY --from=build /out/odek /usr/local/bin/odek +COPY --chmod=0755 docker/cron-entrypoint.sh /usr/local/bin/cron-entrypoint.sh # Docker does NOT set $HOME from USER, but Odek resolves ~/.odek via $HOME. # Set it explicitly so config.json, sessions, and the Telegram lock land in @@ -79,4 +105,8 @@ ENV HOME=/home/odek USER odek WORKDIR /workspace -ENTRYPOINT ["odek"] +# The wrapper starts supercronic in the background IFF a crontab is mounted, +# then `exec`s odek — so services without a crontab behave exactly as before +# (odek stays the container's main process; signals and the singleton lock are +# unchanged). The compose `command:` (serve/telegram/run …) flows through as $@. +ENTRYPOINT ["/usr/local/bin/cron-entrypoint.sh"] diff --git a/docker/README.md b/docker/README.md index a6fc1a1..e597f1a 100644 --- a/docker/README.md +++ b/docker/README.md @@ -25,6 +25,8 @@ docker/ ├── config.restricted.json # Restricted permission policy ├── config.godmode.json # Godmode (YOLO) permission policy ├── .env.example # copy to .env, add your API key +├── cron-entrypoint.sh # starts supercronic (if a crontab is mounted), then execs odek +├── crontab # scheduled reminders (edit + uncomment to enable) └── workspace/ # the dir the agent works in (mounted in) ``` @@ -99,6 +101,29 @@ local `./.odek` folder — an external host folder, just like `./workspace`. > long-poller per bot (a second gets `409 Conflict`). Create a second bot via > @BotFather if you want both. +### Scheduled reminders (cron) + +The Telegram profiles bundle [supercronic](https://github.com/aptible/supercronic), a +container-friendly cron. Unlike the classic `crond`, it runs as the non-root user **and +passes the container environment to each job** — so a scheduled `odek run --deliver` +sees the same `.env` vars (API key, bot token) the bot does. No separate host crontab, +no daemon juggling. + +1. In `.env`, set **`ODEK_TELEGRAM_DEFAULT_CHAT_ID`** — the chat reminders are sent to + (usually your own ID, the same as `ODEK_TELEGRAM_ALLOWED_CHATS`). +2. Edit `crontab` and uncomment/add jobs (standard 5-field syntax; min granularity is + 1 minute). Example — a weekday stand-up nudge: + + ```cron + 0 9 * * 1-5 /usr/local/bin/odek run --deliver "Reminder: stand-up in 15 minutes." + ``` + +3. (Re)start a Telegram profile. On boot you'll see `cron-entrypoint: starting + supercronic …` in the logs; each job's result is delivered to your chat. + +Times are UTC unless you set `TZ` in `.env`. An empty/all-commented `crontab` is fine — +supercronic simply schedules nothing. + ## Verify the profiles differ - **Restricted**: ask it to `rm -rf` everything in `/workspace` → denied, never runs. diff --git a/docker/cron-entrypoint.sh b/docker/cron-entrypoint.sh new file mode 100755 index 0000000..47ee4fb --- /dev/null +++ b/docker/cron-entrypoint.sh @@ -0,0 +1,63 @@ +#!/bin/sh +# cron-entrypoint.sh — container entrypoint for the odek image. +# +# If a crontab is mounted, start supercronic in the background, then hand the +# container over to the real odek command (serve / telegram / run / …) via +# `exec` so odek remains the main process: signals, graceful restart, and the +# Telegram singleton lock all behave exactly as they did before this wrapper. +# +# supercronic inherits THIS process's environment and passes it to every cron +# job, so a scheduled `odek run --deliver` sees the same env_file vars +# (ODEK_API_KEY, ODEK_TELEGRAM_BOT_TOKEN, ODEK_TELEGRAM_DEFAULT_CHAT_ID, …) +# that the bot does. That is the whole reason for using supercronic over the +# classic crond, which scrubs the environment from its jobs. +set -eu + +# Path to the crontab. Overridable so an operator can relocate the mount. +CRONTAB="${ODEK_CRONTAB:-/home/odek/.crontabs/crontab}" + +# Graceful-restart support: odek's /restart command re-execs via this script +# (see ODEK_ENTRYPOINT below). Kill any supercronic from the previous run so we +# never end up with two instances scheduling the same crontab concurrently. +if [ -n "${ODEK_SUPERCRONIC_PID:-}" ]; then + kill "$ODEK_SUPERCRONIC_PID" 2>/dev/null || true + unset ODEK_SUPERCRONIC_PID +fi + +if [ -d "$CRONTAB" ]; then + # Docker creates a directory when the bind-mount source doesn't exist on the + # host. This is almost always a misconfiguration — warn loudly rather than + # silently skipping so the operator knows why reminders aren't firing. + echo "cron-entrypoint: WARNING: $CRONTAB is a directory, not a file" >&2 + echo "cron-entrypoint: Docker created it because the host path was missing." >&2 + echo "cron-entrypoint: Fix: remove the directory on the host and create the file." >&2 + echo "cron-entrypoint: Skipping supercronic — cron jobs will NOT run." >&2 +elif [ -f "$CRONTAB" ]; then + echo "cron-entrypoint: starting supercronic for $CRONTAB" >&2 + # -passthrough-logs keeps each job's own stdout/stderr intact in the + # container log alongside supercronic's scheduling lines. + supercronic -passthrough-logs "$CRONTAB" & + ODEK_SUPERCRONIC_PID=$! + export ODEK_SUPERCRONIC_PID + # Brief liveness check: supercronic parses the crontab at startup and exits + # immediately on a syntax error or missing binary. Neither is recoverable at + # runtime, so catching it here produces a clear warning rather than silent + # non-delivery. set -e does not cover backgrounded processes, so we check + # explicitly after a short window. + sleep 1 + if ! kill -0 "$ODEK_SUPERCRONIC_PID" 2>/dev/null; then + echo "cron-entrypoint: WARNING: supercronic exited immediately — cron jobs will NOT run" >&2 + unset ODEK_SUPERCRONIC_PID + fi +else + echo "cron-entrypoint: no crontab at $CRONTAB — skipping supercronic" >&2 +fi + +# Advertise this script's own path so spawnChild (odek's /restart handler) can +# re-exec through the wrapper instead of the bare binary. Without this, a +# graceful restart would skip supercronic entirely. +export ODEK_ENTRYPOINT="$0" + +# Default to printing usage if no command was provided (matches the previous +# `ENTRYPOINT ["odek"]` behaviour for a bare `docker run`). +exec odek "$@" diff --git a/docker/crontab b/docker/crontab new file mode 100644 index 0000000..ffd5e03 --- /dev/null +++ b/docker/crontab @@ -0,0 +1,28 @@ +# odek reminders — supercronic crontab (standard 5-field cron syntax). +# +# This file is bind-mounted read-only into the container at +# /home/odek/.crontabs/crontab (see docker-compose.yml). When present, the +# entrypoint starts supercronic, which runs each job below on schedule. +# +# Each reminder is just `odek run --deliver ""`. --deliver sends the +# agent's final response to the Telegram chat in ODEK_TELEGRAM_DEFAULT_CHAT_ID +# (set in .env). supercronic passes the container environment to every job, so +# ODEK_API_KEY and the bot token are available here with no extra wiring. +# +# ┌ minute (0-59) +# │ ┌ hour (0-23) +# │ │ ┌ day-of-month (1-31) +# │ │ │ ┌ month (1-12) +# │ │ │ │ ┌ day-of-week (0-6, Sun=0) +# │ │ │ │ │ +# * * * * * command +# +# Times are UTC unless you set TZ in .env. Use the absolute binary path. +# +# Uncomment / edit the examples below to enable reminders: + +# Weekdays at 09:00 — stand-up nudge: +# 0 9 * * 1-5 /usr/local/bin/odek run --deliver "Reminder: daily stand-up starts in 15 minutes." + +# Every day at 18:30 — end-of-day wrap-up prompt: +# 30 18 * * * /usr/local/bin/odek run --deliver "End of day: summarize what I shipped and what's open for tomorrow." diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index c552a6b..bbf6be8 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -62,10 +62,20 @@ services: image: odek:local env_file: .env command: ["telegram"] + # init: true adds Docker's built-in init (tini) as PID 1. This gives us: + # - Zombie reaping: supercronic child processes are reaped when they exit. + # - Signal forwarding: SIGTERM from `docker stop` reaches all children, + # giving in-flight cron jobs a clean shutdown window. + # - Graceful restart safety: when odek exits during /restart, the spawned + # child is reparented to the init rather than dying with odek. + init: true volumes: - ./workspace:/workspace - ./.odek:/home/odek/.odek - ./config.restricted.json:/home/odek/.odek/config.json:ro + # Scheduled reminders: supercronic runs the jobs in ./crontab and + # delivers results to ODEK_TELEGRAM_DEFAULT_CHAT_ID via `--deliver`. + - ./crontab:/home/odek/.crontabs/crontab:ro restart: unless-stopped # ── Telegram bot — Godmode (no prompts; unrestricted) ── @@ -79,8 +89,12 @@ services: image: odek:local env_file: .env command: ["telegram"] + init: true # zombie reaping + SIGTERM forwarding (see telegram-restricted) volumes: - ./workspace:/workspace - ./.odek:/home/odek/.odek - ./config.godmode.json:/home/odek/.odek/config.json:ro + # Scheduled reminders: supercronic runs the jobs in ./crontab and + # delivers results to ODEK_TELEGRAM_DEFAULT_CHAT_ID via `--deliver`. + - ./crontab:/home/odek/.crontabs/crontab:ro restart: unless-stopped diff --git a/internal/telegram/config.go b/internal/telegram/config.go index 7af2b9d..7ee13db 100644 --- a/internal/telegram/config.go +++ b/internal/telegram/config.go @@ -97,6 +97,11 @@ func ConfigFromEnv(base TelegramConfig) TelegramConfig { if v := os.Getenv("ODEK_TELEGRAM_LOG_FILE"); v != "" { cfg.LogFile = v } + if v := os.Getenv("ODEK_TELEGRAM_DEFAULT_CHAT_ID"); v != "" { + if id, err := strconv.ParseInt(v, 10, 64); err == nil { + cfg.DefaultChatID = id + } + } return cfg } diff --git a/internal/telegram/config_test.go b/internal/telegram/config_test.go index ab722de..02be936 100644 --- a/internal/telegram/config_test.go +++ b/internal/telegram/config_test.go @@ -205,6 +205,41 @@ func TestConfigFromEnv_pollTimeoutEmpty(t *testing.T) { } } +func TestConfigFromEnv_defaultChatID(t *testing.T) { + t.Setenv("ODEK_TELEGRAM_DEFAULT_CHAT_ID", "8592463065") + cfg := ConfigFromEnv(DefaultConfig()) + if cfg.DefaultChatID != 8592463065 { + t.Errorf("DefaultChatID = %d, want 8592463065", cfg.DefaultChatID) + } +} + +func TestConfigFromEnv_defaultChatIDNegative(t *testing.T) { + // Group/channel chat IDs are negative; ParseInt must accept them. + t.Setenv("ODEK_TELEGRAM_DEFAULT_CHAT_ID", "-1001234567890") + cfg := ConfigFromEnv(DefaultConfig()) + if cfg.DefaultChatID != -1001234567890 { + t.Errorf("DefaultChatID = %d, want -1001234567890", cfg.DefaultChatID) + } +} + +func TestConfigFromEnv_defaultChatIDInvalidKeepsBase(t *testing.T) { + t.Setenv("ODEK_TELEGRAM_DEFAULT_CHAT_ID", "not-a-number") + base := DefaultConfig() + base.DefaultChatID = 42 // a non-zero base must survive an unparseable env value + cfg := ConfigFromEnv(base) + if cfg.DefaultChatID != 42 { + t.Errorf("DefaultChatID = %d, want base 42 preserved", cfg.DefaultChatID) + } +} + +func TestConfigFromEnv_defaultChatIDEmpty(t *testing.T) { + t.Setenv("ODEK_TELEGRAM_DEFAULT_CHAT_ID", "") + cfg := ConfigFromEnv(DefaultConfig()) + if cfg.DefaultChatID != 0 { + t.Errorf("DefaultChatID = %d, want default 0", cfg.DefaultChatID) + } +} + func TestConfigFromEnv_maxMsgLength(t *testing.T) { t.Setenv("ODEK_TELEGRAM_MAX_MSG_LENGTH", "1024") cfg := ConfigFromEnv(DefaultConfig())