Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions cmd/odek/telegram.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
44 changes: 44 additions & 0 deletions cmd/odek/telegram_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions docker/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
36 changes: 33 additions & 3 deletions docker/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"]
25 changes: 25 additions & 0 deletions docker/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
```

Expand Down Expand Up @@ -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.
Expand Down
63 changes: 63 additions & 0 deletions docker/cron-entrypoint.sh
Original file line number Diff line number Diff line change
@@ -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 "$@"
28 changes: 28 additions & 0 deletions docker/crontab
Original file line number Diff line number Diff line change
@@ -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 "<task>"`. --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."
14 changes: 14 additions & 0 deletions docker/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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) ──
Expand All @@ -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
5 changes: 5 additions & 0 deletions internal/telegram/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
35 changes: 35 additions & 0 deletions internal/telegram/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
Loading