From 5ebc394a37cc71ef697490169afff6883c4f7b04 Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Wed, 3 Jun 2026 16:35:15 +0200 Subject: [PATCH 1/3] feat(docker): scheduled reminders via supercronic + --deliver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add in-container scheduling so the dockerized agent can fire reminders on a cron schedule and deliver results to Telegram — without a host crontab. Why supercronic over crond: classic crond scrubs the environment from its jobs (so env_file vars like ODEK_API_KEY / the bot token never reach a tick) and wants root to setuid, clashing with the non-root container user. supercronic runs as the normal user and passes its own environment to each job, so a scheduled `odek run --deliver` sees exactly what the bot sees. - Dockerfile: install supercronic v0.2.46, pinned by SHA-256 computed from the official release assets (arch-aware via TARGETARCH); add a cron-entrypoint.sh wrapper that starts supercronic only when a crontab is mounted, then execs odek (PID-1 semantics, signals, and the Telegram lock unchanged). - compose: mount ./crontab into both telegram profiles. - telegram config: read ODEK_TELEGRAM_DEFAULT_CHAT_ID from env (was config-file only), so --deliver's target chat can live in .env like everything else. - docs + .env.example: document reminders and the new env var. E2E verified (build + run): supercronic SHA-256 checks out, a cron job inside the container inherits an injected env var, and the no-crontab path still runs odek unchanged. Unit tests cover the new env-var parsing (incl. negative IDs). Co-Authored-By: Claude Opus 4.8 (1M context) --- docker/.env.example | 3 +++ docker/Dockerfile | 36 +++++++++++++++++++++++++++++--- docker/README.md | 25 ++++++++++++++++++++++ docker/cron-entrypoint.sh | 30 ++++++++++++++++++++++++++ docker/crontab | 28 +++++++++++++++++++++++++ docker/docker-compose.yml | 6 ++++++ internal/telegram/config.go | 5 +++++ internal/telegram/config_test.go | 35 +++++++++++++++++++++++++++++++ 8 files changed, 165 insertions(+), 3 deletions(-) create mode 100755 docker/cron-entrypoint.sh create mode 100644 docker/crontab 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..f03c981 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:-amd64}"; \ + 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..c1c79ed --- /dev/null +++ b/docker/cron-entrypoint.sh @@ -0,0 +1,30 @@ +#!/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}" + +if [ -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" & +else + echo "cron-entrypoint: no crontab at $CRONTAB — skipping supercronic" >&2 +fi + +# 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..069092f 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -66,6 +66,9 @@ services: - ./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) ── @@ -83,4 +86,7 @@ services: - ./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()) From 33964380bc39ebb9f3fdadf6acf19e0626a47f07 Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Wed, 3 Jun 2026 16:54:22 +0200 Subject: [PATCH 2/3] fix(docker): address code-review findings in supercronic integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five findings from a post-merge review, all verified: C1 — graceful restart (/restart) re-execs the bare odek binary, bypassing cron-entrypoint.sh; supercronic was never restarted. Fix: cron-entrypoint.sh exports ODEK_ENTRYPOINT=$0 and ODEK_SUPERCRONIC_PID. spawnChild() uses ODEK_ENTRYPOINT when set so the wrapper is re-entered on restart. The wrapper kills the old supercronic PID before starting a new one, preventing duplicate scheduler instances. C2+C3 — no init process: supercronic zombies on exit; SIGTERM from docker stop not forwarded to supercronic (in-flight jobs killed abruptly at SIGKILL). Fix: init: true on both telegram compose services. Docker's built-in init becomes PID 1, reaping orphaned children and forwarding SIGTERM to the process group. C4 — arch="${TARGETARCH:-amd64}" silently installed the wrong-arch supercronic binary on arm64 hosts building without BuildKit; the SHA check still passed. Fix: change to ${TARGETARCH:?...} — a hard build failure with an actionable error message rather than a silent wrong-arch download. C5 — Docker creates a root-owned directory at ./crontab on the host when the source path is missing from a bind mount; [ -f ] returned false silently. Fix: add [ -d ] branch with an explicit warning explaining the cause and fix. C6 — supercronic backgrounded with &; set -e does not apply to background processes, so an immediate startup failure was silently swallowed. Fix: sleep 1 + kill -0 liveness check after launch; emits a clear WARNING if supercronic exits immediately, rather than proceeding as if cron is running. Co-Authored-By: Claude Opus 4.8 (1M context) --- cmd/odek/telegram.go | 8 +++++++ docker/Dockerfile | 2 +- docker/cron-entrypoint.sh | 45 +++++++++++++++++++++++++++++++++------ docker/docker-compose.yml | 8 +++++++ 4 files changed, 56 insertions(+), 7 deletions(-) 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/docker/Dockerfile b/docker/Dockerfile index f03c981..41f6773 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -76,7 +76,7 @@ RUN apk add --no-cache ca-certificates git github-cli bash coreutils curl jq ARG SUPERCRONIC_VERSION=v0.2.46 ARG TARGETARCH RUN set -eu; \ - arch="${TARGETARCH:-amd64}"; \ + 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 ;; \ diff --git a/docker/cron-entrypoint.sh b/docker/cron-entrypoint.sh index c1c79ed..47ee4fb 100755 --- a/docker/cron-entrypoint.sh +++ b/docker/cron-entrypoint.sh @@ -16,15 +16,48 @@ set -eu # Path to the crontab. Overridable so an operator can relocate the mount. CRONTAB="${ODEK_CRONTAB:-/home/odek/.crontabs/crontab}" -if [ -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" & +# 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 + 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/docker-compose.yml b/docker/docker-compose.yml index 069092f..bbf6be8 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -62,6 +62,13 @@ 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 @@ -82,6 +89,7 @@ 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 From 8eb09c08bb1be1445b6af5b315c3b0b4ac78d632 Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Wed, 3 Jun 2026 17:03:55 +0200 Subject: [PATCH 3/3] test(telegram): improve spawnChild coverage for ODEK_ENTRYPOINT branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The graceful-restart fix in the previous commit added an ODEK_ENTRYPOINT check to spawnChild — when set by cron-entrypoint.sh, the child is re-exeucted through the wrapper so supercronic is restarted. That branch was not covered. Add three targeted tests: - TestSpawnChild_UsesODEKENTRYPOINT: exercises the true branch (ODEK_ENTRYPOINT set) — spawnChild must call os.StartProcess with the wrapper path, not the odek binary. Uses /bin/sh as a universally present stand-in executable. - TestSpawnChild_ODEKENTRYPOINTEmpty_FallsBackToOdekBinary: empty env var must not override the executable (false branch). - TestSpawnChild_ResolvedAPIKeyInjected: API key is appended to childEnv only, not leaked into the current process environment. spawnChild coverage: 68.4% → 89.5%. Co-Authored-By: Claude Opus 4.8 (1M context) --- cmd/odek/telegram_test.go | 44 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) 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)