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
3 changes: 0 additions & 3 deletions deploy/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,6 @@ Then fill the env files and run `install.sh` again. It enables each job once its
files have content.

```bash
runuser -u zendesk -- env HOME=/home/zendesk sh -c 'curl -fsSL https://claude.ai/install.sh | bash'
runuser -u zendesk -- env HOME=/home/zendesk /home/zendesk/.local/bin/claude # then /login

cp /opt/session-ops/deploy/nginx-webhooks.conf /etc/nginx/sites-available/webhooks.session.codes
ln -s /etc/nginx/sites-available/webhooks.session.codes /etc/nginx/sites-enabled/
nginx -t && certbot --nginx --redirect -d webhooks.session.codes && nginx -t && systemctl reload nginx
Expand Down
7 changes: 0 additions & 7 deletions deploy/env/expiry.toml.example
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,3 @@
# Crowdin → Account Settings → API → Personal Access Tokens, the Expires column.
# A date, or "never" when it does not expire or this host does not use it.
#CROWDIN_API_TOKEN = "never"

# Only for a Claude Code token installed before token-expiry first ran: it otherwise
# dates a token from the day it first saw it. Ignored once that token is replaced.
# The fingerprint is printed by a dry run of token-expiry: see docs/jobs/token-expiry.md.
#[issued.CLAUDE_CODE_OAUTH_TOKEN]
#fingerprint = "3f2a9c01b4de"
#date = 2025-11-20
7 changes: 2 additions & 5 deletions deploy/env/zendesk.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,8 @@
# A `#` starts a comment only as a line's first character. Restart zendesk-relay after
# an edit: it reads this file at start.

# Not HOME, which systemd sets from the account. PATH replaces systemd's default.
PATH=/home/zendesk/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
# Optional: a one-year token from `claude setup-token` on a machine with a browser, same
# subscription. Without it the interactive login expires within weeks.
#CLAUDE_CODE_OAUTH_TOKEN=
# Claude API key for the digest, the transcripts and the `claude:` notes.
ANTHROPIC_API_KEY=
ZENDESK_SUBDOMAIN=
# Authors every comment the reply flow posts.
ZENDESK_EMAIL=
Expand Down
13 changes: 2 additions & 11 deletions deploy/zendesk-relay.service
Original file line number Diff line number Diff line change
Expand Up @@ -29,26 +29,17 @@ NoNewPrivileges=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectSystem=strict
# tmpfs rather than yes: the Claude Code CLI the translation shells out to reads its
# login from $HOME, and `yes` makes /home inaccessible. tmpfs masks /home with an
# empty one and BindPaths mounts just this account's back in, so every other home on
# the box stays hidden. The bind is the real on-disk directory — the tmpfs is a mask
# for this service's mount namespace, not storage, so nothing here is lost on reboot.
ProtectHome=tmpfs
BindPaths=/home/zendesk
ProtectHome=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
# AF_UNIX for asyncio's self-pipe, a socketpair.
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
RestrictNamespaces=yes
LockPersonality=yes
MemoryDenyWriteExecute=yes
SystemCallFilter=@system-service
SystemCallErrorNumber=EPERM
# The relay itself still stores nothing. The writable path is for the Claude Code
# CLI, which keeps its credentials and cache under $HOME; without it the translation
# fails with a bare non-zero exit from `claude`.
ReadWritePaths=/home/zendesk
ReadOnlyPaths=/opt/session-ops

[Install]
Expand Down
12 changes: 3 additions & 9 deletions docs/jobs/token-expiry.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ as Crowdin's, counts from 00:00 UTC that day.
| | |
| --- | --- |
| Runs | `session-ops@token-expiry.timer`, daily at 09:00 Melbourne |
| Secrets | `/etc/session-ops/alerts.env`: `ALERT_DISCORD_WEBHOOK_URL`; `github-prs.env` and `zendesk.env` for the tokens it checks |
| Secrets | `/etc/session-ops/alerts.env`: `ALERT_DISCORD_WEBHOOK_URL`; `github-prs.env` for the GitHub token |
| Dates | `/etc/session-ops/expiry.toml`, from [`expiry.toml.example`](../../deploy/env/expiry.toml.example) |
| Dry run | `session-ops run token-expiry --dry-run` prints each token's expiry and the alert it would post. On the host, through `systemd-run` as in [deploy/README.md](../../deploy/README.md). |
| Logs | `journalctl -u session-ops@token-expiry -n 50 --no-pager` |
Expand All @@ -16,15 +16,9 @@ as Crowdin's, counts from 00:00 UTC that day.
| --- | --- |
| `GITHUB_PRS_TOKEN` | GitHub's `github-authentication-token-expiration` header; nothing to update on rotation |
| `CROWDIN_API_TOKEN` | `expiry.toml`: the Expires column at https://crowdin.com/settings#api-key |
| `CLAUDE_CODE_OAUTH_TOKEN` | A year after the job first saw it, by fingerprint; nothing to update on rotation |

`CROWDIN_API_TOKEN` is reported on every daily run until `expiry.toml` has a date or
`"never"` for it.

The Claude Code token's first sighting lives in `/var/lib/session-ops/token-expiry/state.json`.
For a token installed before the job first ran, or if that file is lost, add its real
issue date under `[issued]` with the fingerprint the dry run prints; the entry is
ignored once the token changes. A date that is wrong is not caught: the Zendesk
digest's failure alert, which names a dead Claude login, is then the backstop.

The Zendesk API token, the Discord webhooks and the GitHub App key do not expire.
The Zendesk API token, the Claude API key, the Discord webhooks and the GitHub App key
do not expire.
16 changes: 7 additions & 9 deletions docs/jobs/zendesk-digest.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ Claude reviews the Zendesk tickets awaiting a reply — `new` and `open`, no app
| | |
| --- | --- |
| Runs | `session-ops-queue.timer`, Mon–Fri from 10:00 Australia/Melbourne, after `github-prs-digest`: `zendesk-resolve-reviews --apply`, then `zendesk-triage` |
| Secrets | `/etc/session-ops/zendesk.env`: the Zendesk API token, the triage channel's webhook, and the Claude Code CLI login of the `zendesk` account |
| Secrets | `/etc/session-ops/zendesk.env`: the Zendesk API token, the triage channel's webhook, and the Claude API key |
| Dry run | `session-ops run zendesk-digest --dry-run`; `uv run zendesk-triage --window-hours 72 --dry-run` from a checkout. On the host, through `systemd-run` as in [deploy/README.md](../../deploy/README.md). |
| Re-run | `systemctl start session-ops@zendesk-digest.service` |
| Logs | `journalctl -u session-ops@zendesk-digest -n 50 --no-pager` |
Expand Down Expand Up @@ -49,7 +49,7 @@ The star-floor machinery (`partition_reviews`, `--review-star-floor`, `--include

Twitter DM tickets arrive with `description` identical to `subject` — both just `"Conversation with <handle>"` — which is 15% of non-review tickets and unclassifiable as fetched. For those only, `hydrate_descriptions` fetches a page of up to 10 comments and joins every body that differs from the subject into the description; later replies often carry the actual detail. Hydration is an enrichment, so an HTTP error or an unreachable endpoint leaves the ticket as-is rather than failing the run (`--no-hydrate` to skip it entirely).

The script (`zendesk-triage`, [triage.py](../../src/session_ops/zendesk/triage.py)) fetches the tickets in a rolling time window, classifies the whole batch in one schema-enforced request through the Claude Code CLI, and posts a Discord digest: a short header, the new tickets not in English, one line per ticket worth looking into, then the abuse reports and the unflagged tickets.
The script (`zendesk-triage`, [triage.py](../../src/session_ops/zendesk/triage.py)) fetches the tickets in a rolling time window, classifies the whole batch in one schema-enforced request to the Claude API, and posts a Discord digest: a short header, the new tickets not in English, one line per ticket worth looking into, then the abuse reports and the unflagged tickets.

Each line leads with a severity marker, a category emoji and a platform icon, links the ticket id, and carries the model's one-line summary plus its root-cause guess:

Expand Down Expand Up @@ -115,9 +115,7 @@ Two caveats worth knowing:

## Claude Authentication

There is no Claude key. Both Claude calls — the digest's classification and the reply flow's translation — shell out to the locally installed Claude Code CLI (`claude --print`), which authenticates as whoever it is logged in as. On the host that is the service user; see [deploy/README.md](../../deploy/README.md).

The trade is process startup, a few seconds per call, against holding an API credential on the box. That is invisible on a nightly digest, and on the reply dialog Discord keeps the interaction open while it runs.
Every Claude call — the digest's classification, the transcripts and the `claude:` notes — goes to the Claude API with `ANTHROPIC_API_KEY` from `/etc/session-ops/zendesk.env`. A refused key or an exhausted balance is named as such in the failure alert, since re-running fixes neither.

## Optional Configuration

Expand All @@ -140,9 +138,9 @@ The trade is process startup, a few seconds per call, against holding an API cre

**Opus**, because the hard part of this job isn't per-ticket classification — enum-constrained categories with prompt guidance is squarely mid-tier work. It's the two batch-wide fields: `cluster` has to spot that a German app-store review and an English bug report describe one root cause, and `priority_rank` has to stay consistent across the whole batch. Those need the model to hold ~45 heterogeneous tickets in mind at once. The exact-transcription requirement (a 66-character Session ID copied verbatim) points the same way. And the entire job costs **single-digit dollars a month** on any current model — roughly $10 on Opus 5 against $6 on Sonnet 5 and $2 on Haiku 4.5 — so trading classification quality for a few dollars would be optimising the wrong thing when the cost of a miss is an unseen security report or a crash cluster nobody grouped.

**The `opus` alias rather than a pinned id**, for every Zendesk call: the digest, the
transcripts and the `claude:` notes. The CLI resolves it to its newest Opus, so a new model
arrives with a CLI update and nothing here needs bumping. The cost: the digest's clusters,
**The newest Opus rather than a pinned id**, for every Zendesk call: the digest, the
transcripts and the `claude:` notes. Each run looks it up in the Models API, so a new model
is picked up with nothing here to bump. The cost: the digest's clusters,
priority ranks and severity calibration can shift when that happens, with no run in between
to compare. There is no override; one model is all this job supports.

Expand Down Expand Up @@ -196,7 +194,7 @@ Offline tests covering the window arithmetic, dedup partitioning, state round-tr

## Local Testing

Local runs need the `claude` CLI on `PATH` and logged in (`claude --version`), alongside the Zendesk credentials. `--dry-run` prints the Discord payload instead of posting, so no webhook is needed. Keep it to local runs: it prints ticket content. `--no-discord` prints counts only:
Local runs need `ANTHROPIC_API_KEY` alongside the Zendesk credentials. `--dry-run` prints the Discord payload instead of posting, so no webhook is needed. Keep it to local runs: it prints ticket content. `--no-discord` prints counts only:

```
uv sync
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ description = "Session Foundation's scheduled jobs and webhooks: Zendesk, GitHub
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"anthropic>=1.15",
# CLDR data: a new babel can rename a language in the generated language lists.
"babel==2.17.0",
"colorama>=0.4.6",
Expand Down
16 changes: 4 additions & 12 deletions src/session_ops/jobs.toml
Original file line number Diff line number Diff line change
Expand Up @@ -46,18 +46,12 @@ entry = "session_ops.zendesk.digest_job:main"
args = ["--state", "{state}/seen.json"]
user = "zendesk"
env_files = ["/etc/session-ops/zendesk.env"]
env = ["ZENDESK_SUBDOMAIN", "ZENDESK_EMAIL", "ZENDESK_API_TOKEN", "ZENDESK_DISCORD_WEBHOOK_URL"]
env = ["ZENDESK_SUBDOMAIN", "ZENDESK_EMAIL", "ZENDESK_API_TOKEN", "ZENDESK_DISCORD_WEBHOOK_URL",
"ANTHROPIC_API_KEY"]
channel_env = "ZENDESK_DISCORD_WEBHOOK_URL"
max_age_hours = 80
# A full 1000-ticket resolve is ten bulk jobs polled for up to 300 s each.
timeout = "90min"
# The Claude Code CLI reads its login from $HOME: mask /home and bind only this one.
unit = [
"ProtectHome=tmpfs",
"BindPaths=/home/zendesk",
"ReadWritePaths=/home/zendesk",
"RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX",
]

[[job]]
name = "crowdin-duplicates"
Expand Down Expand Up @@ -92,10 +86,8 @@ description = "Alert before a token the jobs use expires"
entry = "session_ops.monitor.token_expiry:main"
args = ["--state", "{state}/state.json"]
user = "sessionops"
# For the GitHub token it probes and the Claude Code token it fingerprints, which brings
# the rest of zendesk.env's secrets into this unit. expiry.toml holds none.
env_files = ["/etc/session-ops/alerts.env", "/etc/session-ops/github-prs.env",
"/etc/session-ops/zendesk.env"]
# github-prs.env for the token it probes. expiry.toml holds no secrets.
env_files = ["/etc/session-ops/alerts.env", "/etc/session-ops/github-prs.env"]
env = ["ALERT_DISCORD_WEBHOOK_URL"]
schedule = "*-*-* 09:00 Australia/Melbourne"
# A day, plus the hour a DST change adds and slack for the run.
Expand Down
2 changes: 1 addition & 1 deletion src/session_ops/monitor/alert.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
from session_ops.ops.runner import scrub
from session_ops.shared import http
from session_ops.shared.discord import post_to_discord
from session_ops.zendesk.claude_cli import advice
from session_ops.zendesk.claude_api import advice


EXCERPT_CHARS = 400
Expand Down
80 changes: 12 additions & 68 deletions src/session_ops/monitor/token_expiry.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,9 @@
"""
Alert before a token the jobs use expires.

Each token's expiry comes from one of three places:
Each token's expiry comes from one of two places:

- GITHUB_PRS_TOKEN: GitHub's response header, so a rotation needs nothing updated.
- CLAUDE_CODE_OAUTH_TOKEN: a year after this job first saw it, by a fingerprint kept
in --state, so a rotation needs nothing updated either. A token installed before the
job existed gets its real issue date from [issued] in the expiry file, which applies
only while that same token is in place.
- CROWDIN_API_TOKEN: exposes its expiry to no API, so it is recorded by hand, and
reported on every run until it is.

Expand All @@ -18,11 +14,6 @@
# A date, or "never" when it does not expire or this host does not use it.
CROWDIN_API_TOKEN = "never"

[issued.CLAUDE_CODE_OAUTH_TOKEN]
# As printed by --dry-run.
fingerprint = "3f2a9c01b4de"
date = 2025-11-20

Posts once as each token comes within 14 days, 7 days and 24 hours of expiring, and
once more when it has expired; a new expiry date starts it over. Times are UTC, and a
date without one counts from its start, so a run on any timezone's morning errs early.
Expand All @@ -31,14 +22,12 @@
Config:
ALERT_DISCORD_WEBHOOK_URL where the alert goes (not needed with --dry-run)
GITHUB_PRS_TOKEN probed for its expiry; skipped when unset
CLAUDE_CODE_OAUTH_TOKEN fingerprinted; skipped when unset

Usage:
session-ops-token-expiry --state /var/lib/session-ops/token-expiry/state.json
session-ops-token-expiry --dry-run # print every token's expiry and the alert
"""
import argparse
import hashlib
import json
import os
import socket
Expand All @@ -53,7 +42,7 @@
EXPIRY_FILE = "/etc/session-ops/expiry.toml"
THRESHOLDS = (1, 7, 14)
NEVER = "never"
GITHUB, RECORDED, FIRST_SEEN = "github", "recorded", "first seen"
GITHUB, RECORDED = "github", "recorded"
GITHUB_RATE_LIMIT = "https://api.github.com/rate_limit"
GITHUB_EXPIRY_HEADER = "github-authentication-token-expiration"

Expand All @@ -63,7 +52,6 @@ class Token:
env_file: str
renew: str
source: str
lifetime_days: int = None


TOKENS = {
Expand All @@ -72,17 +60,12 @@ class Token:
GITHUB),
"CROWDIN_API_TOKEN": Token("/etc/session-ops/crowdin.env",
"https://crowdin.com/settings#api-key", RECORDED),
"CLAUDE_CODE_OAUTH_TOKEN": Token("/etc/session-ops/zendesk.env",
"`claude setup-token` on a machine with a browser, "
"then restart zendesk-relay",
FIRST_SEEN, lifetime_days=365),
}


@dataclass(frozen=True)
class Config:
expires: dict
issued: dict


def start_of(day):
Expand All @@ -94,12 +77,12 @@ def as_date(value):


def load_config(path):
"""The expiry file's [expires] and [issued]; empty when it does not exist yet."""
"""The expiry file's [expires]; empty when it does not exist yet."""
try:
with open(path, "rb") as handle:
data = tomllib.load(handle)
except FileNotFoundError:
return Config({}, {})
return Config({})
except tomllib.TOMLDecodeError as exc:
sys.exit(f"{path}: {exc}")
expires = {}
Expand All @@ -108,34 +91,7 @@ def load_config(path):
if not (isinstance(value, date) or value == NEVER):
sys.exit(f"{path}: {name} must be a date (2027-03-01) or \"{NEVER}\", not {value!r}")
expires[name] = value
issued = {}
for name, entry in data.get("issued", {}).items():
when = as_date(entry.get("date")) if isinstance(entry, dict) else None
if not (isinstance(when, date) and isinstance(entry.get("fingerprint"), str)):
sys.exit(f"{path}: [issued.{name}] needs a fingerprint and a date")
issued[name] = (entry["fingerprint"], when)
return Config(expires, issued)


def fingerprint(value):
"""Enough of a hash to tell tokens apart, and nothing that helps recover one."""
return hashlib.sha256(value.encode()).hexdigest()[:12]


def issued_on(name, value, issued, seen, today):
"""When the token in `value` was issued: its [issued] date while that names this
token, else the day it was first seen. Records it in `seen`."""
current = fingerprint(value)
recorded = issued.get(name)
entry = seen.get(name, {})
if recorded and recorded[0] == current:
since = recorded[1]
elif entry.get("fingerprint") == current:
since = date.fromisoformat(entry["since"])
else:
since = today
seen[name] = {"fingerprint": current, "since": since.isoformat()}
return since
return Config(expires)


def parse_github_expiry(value):
Expand Down Expand Up @@ -224,8 +180,7 @@ def utc_now():


def load_state(path):
"""{"alerts": ..., "seen": ...}. Losing it re-dates a fingerprinted token from the
next run, which alerts late: the reason it is saved even when a post fails."""
"""{"alerts": ...}: what has been alerted, so each window posts once."""
data = {}
if path and os.path.exists(path):
try:
Expand All @@ -234,7 +189,7 @@ def load_state(path):
except (OSError, ValueError):
data = {}
data = data if isinstance(data, dict) else {}
return {"alerts": data.get("alerts", {}), "seen": data.get("seen", {})}
return {"alerts": data.get("alerts", {})}


def save_state(path, state):
Expand All @@ -244,22 +199,16 @@ def save_state(path, state):
os.replace(temporary, path)


def collect(config, environ, seen, session, now):
"""{name: expiry} for every token in use. A token set nowhere is left out, and so
is its fingerprint."""
def collect(config, environ, session):
"""{name: expiry} for every token in use. A token set nowhere is left out."""
expiries = {}
for name, token in TOKENS.items():
value = environ.get(name)
if token.source == RECORDED:
expiry = config.expires.get(name, "missing")
expiries[name] = start_of(expiry) if isinstance(expiry, date) else expiry
elif not value:
seen.pop(name, None)
elif token.source == GITHUB:
elif value:
expiries[name] = probe_github(session, value)
else:
since = issued_on(name, value, config.issued, seen, now.date())
expiries[name] = start_of(since + timedelta(days=token.lifetime_days))
return expiries


Expand All @@ -276,15 +225,10 @@ def main(argv=None):
webhook = get_env("ALERT_DISCORD_WEBHOOK_URL", args.webhook, required=not args.dry_run)
now = utc_now()
state = load_state(args.state)
expiries = collect(load_config(args.expiry_file), os.environ, state["seen"],
http.Session(), now)
expiries = collect(load_config(args.expiry_file), os.environ, http.Session())
for name in TOKENS:
expiry = expiries.get(name, "not set")
line = f"{name}: {shown(expiry) if isinstance(expiry, datetime) else expiry}"
seen = state["seen"].get(name)
if seen:
line += f" (fingerprint {seen['fingerprint']}, issued {seen['since']})"
print(line)
print(f"{name}: {shown(expiry) if isinstance(expiry, datetime) else expiry}")

due = evaluate(expiries, state["alerts"], now)
persist = bool(args.state) and not args.dry_run
Expand Down
Loading
Loading