Skip to content
Open
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
6 changes: 6 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
.git
node_modules
dist
.telex
.env
*.log
17 changes: 12 additions & 5 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
# Required. Create the bot with @BotFather.
TELEGRAM_BOT_TOKEN=123456:replace-me
# Telegram connector. Create the bot with @BotFather. Optional when the Slack
# connector below is configured; at least one connector must be set.
# TELEGRAM_BOT_TOKEN=123456:replace-me

# Required. Comma-separated Telegram numeric user IDs. Messages from everyone
# else are ignored, including guest-mode mentions.
TELEGRAM_ALLOWED_USER_IDS=123456789
# Comma-separated Telegram numeric user IDs. Messages from everyone else are
# ignored, including guest-mode mentions. Set together with the bot token.
# TELEGRAM_ALLOWED_USER_IDS=123456789

# Optional Slack connector (Socket Mode). Set all three together to enable it;
# see docs/slack.md for the full setup guide, including a pasteable app manifest.
# SLACK_BOT_TOKEN=xoxb-replace-me
# SLACK_APP_TOKEN=xapp-replace-me
# SLACK_ALLOWED_USER_IDS=U0123ABCDEF
Comment thread
coderabbitai[bot] marked this conversation as resolved.

# Public HTTPS origin serving the Mini App, normally through a reverse proxy.
# Leave it unset to expose the Mini App through a TryCloudflare quick tunnel
Expand Down
34 changes: 34 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Telex container image. The container is the isolation boundary: the process
# runs as the unprivileged `telex` user and all state lives under /data.
FROM node:24-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --no-audit --no-fund
COPY . .
RUN npm run build && npm prune --omit=dev

FROM node:24-slim
ARG TELEX_UID=1001
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl git ripgrep \
&& curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
-o /usr/share/keyrings/githubcli-archive-keyring.gpg \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \
> /etc/apt/sources.list.d/github-cli.list \
&& apt-get update \
&& apt-get install -y --no-install-recommends gh \
Comment thread
coderabbitai[bot] marked this conversation as resolved.
&& rm -rf /var/lib/apt/lists/* \
&& useradd --create-home --uid "${TELEX_UID}" --user-group telex
WORKDIR /app
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY --from=build /app/package.json /app/codex.version ./
COPY docker/entrypoint.sh /usr/local/bin/telex-entrypoint
RUN chmod 0755 /usr/local/bin/telex-entrypoint
ENV TELEX_DATA_DIR=/data/telex \
CODEX_WORKSPACE=/data/workspace
VOLUME /data
# The entrypoint starts as root only to take ownership of freshly created
# volumes, then drops to the unprivileged telex user before running Telex.
ENTRYPOINT ["telex-entrypoint"]
CMD ["node", "dist/index.js"]
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ Telex supports private conversations, scheduled runs, automatic Telegram voice-m
- A Telegram bot token from [@BotFather](https://t.me/BotFather)
- The numeric Telegram user IDs allowed to use the bot
- Optionally, a public HTTPS URL for the settings Mini App; without one, Telex exposes it through an automatic quick tunnel
- Optionally, a Slack app for the [Slack connector](docs/slack.md)

In BotFather, enable guest mode if the bot should answer mentions in group chats. Guest replies are intentionally one-shot: they do not persist a thread, cannot answer interactive approval prompts, and cannot upload newly generated local files. When a guest result includes a file, Telex explains that file attachments require a direct bot chat instead of silently omitting it.

Expand Down Expand Up @@ -170,6 +171,19 @@ In the other direction, Telex uploads completed Codex image-generation results a

Telegram's hosted Bot API only allows bots to download files up to 20 MB and upload general files up to 50 MB. Telex still forwards the file metadata and a clear limitation notice when a download or upload is unavailable. Set `TELEGRAM_API_BASE` to a [local Bot API server](https://core.telegram.org/bots/api#using-a-local-bot-api-server) to remove the download limit and support larger uploads.

## Docker

[docs/docker.md](docs/docker.md) describes the container image: Telex runs as
an unprivileged user with all state under a `/data` volume, and the container
replaces Codex's Linux sandbox as the isolation boundary. A Compose example
lives in [docker/docker-compose.example.yml](docker/docker-compose.example.yml).

## Slack connector

Telex can additionally bridge Codex into Slack over [Socket Mode](https://docs.slack.dev/apis/events-api/using-socket-mode) — no public URL required. Direct messages stream progress like the Telegram private chat; in channels the bot answers mentions in threads, with each thread acting as its own Codex conversation. Approvals arrive as buttons, files flow in both directions, and commands are available as `/telex <subcommand>` (Slack reserves bare `/new`-style messages for its own slash-command system). Scheduled runs created from Slack notify back into the originating channel or thread.

Set `SLACK_BOT_TOKEN`, `SLACK_APP_TOKEN`, and `SLACK_ALLOWED_USER_IDS` together to enable it. [docs/slack.md](docs/slack.md) walks through creating the Slack app from a pasteable manifest, collecting both tokens, and first steps. The settings Mini App stays Telegram-only because it authenticates through Telegram `initData`.

## Scheduled runs

Ask Codex naturally, for example, “Every weekday at 9, check this project for failed CI runs” or “Revisit this task every hour and notify me only if something changed.” Telex exposes a host-managed `automation_update` tool to new Codex tasks and stores each schedule with an explicit time zone. A task created before upgrading does not have that tool in its persisted definition; send `/new` once before asking it to create or edit schedules. `/schedules` remains available for viewing them.
Expand Down
19 changes: 19 additions & 0 deletions docker/docker-compose.example.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Example Compose deployment. Copy next to your .env (SLACK_* and/or
# TELEGRAM_* variables) and run: docker compose up -d --build
#
# State lives in the `telex-data` volume. To keep it on the host under a
# dedicated user instead, replace the volume with a bind mount owned by
# uid 1001 (the in-container `telex` user):
# volumes:
# - /srv/telex/data:/data
services:
telex:
build: ..
env_file: .env
init: true
restart: unless-stopped
volumes:
- telex-data:/data

volumes:
telex-data:
39 changes: 39 additions & 0 deletions docker/entrypoint.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
#!/bin/sh
# Seed the Codex configuration on a fresh volume. Codex's own Linux sandbox
# needs user namespaces, which Docker's default seccomp/AppArmor confinement
# blocks; the container itself is the isolation boundary instead.
set -e

data_dir="${TELEX_DATA_DIR:-/data/telex}"
workspace="${CODEX_WORKSPACE:-/data/workspace}"

# Fresh named volumes are created owned by root; take ownership of the state
# roots (non-recursively), then continue as the unprivileged telex user.
if [ "$(id -u)" = "0" ]; then
mkdir -p "${data_dir}" "${workspace}"
chown telex:telex /data "${data_dir}" "${workspace}" 2>/dev/null || true
exec env HOME=/home/telex runuser -u telex -- "$0" "$@"
fi

mkdir -p "${data_dir}/codex-home" "${workspace}"
Comment on lines +10 to +18

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- docker/entrypoint.sh ---'
cat -n docker/entrypoint.sh
printf '%s\n' '--- related Docker configuration ---'
rg -n -C 3 'entrypoint|data_dir|workspace|codex-home|/data|/workspace|runuser|USER ' Dockerfile* docker compose*.yml compose*.yml 2>/dev/null || true
printf '%s\n' '--- references to state paths ---'
rg -n -C 2 'codex-home|workspace|data_dir|HOME=/home/telex|/data' . --glob '!node_modules' --glob '!dist' --glob '!build'

Repository: sadfun/telex

Length of output: 50371


🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- Dockerfile ---'
cat -n Dockerfile
printf '%s\n' '--- docker/docs state and ownership guidance ---'
cat -n docs/docker.md
printf '%s\n' '--- application directory creation and persistence writes ---'
rg -n -C 4 'ensureDirectory|writeFile|appendFile|mkdir\(|rm\(|rename\(|dataDirectory|workspace|codexHome|outbound|conversations|automations' src/index.ts src --glob '*.ts' | head -n 500
printf '%s\n' '--- entrypoint behavior model ---'
python3 - <<'PY'
import os
import pathlib
import tempfile

with tempfile.TemporaryDirectory() as root:
    data = pathlib.Path(root) / "data"
    state = data / "telex"
    workspace = data / "workspace"
    nested = state / "codex-home" / "config.toml"
    nested.parent.mkdir(parents=True)
    workspace.mkdir(parents=True)
    nested.write_text("existing")
    os.chown(data, 0, 0)
    os.chown(state, 0, 0)
    os.chown(workspace, 0, 0)
    os.chown(nested.parent, 0, 0)
    os.chown(nested, 0, 0)

    # This models chown telex:telex /data "$data_dir" "$workspace":
    # only the named paths change; descendants do not.
    target_uid = target_gid = os.getuid()
    for path in (data, state, workspace):
        os.chown(path, target_uid, target_gid)

    print("data owner:", (data.stat().st_uid, data.stat().st_gid))
    print("state owner:", (state.stat().st_uid, state.stat().st_gid))
    print("workspace owner:", (workspace.stat().st_uid, workspace.stat().st_gid))
    print("codex-home owner:", (nested.parent.stat().st_uid, nested.parent.stat().st_gid))
    print("config owner:", (nested.stat().st_uid, nested.stat().st_gid))
PY

Repository: sadfun/telex

Length of output: 34700


🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- relevant application startup and filesystem helpers ---'
cat -n src/index.ts | sed -n '45,115p'
cat -n src/shared/fs.ts
printf '%s\n' '--- persistent store write paths ---'
rg -n -C 5 'atomicWrite|writeFile|rename|mkdir' src --glob '*store*.ts' --glob 'src/automations/*.ts' --glob 'src/shared/fs.ts'
printf '%s\n' '--- deterministic entrypoint checks ---'
python3 - <<'PY'
from pathlib import Path
import re

text = Path("docker/entrypoint.sh").read_text()
match = re.search(r'^\s*chown\s+(.+?)\s+2>/dev/null\s+\|\|\s+true\s*$', text, re.M)
assert match, "The suppressed chown command was not found"
command = match.group(0)
print("suppressed chown:", command)
print("recursive flag present:", "-R" in match.group(1).split())
print("failure is ignored:", "|| true" in command)
print("runuser follows chown:", text.index("runuser") > text.index("chown"))
print("post-drop mkdir follows runuser:", text.index('mkdir -p "${data_dir}/codex-home"') > text.index("runuser"))
PY

Repository: sadfun/telex

Length of output: 12309


Fail before dropping privileges when ownership setup is incomplete.

Line 14 ignores chown failures and changes ownership only on the named paths. A reused /data volume can retain root-owned descendants such as codex-home, toolchains, or workspace state. The telex process can then fail during startup or later writes.

Fail when chown fails and apply a scoped recursive ownership migration to ${data_dir} and ${workspace} before runuser, or validate all required descendants and exit.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docker/entrypoint.sh` around lines 10 - 18, The root branch of the entrypoint
currently ignores ownership failures and only updates top-level paths. In the
id-u check around mkdir/chown, recursively migrate ownership of ${data_dir} and
${workspace} to telex:telex, and remove the silent failure fallback so any chown
failure exits before the runuser transition.


config="${data_dir}/codex-home/config.toml"
if [ ! -f "${config}" ]; then
cat > "${config}" <<'EOF'
# Managed by Telex. You can edit this file.
# The container provides isolation; Codex's Linux sandbox is unavailable here.
approval_policy = "on-request"
sandbox_mode = "danger-full-access"
web_search = "live"
cli_auth_credentials_store = "file"
project_root_markers = []
EOF
fi

# With GH_TOKEN set, let git clone/fetch over HTTPS through gh's credential
# helper (gh itself reads GH_TOKEN directly).
if [ -n "${GH_TOKEN:-}" ] && command -v gh >/dev/null 2>&1; then
gh auth setup-git >/dev/null 2>&1 || true
fi

exec "$@"
41 changes: 41 additions & 0 deletions docs/docker.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Running Telex in Docker

The image runs Telex as the unprivileged `telex` user (uid 1001) with all
state — Codex home (auth, config, threads), the pinned Codex toolchain, the
workspace, and conversation/automation stores — under the `/data` volume.
No ports need to be published: both the Telegram and Slack connectors dial
out (long polling / Socket Mode).

```bash
cp docker/docker-compose.example.yml docker-compose.yml
cp .env.example .env # fill in SLACK_* and/or TELEGRAM_* variables
docker compose up -d --build
docker compose logs -f
```

To keep state on the host under a dedicated user instead of a named volume:

```bash
useradd --system --uid 1001 --user-group --shell /usr/sbin/nologin telex
mkdir -p /srv/telex/data && chown -R telex:telex /srv/telex/data
# then bind-mount /srv/telex/data:/data in the compose file
```

## Codex sandboxing inside the container

On bare Linux, Codex sandboxes shell commands with a bubblewrap helper that
needs unprivileged user namespaces. Docker's default seccomp and AppArmor
confinement blocks that, so the entrypoint seeds `config.toml` with
`sandbox_mode = "danger-full-access"` on a fresh volume: the container — an
isolated filesystem, an unprivileged user, and no host mounts beyond `/data`
— is the sandbox boundary instead. Keep that in mind before bind-mounting
anything sensitive into the container.

## Notes

- The settings Mini App binds to `HOST:PORT` inside the container; publish
the port and set `PUBLIC_URL` if you use it with the Telegram connector.
- Telex's release self-update (`/update`, `TELEX_UPDATE_MODE=auto`) does not
apply to containers — rebuild the image to update instead.
- The first start on a fresh volume downloads the pinned Codex CLI from npm
into `/data/telex/toolchains`.
177 changes: 177 additions & 0 deletions docs/slack.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
# Slack connector

Telex can bridge Codex into Slack alongside Telegram. The connector uses
[Socket Mode](https://docs.slack.dev/apis/events-api/using-socket-mode), so it
needs no public URL, webhook endpoint, or reverse proxy — the bridge dials out
to Slack exactly like the Telegram long-polling connection.

What works in Slack:

- Direct messages with the bot: send a message, watch live progress, get the
final answer, exchange file attachments.
- Channels and group DMs: mention the bot (`@Telex fix the build`) and it
answers in a thread. Every message addressed to the bot needs a mention —
including follow-ups in the same thread — so human discussion around it
stays untouched. Each thread is its own Codex conversation with persistent
context. When first mentioned inside an existing thread, the bot reads the
earlier thread messages (up to 100, newest-biased) as context, so it
understands the discussion it was called into.
- Approvals: when Codex asks for confirmation, the question arrives as Slack
buttons.
- Scheduled runs: results are delivered to the channel or thread that created
them, with a Continue button.
- Commands: `/telex new`, `/telex status`, and friends (Slack reserves plain
`/new`-style messages for its own slash commands, so Telex registers a single
`/telex` command with subcommands).

When Codex creates a report, archive, image, or another deliverable, Telex can
upload it into the same Slack DM or thread. The connector-aware system context
instructs Codex to link the workspace-local deliverable in its final answer;
Telex validates and snapshots that file before uploading it with Slack's
`files:write` permission. Local links used only as code references are not
uploaded.

The settings Mini App remains Telegram-only because it authenticates through
Telegram. Everything else — including `/telex login` for the ChatGPT sign-in —
works from Slack.

## 1. Create the Slack app

1. Open <https://api.slack.com/apps> and click **Create New App**.
2. Choose **From a manifest**, pick your workspace, and paste the manifest
below (YAML tab). Rename the app if you like — the name is what you will
@mention.
3. Click **Create**.

```yaml
display_information:
name: Telex
description: Codex in your Slack
background_color: "#1a1d21"
features:
app_home:
messages_tab_enabled: true
messages_tab_read_only_enabled: false
bot_user:
display_name: Telex
always_online: true
slash_commands:
- command: /telex
description: Control Telex (new, stop, status, help…)
usage_hint: "new | back | stop | status | help"
should_escape: false
oauth_config:
scopes:
bot:
- chat:write
- im:history
- channels:history
- groups:history
- mpim:history
- files:read
- files:write
- users:read
- commands
settings:
event_subscriptions:
bot_events:
- message.channels
- message.groups
- message.im
- message.mpim
interactivity:
is_enabled: true
org_deploy_enabled: false
socket_mode_enabled: true
token_rotation_enabled: false
```

## 2. Collect the two tokens

- **App-level token** (`xapp-…`): in the app's **Basic Information** page,
scroll to **App-Level Tokens**, click **Generate Token and Scopes**, name it
(for example `telex-socket`), add the `connections:write` scope, and
generate. Copy the `xapp-…` value — this is `SLACK_APP_TOKEN`.
- **Bot token** (`xoxb-…`): open **Install App** (or **OAuth & Permissions**),
click **Install to Workspace**, and approve. Copy the **Bot User OAuth
Token** — this is `SLACK_BOT_TOKEN`.

## 3. Decide who is allowed

Telex answers only authorized users. Two modes:

- **Allowlist**: comma-separated member IDs. In Slack, open a profile →
**⋯ (More)** → **Copy member ID**; it looks like `U0123ABCDEF`.
- **Whole workspace**: `SLACK_ALLOWED_USER_IDS=*` authorizes every regular
member of the workspace the app is installed in. Bots, deactivated
accounts, single/multi-channel guests, and Slack Connect participants from
other workspaces are still rejected (membership is verified through
`users.info` and cached for ten minutes, so deactivating someone in Slack
locks them out without a restart).

Everyone shares one Telex: the same Codex account, the same workspace
directory on the host, and the same conversation state per channel/thread.
Open it to the whole workspace only if that is acceptable.

Optionally, `SLACK_ADMIN_USER_IDS` (comma-separated member IDs) restricts
instance-wide commands — `/telex config`, `login`, `logout`, `reload`,
`restart`, `update` — to the listed users. Unset, every authorized user may
run them. `/telex config` opens interactive Codex settings built from Slack
buttons (model, reasoning effort, speed tier, approvals, sandbox, web
search) in the bot DM — the Slack counterpart of the Telegram Mini App.

## 4. Configure Telex

Add the three variables to the environment (`.env` for a source checkout, or
`~/.config/telex/telex.env` for an installed release):

```dotenv
SLACK_BOT_TOKEN=xoxb-…
SLACK_APP_TOKEN=xapp-…
SLACK_ALLOWED_USER_IDS=U0123ABCDEF,U0456GHIJKL
```

All three must be set together; leaving them all unset keeps the connector
disabled. Telegram is optional when Slack is configured — with only the Slack
variables set, Telex runs Slack-only (the Telegram bot and the settings Mini
App stay off). Restart Telex and check the log for
`Slack bot connected through Socket Mode`.

## 5. Talk to it

- **Direct message**: open the app under **Apps** in the Slack sidebar and
send a message. If Slack says the app cannot receive messages, enable the
Messages Tab: app settings → **App Home** → check *Allow users to send Slash
commands and messages from the messages tab* (the manifest above enables it,
but workspaces occasionally need a re-toggle), then reload Slack.
- **Channel**: invite the bot (`/invite @Telex`), then mention it:
`@Telex what does this repo do?`. The reply opens a thread; address it
there with a mention each time (`@Telex and now check the tests`) — the
thread's Codex conversation continues across mentions.
- **Commands**: `/telex help` anywhere, or prefix a command in a mention:
`@Telex /new`. In the bot DM, plain `/new` will not reach Telex — Slack
intercepts everything that starts with `/` — so use `/telex new`.
Conversation-scoped commands (`new`, `back`, `stop`, `schedules`,
`continue`) only work as `/telex …` in the bot DM; in a channel each thread
is its own conversation, so run them inside the thread as a mention
(`@Telex /stop`).
- **Sign-in**: if Codex is not signed in yet, `/telex login` in the bot DM
returns the ChatGPT device-code link, exactly like `/login` on Telegram.

## Notes and limits

- **Authorization**: messages, commands, and button clicks from users outside
`SLACK_ALLOWED_USER_IDS` are ignored (and logged). Scheduled runs re-check
the owner against the allowlist before every unattended execution.
- **Thread context after a restart**: the "already read this thread" memory
is in-process, so the first mention after a Telex restart re-reads the
thread history. The Codex conversation itself is persisted and continues.
- **Attachments**: inbound files are downloaded through Slack's private file
URLs with the bot token (never sent to third-party hosts); generated files
are uploaded back with `files.uploadV2`. Slack voice clips are transcribed
the same way Telegram voice messages are.
- **Formatting**: Codex's Markdown is converted to Slack mrkdwn (headings
become bold lines, `**bold**` becomes `*bold*`, links become
`<url|label>`); code blocks pass through untouched.
- **Rate limits**: live progress is streamed by editing a single message at
most every 1.5 seconds, which stays inside Slack's `chat.update` budget.
Loading