Skip to content

Commit 5ebc394

Browse files
jkyberneeesclaude
andcommitted
feat(docker): scheduled reminders via supercronic + --deliver
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) <noreply@anthropic.com>
1 parent 5783c8d commit 5ebc394

8 files changed

Lines changed: 165 additions & 3 deletions

File tree

docker/.env.example

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,9 @@ GIT_COMMITTER_EMAIL=you@example.com
4444
# ODEK_TELEGRAM_BOT_TOKEN=123456:ABC-your-bot-token
4545
# ODEK_TELEGRAM_ALLOWED_CHATS=11111111 # comma-separated chat IDs
4646
# ODEK_TELEGRAM_ALLOWED_USERS=11111111 # comma-separated user IDs (optional)
47+
# Chat ID that `odek run --deliver` (and supercronic reminders) send to.
48+
# Required for scheduled reminders; usually your own chat ID from ALLOWED_CHATS.
49+
# ODEK_TELEGRAM_DEFAULT_CHAT_ID=11111111
4750
# ODEK_TELEGRAM_DAILY_TOKEN_BUDGET=2000000 # optional cost cap; 0/unset = unlimited
4851
# ODEK_TELEGRAM_SESSION_TTL_HOURS=24 # optional
4952
# ODEK_TELEGRAM_HEALTH_ADDR=0.0.0.0:9090 # optional GET /health endpoint

docker/Dockerfile

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,14 +63,40 @@ RUN apk add --no-cache ca-certificates git github-cli bash coreutils curl jq
6363
# `adduser -D -u 1000 odek` with `useradd -m -u 1000 odek`
6464
# (the mkdir/chown, ENV HOME, USER, and WORKDIR lines all work unchanged).
6565

66+
# ── supercronic — container-friendly cron for scheduled `odek run` jobs ──
67+
# Why not busybox crond: crond launches jobs with a SCRUBBED environment, so
68+
# env_file vars (ODEK_API_KEY, ODEK_TELEGRAM_BOT_TOKEN, …) never reach a cron
69+
# tick — and it wants root to setuid, clashing with the non-root user below.
70+
# supercronic runs as a normal user and passes ITS OWN environment through to
71+
# each job, so `odek run --deliver` from cron sees the same vars as the bot.
72+
#
73+
# Pinned to a release + SHA-256 computed from the official GitHub assets, so a
74+
# tampered or substituted binary fails the build. TARGETARCH is supplied by
75+
# BuildKit (this Dockerfile already opts in via the syntax= directive above).
76+
ARG SUPERCRONIC_VERSION=v0.2.46
77+
ARG TARGETARCH
78+
RUN set -eu; \
79+
arch="${TARGETARCH:-amd64}"; \
80+
case "$arch" in \
81+
amd64) sha=5adff01c5a797663948e656d2b61d10932369ee437eb5cb54fa872b2960f222b ;; \
82+
arm64) sha=c0576a8eb092e3f79108ed0a2155a25c7766af78456e5a6070e54757ef513bfe ;; \
83+
*) echo "supercronic: unsupported TARGETARCH=$arch" >&2; exit 1 ;; \
84+
esac; \
85+
curl -fsSL "https://github.com/aptible/supercronic/releases/download/${SUPERCRONIC_VERSION}/supercronic-linux-${arch}" \
86+
-o /usr/local/bin/supercronic; \
87+
echo "${sha} /usr/local/bin/supercronic" | sha256sum -c -; \
88+
chmod +x /usr/local/bin/supercronic
89+
6690
# Run as a non-root user — defense in depth even inside the container.
6791
# Pre-create ~/.odek owned by the user so it's writable for config, sessions,
6892
# and the Telegram lock (whether backed by an image dir or a mounted folder).
93+
# ~/.crontabs holds the (optional) bind-mounted crontab read by supercronic.
6994
RUN adduser -D -u 1000 odek \
70-
&& mkdir -p /home/odek/.odek /workspace \
71-
&& chown -R odek:odek /home/odek/.odek /workspace
95+
&& mkdir -p /home/odek/.odek /home/odek/.crontabs /workspace \
96+
&& chown -R odek:odek /home/odek/.odek /home/odek/.crontabs /workspace
7297

7398
COPY --from=build /out/odek /usr/local/bin/odek
99+
COPY --chmod=0755 docker/cron-entrypoint.sh /usr/local/bin/cron-entrypoint.sh
74100

75101
# Docker does NOT set $HOME from USER, but Odek resolves ~/.odek via $HOME.
76102
# Set it explicitly so config.json, sessions, and the Telegram lock land in
@@ -79,4 +105,8 @@ ENV HOME=/home/odek
79105
USER odek
80106
WORKDIR /workspace
81107

82-
ENTRYPOINT ["odek"]
108+
# The wrapper starts supercronic in the background IFF a crontab is mounted,
109+
# then `exec`s odek — so services without a crontab behave exactly as before
110+
# (odek stays the container's main process; signals and the singleton lock are
111+
# unchanged). The compose `command:` (serve/telegram/run …) flows through as $@.
112+
ENTRYPOINT ["/usr/local/bin/cron-entrypoint.sh"]

docker/README.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ docker/
2525
├── config.restricted.json # Restricted permission policy
2626
├── config.godmode.json # Godmode (YOLO) permission policy
2727
├── .env.example # copy to .env, add your API key
28+
├── cron-entrypoint.sh # starts supercronic (if a crontab is mounted), then execs odek
29+
├── crontab # scheduled reminders (edit + uncomment to enable)
2830
└── workspace/ # the dir the agent works in (mounted in)
2931
```
3032

@@ -99,6 +101,29 @@ local `./.odek` folder — an external host folder, just like `./workspace`.
99101
> long-poller per bot (a second gets `409 Conflict`). Create a second bot via
100102
> @BotFather if you want both.
101103
104+
### Scheduled reminders (cron)
105+
106+
The Telegram profiles bundle [supercronic](https://github.com/aptible/supercronic), a
107+
container-friendly cron. Unlike the classic `crond`, it runs as the non-root user **and
108+
passes the container environment to each job** — so a scheduled `odek run --deliver`
109+
sees the same `.env` vars (API key, bot token) the bot does. No separate host crontab,
110+
no daemon juggling.
111+
112+
1. In `.env`, set **`ODEK_TELEGRAM_DEFAULT_CHAT_ID`** — the chat reminders are sent to
113+
(usually your own ID, the same as `ODEK_TELEGRAM_ALLOWED_CHATS`).
114+
2. Edit `crontab` and uncomment/add jobs (standard 5-field syntax; min granularity is
115+
1 minute). Example — a weekday stand-up nudge:
116+
117+
```cron
118+
0 9 * * 1-5 /usr/local/bin/odek run --deliver "Reminder: stand-up in 15 minutes."
119+
```
120+
121+
3. (Re)start a Telegram profile. On boot you'll see `cron-entrypoint: starting
122+
supercronic …` in the logs; each job's result is delivered to your chat.
123+
124+
Times are UTC unless you set `TZ` in `.env`. An empty/all-commented `crontab` is fine —
125+
supercronic simply schedules nothing.
126+
102127
## Verify the profiles differ
103128

104129
- **Restricted**: ask it to `rm -rf` everything in `/workspace` → denied, never runs.

docker/cron-entrypoint.sh

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
#!/bin/sh
2+
# cron-entrypoint.sh — container entrypoint for the odek image.
3+
#
4+
# If a crontab is mounted, start supercronic in the background, then hand the
5+
# container over to the real odek command (serve / telegram / run / …) via
6+
# `exec` so odek remains the main process: signals, graceful restart, and the
7+
# Telegram singleton lock all behave exactly as they did before this wrapper.
8+
#
9+
# supercronic inherits THIS process's environment and passes it to every cron
10+
# job, so a scheduled `odek run --deliver` sees the same env_file vars
11+
# (ODEK_API_KEY, ODEK_TELEGRAM_BOT_TOKEN, ODEK_TELEGRAM_DEFAULT_CHAT_ID, …)
12+
# that the bot does. That is the whole reason for using supercronic over the
13+
# classic crond, which scrubs the environment from its jobs.
14+
set -eu
15+
16+
# Path to the crontab. Overridable so an operator can relocate the mount.
17+
CRONTAB="${ODEK_CRONTAB:-/home/odek/.crontabs/crontab}"
18+
19+
if [ -f "$CRONTAB" ]; then
20+
echo "cron-entrypoint: starting supercronic for $CRONTAB" >&2
21+
# -passthrough-logs keeps each job's own stdout/stderr intact in the
22+
# container log alongside supercronic's scheduling lines.
23+
supercronic -passthrough-logs "$CRONTAB" &
24+
else
25+
echo "cron-entrypoint: no crontab at $CRONTAB — skipping supercronic" >&2
26+
fi
27+
28+
# Default to printing usage if no command was provided (matches the previous
29+
# `ENTRYPOINT ["odek"]` behaviour for a bare `docker run`).
30+
exec odek "$@"

docker/crontab

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# odek reminders — supercronic crontab (standard 5-field cron syntax).
2+
#
3+
# This file is bind-mounted read-only into the container at
4+
# /home/odek/.crontabs/crontab (see docker-compose.yml). When present, the
5+
# entrypoint starts supercronic, which runs each job below on schedule.
6+
#
7+
# Each reminder is just `odek run --deliver "<task>"`. --deliver sends the
8+
# agent's final response to the Telegram chat in ODEK_TELEGRAM_DEFAULT_CHAT_ID
9+
# (set in .env). supercronic passes the container environment to every job, so
10+
# ODEK_API_KEY and the bot token are available here with no extra wiring.
11+
#
12+
# ┌ minute (0-59)
13+
# │ ┌ hour (0-23)
14+
# │ │ ┌ day-of-month (1-31)
15+
# │ │ │ ┌ month (1-12)
16+
# │ │ │ │ ┌ day-of-week (0-6, Sun=0)
17+
# │ │ │ │ │
18+
# * * * * * command
19+
#
20+
# Times are UTC unless you set TZ in .env. Use the absolute binary path.
21+
#
22+
# Uncomment / edit the examples below to enable reminders:
23+
24+
# Weekdays at 09:00 — stand-up nudge:
25+
# 0 9 * * 1-5 /usr/local/bin/odek run --deliver "Reminder: daily stand-up starts in 15 minutes."
26+
27+
# Every day at 18:30 — end-of-day wrap-up prompt:
28+
# 30 18 * * * /usr/local/bin/odek run --deliver "End of day: summarize what I shipped and what's open for tomorrow."

docker/docker-compose.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,9 @@ services:
6666
- ./workspace:/workspace
6767
- ./.odek:/home/odek/.odek
6868
- ./config.restricted.json:/home/odek/.odek/config.json:ro
69+
# Scheduled reminders: supercronic runs the jobs in ./crontab and
70+
# delivers results to ODEK_TELEGRAM_DEFAULT_CHAT_ID via `--deliver`.
71+
- ./crontab:/home/odek/.crontabs/crontab:ro
6972
restart: unless-stopped
7073

7174
# ── Telegram bot — Godmode (no prompts; unrestricted) ──
@@ -83,4 +86,7 @@ services:
8386
- ./workspace:/workspace
8487
- ./.odek:/home/odek/.odek
8588
- ./config.godmode.json:/home/odek/.odek/config.json:ro
89+
# Scheduled reminders: supercronic runs the jobs in ./crontab and
90+
# delivers results to ODEK_TELEGRAM_DEFAULT_CHAT_ID via `--deliver`.
91+
- ./crontab:/home/odek/.crontabs/crontab:ro
8692
restart: unless-stopped

internal/telegram/config.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,11 @@ func ConfigFromEnv(base TelegramConfig) TelegramConfig {
9797
if v := os.Getenv("ODEK_TELEGRAM_LOG_FILE"); v != "" {
9898
cfg.LogFile = v
9999
}
100+
if v := os.Getenv("ODEK_TELEGRAM_DEFAULT_CHAT_ID"); v != "" {
101+
if id, err := strconv.ParseInt(v, 10, 64); err == nil {
102+
cfg.DefaultChatID = id
103+
}
104+
}
100105

101106
return cfg
102107
}

internal/telegram/config_test.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,41 @@ func TestConfigFromEnv_pollTimeoutEmpty(t *testing.T) {
205205
}
206206
}
207207

208+
func TestConfigFromEnv_defaultChatID(t *testing.T) {
209+
t.Setenv("ODEK_TELEGRAM_DEFAULT_CHAT_ID", "8592463065")
210+
cfg := ConfigFromEnv(DefaultConfig())
211+
if cfg.DefaultChatID != 8592463065 {
212+
t.Errorf("DefaultChatID = %d, want 8592463065", cfg.DefaultChatID)
213+
}
214+
}
215+
216+
func TestConfigFromEnv_defaultChatIDNegative(t *testing.T) {
217+
// Group/channel chat IDs are negative; ParseInt must accept them.
218+
t.Setenv("ODEK_TELEGRAM_DEFAULT_CHAT_ID", "-1001234567890")
219+
cfg := ConfigFromEnv(DefaultConfig())
220+
if cfg.DefaultChatID != -1001234567890 {
221+
t.Errorf("DefaultChatID = %d, want -1001234567890", cfg.DefaultChatID)
222+
}
223+
}
224+
225+
func TestConfigFromEnv_defaultChatIDInvalidKeepsBase(t *testing.T) {
226+
t.Setenv("ODEK_TELEGRAM_DEFAULT_CHAT_ID", "not-a-number")
227+
base := DefaultConfig()
228+
base.DefaultChatID = 42 // a non-zero base must survive an unparseable env value
229+
cfg := ConfigFromEnv(base)
230+
if cfg.DefaultChatID != 42 {
231+
t.Errorf("DefaultChatID = %d, want base 42 preserved", cfg.DefaultChatID)
232+
}
233+
}
234+
235+
func TestConfigFromEnv_defaultChatIDEmpty(t *testing.T) {
236+
t.Setenv("ODEK_TELEGRAM_DEFAULT_CHAT_ID", "")
237+
cfg := ConfigFromEnv(DefaultConfig())
238+
if cfg.DefaultChatID != 0 {
239+
t.Errorf("DefaultChatID = %d, want default 0", cfg.DefaultChatID)
240+
}
241+
}
242+
208243
func TestConfigFromEnv_maxMsgLength(t *testing.T) {
209244
t.Setenv("ODEK_TELEGRAM_MAX_MSG_LENGTH", "1024")
210245
cfg := ConfigFromEnv(DefaultConfig())

0 commit comments

Comments
 (0)