@askalf/dario 5.4.16 → 5.4.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/docker.md ADDED
@@ -0,0 +1,233 @@
1
+ # Running dario in Docker
2
+
3
+ Official image: **`ghcr.io/askalf/dario`**
4
+
5
+ Multi-arch (`linux/amd64` + `linux/arm64`), published from the same release
6
+ workflow that ships the npm package, so image tags are always in lockstep
7
+ with `@askalf/dario` versions on npm.
8
+
9
+ ## Tags
10
+
11
+ | Tag | Tracks |
12
+ |------------|-------------------------------------------------------|
13
+ | `latest` | Latest published release |
14
+ | `vX.Y.Z` | A specific release (recommended for production pins) |
15
+ | `vX.Y` | Latest patch on a minor line |
16
+ | `vX` | Latest minor on a major line |
17
+
18
+ ## Quick start
19
+
20
+ ```sh
21
+ # 1. One-time OAuth bootstrap — interactive, manual flow (no localhost callback).
22
+ docker volume create dario-config
23
+ docker run --rm -it -v dario-config:/home/dario/.dario \
24
+ ghcr.io/askalf/dario:latest login --manual
25
+
26
+ # 2. Run the proxy. DARIO_API_KEY is REQUIRED — see "Why an API key is mandatory" below.
27
+ docker run -d --name dario \
28
+ -p 3456:3456 \
29
+ -v dario-config:/home/dario/.dario \
30
+ -e DARIO_API_KEY="$(openssl rand -hex 32)" \
31
+ ghcr.io/askalf/dario:latest
32
+
33
+ # 3. Point your tools at it (using the same key you set above).
34
+ export ANTHROPIC_BASE_URL=http://localhost:3456
35
+ export ANTHROPIC_API_KEY=<the key from step 2>
36
+ ```
37
+
38
+ ### Why an API key is mandatory
39
+
40
+ The image binds to `0.0.0.0` so port maps and k8s services can reach the
41
+ proxy. dario refuses to start on a non-loopback bind unless `DARIO_API_KEY`
42
+ is set, because an unauthenticated proxy on a reachable interface is an
43
+ open OAuth-subscription relay for anyone on the network (dario#74). The
44
+ image inherits that refusal — there is no "just don't set a key" path.
45
+
46
+ Escape hatches if you have out-of-band network controls:
47
+
48
+ - `-e DARIO_HOST=127.0.0.1` — bind loopback inside the container. Useless
49
+ for `-p` port maps, but makes sense if another container in the same
50
+ network namespace is the only client.
51
+ - `--unsafe-no-auth` as a CMD arg. Don't.
52
+
53
+ ## OAuth in a container
54
+
55
+ `dario login --manual` skips the localhost-callback flow and prints a URL you
56
+ open in any browser on any machine. Anthropic's authorize endpoint renders the
57
+ authorization code on a copy-paste page; paste it back into the container's
58
+ stdin and dario writes `~/.dario/credentials.json` into the mounted volume.
59
+ Subsequent container starts find the credentials and the `proxy` subcommand
60
+ uses them directly.
61
+
62
+ The container detector (`/.dockerenv` + cgroup probe) auto-suggests
63
+ `--manual` if you accidentally run `login` without it — but the suggestion
64
+ prints to stdout and then fails on the localhost bind, so just use `--manual`
65
+ from the start.
66
+
67
+ ### Pre-seeding credentials (no interactive container needed)
68
+
69
+ If you can't allocate a TTY (k8s Job, CI, immutable infra), run
70
+ `dario login --manual` once on your workstation, then ship
71
+ `~/.dario/credentials.json` into the volume by your usual secrets path
72
+ (SOPS, sealed-secrets, `kubectl create secret generic … --from-file`, etc.).
73
+ The refresh token in that file is good until you revoke it.
74
+
75
+ ### Headless bootstrap over HTTP (`DARIO_ADMIN=1`) — no console at all
76
+
77
+ The third option skips both the interactive container *and* the pre-seeded
78
+ file: start the container **empty** with the admin API enabled, then provision
79
+ the first account over HTTP from anywhere:
80
+
81
+ ```bash
82
+ docker run -d -v dario-data:/home/dario/.dario -p 3456:3456 \
83
+ -e DARIO_API_KEY=<proxy-key> -e DARIO_ADMIN=1 -e DARIO_ADMIN_TOKEN=<admin-token> \
84
+ ghcr.io/askalf/dario:latest
85
+
86
+ curl -s -X POST -H "authorization: Bearer <admin-token>" \
87
+ http://<host>:3456/admin/login/start # -> { authorize_url, alias, ... }
88
+ # open authorize_url in any browser, approve, paste the code back:
89
+ curl -s -X POST -H "authorization: Bearer <admin-token>" \
90
+ http://<host>:3456/admin/login/complete -d '{"alias":"account-1","code":"<code>"}'
91
+ ```
92
+
93
+ The account is routable the moment the `200` lands — the live pool hot-reloads,
94
+ no restart. Until then, LLM requests answer a truthful
95
+ `503 { "error": "No account configured" }` and `/health` reports `degraded`,
96
+ so gate any bootstrap automation on the container being *up*, not *healthy*.
97
+ Full endpoint reference, audit trail, and rate-limit behavior:
98
+ [`docs/admin-api.md`](./admin-api.md).
99
+
100
+ ## Configuration
101
+
102
+ All flags have env-var equivalents. The image sets sensible container defaults:
103
+
104
+ | Variable | Default in image | Purpose |
105
+ |---------------------------|------------------|-----------------------------------------------|
106
+ | `DARIO_HOST` | `0.0.0.0` | Bind address (image flips from `127.0.0.1`) |
107
+ | `DARIO_PORT` | `3456` | Listen port |
108
+ | `DARIO_API_KEY` | unset (**required**) | Required because of the `0.0.0.0` bind — see "Why an API key is mandatory" |
109
+ | `DARIO_CORS_ORIGIN` | unset | Override the default `http://localhost:<port>` allow-list |
110
+ | `DARIO_LOG_FILE` | unset | Path inside the container (mount a volume) |
111
+ | `DARIO_LOG_BODIES` | unset | `1` to log request/response bodies |
112
+ | `DARIO_PASSTHROUGH_BETAS` | unset | `1` to forward `anthropic-beta` headers as-is |
113
+ | `DARIO_CLAUDE_BIN` | unset | Path to a Claude Code binary (optional, for live template capture) |
114
+ | `DARIO_NO_BUN` | unset | `1` to skip the Bun auto-relaunch (not recommended) |
115
+ | `DARIO_ADMIN` | unset | `1` mounts the headless admin API at `/admin/*` — see [`docs/admin-api.md`](./admin-api.md) |
116
+ | `DARIO_ADMIN_TOKEN` | unset | Bearer token for admin calls (falls back to `DARIO_API_KEY`); enabled-but-tokenless fails closed |
117
+ | `DARIO_ADMIN_RATE_LIMIT` | unset | `off` to disable admin rate limiting (on by default) |
118
+
119
+ ### Why `DARIO_HOST=0.0.0.0` in the image
120
+
121
+ dario defaults to `127.0.0.1` on host installs because it's a local-only
122
+ proxy. In a container the loopback interface is internal to the container,
123
+ so the proxy would be unreachable through `-p` port maps or k8s services.
124
+ The image flips the default to `0.0.0.0` and pairs it with the
125
+ mandatory-API-key refusal above; the container's network namespace boundary
126
+ becomes the trust boundary instead.
127
+
128
+ ## Persistence
129
+
130
+ Mount `/home/dario/.dario` as a volume. It holds:
131
+
132
+ - `credentials.json` — OAuth tokens (access + refresh)
133
+ - `accounts/*.json` — multi-account pool entries, if you use the pool
134
+ - `cc-oauth-cache-v6.json` — cached CC OAuth config (auto-refreshes)
135
+ - `oauth-config.override.json` — user-supplied OAuth config override
136
+
137
+ Without a volume, you'd lose the refresh token on every container restart and
138
+ have to re-run `dario login` each time.
139
+
140
+ ## Healthcheck
141
+
142
+ The image ships a Docker `HEALTHCHECK` that hits `/health` every 30s. Once an
143
+ account is loaded and its OAuth is usable the endpoint returns HTTP 200
144
+ `{"status":"ok", ...}`; when OAuth is broken/absent it returns HTTP 503
145
+ `{"status":"degraded", ...}`. Use the same endpoint for k8s liveness/readiness
146
+ probes:
147
+
148
+ ```yaml
149
+ livenessProbe:
150
+ httpGet: { path: /health, port: 3456 }
151
+ periodSeconds: 30
152
+ readinessProbe:
153
+ httpGet: { path: /health, port: 3456 }
154
+ periodSeconds: 5
155
+ ```
156
+
157
+ > **Empty admin-mode start:** with `DARIO_ADMIN=1` and no accounts yet,
158
+ > `/health` is `degraded`/503 by design (every LLM call would 503 until an
159
+ > account exists), so a readiness probe on `/health` holds the pod out of
160
+ > rotation until you provision the first account via the
161
+ > [admin API](./admin-api.md). Provision before relying on readiness, or use a
162
+ > long-window `startupProbe` around the bootstrap.
163
+
164
+ ## Kubernetes example
165
+
166
+ ```yaml
167
+ apiVersion: v1
168
+ kind: Secret
169
+ metadata: { name: dario-credentials }
170
+ type: Opaque
171
+ stringData:
172
+ # The API key clients send as `ANTHROPIC_API_KEY` to reach the proxy.
173
+ api-key: <SOME_RANDOM_SECRET>
174
+ data:
175
+ # base64-encoded contents of ~/.dario/credentials.json from a prior
176
+ # `dario login --manual` on your workstation.
177
+ credentials.json: <BASE64>
178
+ ---
179
+ apiVersion: apps/v1
180
+ kind: Deployment
181
+ metadata: { name: dario }
182
+ spec:
183
+ replicas: 1
184
+ selector: { matchLabels: { app: dario } }
185
+ template:
186
+ metadata: { labels: { app: dario } }
187
+ spec:
188
+ containers:
189
+ - name: dario
190
+ image: ghcr.io/askalf/dario:latest
191
+ ports: [{ containerPort: 3456 }]
192
+ env:
193
+ - { name: DARIO_API_KEY, valueFrom: { secretKeyRef: { name: dario-credentials, key: api-key } } }
194
+ volumeMounts:
195
+ - { name: config, mountPath: /home/dario/.dario }
196
+ livenessProbe:
197
+ httpGet: { path: /health, port: 3456 }
198
+ periodSeconds: 30
199
+ readinessProbe:
200
+ httpGet: { path: /health, port: 3456 }
201
+ periodSeconds: 5
202
+ volumes:
203
+ - name: config
204
+ projected:
205
+ sources:
206
+ - secret:
207
+ name: dario-credentials
208
+ items:
209
+ - { key: credentials.json, path: credentials.json }
210
+ ---
211
+ apiVersion: v1
212
+ kind: Service
213
+ metadata: { name: dario }
214
+ spec:
215
+ selector: { app: dario }
216
+ ports: [{ port: 3456, targetPort: 3456 }]
217
+ ```
218
+
219
+ Replicas should stay at `1` — dario's OAuth refresh races on a single
220
+ credentials file. For HA, run multiple dario instances each with their own
221
+ account in a [multi-account pool](./multi-account-pool.md).
222
+
223
+ ## Image updates
224
+
225
+ The image is rebuilt on every release tag, so any image-update tool that
226
+ watches semver tags works out of the box:
227
+
228
+ - **Renovate** — `docker:enableMajor` + `:vX.Y.Z` pin
229
+ - **Argo CD Image Updater** — `update-strategy: semver`
230
+ - **Keel** — `keel.sh/policy: minor`
231
+
232
+ `:latest` is provided for convenience but you should pin a major or minor in
233
+ production so a breaking change in dario doesn't roll out unattended.
@@ -0,0 +1,290 @@
1
+ # Drift monitor
2
+
3
+ Dario's bundled CC template (`src/cc-template-data.json`) is the wire-shape
4
+ fallback the proxy uses when it can't fingerprint a live CC install. For that
5
+ fallback to be honest, the bundle has to keep up with what real CC is actually
6
+ sending on the wire. CC drifts in two distinct ways, and there are two
7
+ distinct watchers — one of which needs a self-hosted runner.
8
+
9
+ ## Two classes of drift
10
+
11
+ **Class A — npm-release drift.** Anthropic ships a new `@anthropic-ai/claude-code`
12
+ to npm. The binary changes; the wire shape usually changes with it (new tools,
13
+ new system-prompt slots, beta-header swaps). This is the visible kind.
14
+
15
+ > Watched by `.github/workflows/cc-drift-watch.yml`. Runs on a GitHub-hosted
16
+ > runner, no auth required — polls npm, diffs the dist file, opens an issue
17
+ > when the bundled `_version` is behind latest.
18
+
19
+ **Class B — same-binary remote-config drift.** Anthropic does *not* ship a new
20
+ npm version, but the wire shape changes anyway. We documented an instance of
21
+ this in [CHANGELOG `4.2.1`](../CHANGELOG.md#421---2026-05-17): same CC `2.1.143`
22
+ binary, same machine, captures 24h apart produced materially different
23
+ `/v1/messages` bodies (different anthropic-beta header, +355 char system
24
+ prompt). The npm watcher can't see this; the binary is unchanged.
25
+
26
+ > Watched by `.github/workflows/cc-drift-template-watch.yml`. Runs on a
27
+ > **self-hosted** runner because it needs a live, authenticated CC install
28
+ > to capture against. Runs `node scripts/capture-and-bake.mjs --check` every
29
+ > 30 min and opens (or comments on) a `cc-drift-template`-labeled issue when
30
+ > the captured template diverges from the committed bundle.
31
+ >
32
+ > Watched-by-the-watcher: `.github/workflows/cc-drift-watcher-liveness.yml`
33
+ > runs every 2 hours on a github-hosted runner and opens a
34
+ > `cc-watcher-liveness`-labeled alert if the class-B watcher has not had a
35
+ > successful run within 8 hours (≥ 16 missed cycles). Catches "runner went
36
+ > offline silently" — the failure mode where class-B drift goes uncaught
37
+ > because the watcher itself is down. The liveness watcher lives on
38
+ > github-hosted infrastructure deliberately so it survives the exact failure
39
+ > modes it's designed to detect.
40
+
41
+ **Class C — billing-classifier drift** *(v4.6.0)*. Different signal again:
42
+ Anthropic changes the classifier *rules* — adds a new signal, tightens an
43
+ existing one, flips a threshold — and dario's canonical-rebuild output no
44
+ longer scores as `subscription` even though CC's wire shape is unchanged.
45
+ The template-drift watcher cannot see this because nothing in CC's outbound
46
+ has moved; only an end-to-end "send a real request, inspect the billing
47
+ bucket" probe catches it.
48
+
49
+ > Watched by `.github/workflows/cc-billing-classifier-canary.yml`. Runs daily
50
+ > at 06:30 UTC on the same self-hosted runner. Sends one tiny haiku request
51
+ > through `dario proxy` (canonical-rebuild mode, NOT `--passthrough`),
52
+ > captures the `representative-claim` response header, opens a
53
+ > `cc-billing-canary`-labeled alert when it flips to `overage` / `api`
54
+ > / `unknown`. Auto-closes when it next returns a subscription bucket.
55
+ > Cost: ~1 small subscription request per day.
56
+
57
+ ## What --check considers drift
58
+
59
+ The `--check` mode in `scripts/capture-and-bake.mjs` deliberately ignores
60
+ fields that always differ between runs (`_captured` timestamp, user-agent
61
+ string, `_version` / `_supportedMaxTested` labels). It flags **shape** drift in:
62
+
63
+ - **tools** added or removed (by name set)
64
+ - **anthropic_beta** header values added or removed
65
+ - **system_prompt** content (any character delta)
66
+ - **body_field_order** (top-level JSON key order)
67
+ - **header_order**
68
+ - **agent_identity** content
69
+
70
+ Exit codes:
71
+
72
+ | Code | Meaning |
73
+ |---|---|
74
+ | 0 | Full match — wire shape AND `_version` label both current |
75
+ | 1 | Infrastructure failure (CC not on PATH, capture timeout, scrub leak, or installed CC **older** than the bundle's capture — stale runner) |
76
+ | 2 | **Shape** drift vs current bundled template (needs a real re-bake) |
77
+ | 3 | **Label-only** drift — wire shape matches but `_version` lags the live CC version |
78
+
79
+ The workflow swallows exit 2 and 3 (continues to the next step) so the
80
+ remediation steps can run; exit 1 fails the job.
81
+
82
+ The stale-runner case matters because an older binary cannot observe forward
83
+ drift — it re-captures the *previous* wire shape, which `--check` would report
84
+ as exit-2 drift and the watcher would auto-rebake as a template **downgrade**.
85
+ That exact sequence reached the ship gate on 2026-07-02 (PR #632: runner CC at
86
+ 2.1.197 against the 2.1.198-baked bundle reported the afk-mode beta
87
+ "removed"). The guard compares the captured CC version against the bundle's
88
+ `_version` and exits 1 with an update-the-runner message when the binary is
89
+ older. A *deliberate* downgrade bake (an upstream CC release gets pulled and
90
+ the bundle must go backward) bypasses it with `--allow-older-cc`.
91
+
92
+ ### Exit 2 vs exit 3 — why the split, and why only one auto-merges
93
+
94
+ Because `--check` ignores `_version`, a CC release whose wire shape is
95
+ *unchanged* (the common case for a patch bump) produces a bundle whose shape
96
+ matches live CC but whose `_version` label is stale. The shape-only detector
97
+ sees no drift (exit 0 territory), yet `sdk-drift-watch.yml` — which compares
98
+ the `_version` label against `@anthropic-ai/claude-code@latest` on npm — flags
99
+ it, with **nothing to re-bake**. That mismatch used to require a hand PR every
100
+ time (issues #418, #426/#427, #445/#451).
101
+
102
+ Exit 3 captures exactly that case (`computeDrift` empty **and**
103
+ `bundled._version !== live._version`) and writes the live version to
104
+ `label-target.txt`. The **Label-sync** workflow step then runs
105
+ `scripts/label-sync.mjs`, which bumps only the three version-label fields
106
+ (`_version`, `_supportedMaxTested`, and the `claude-cli/<v>` token in the
107
+ user-agent header) — never the wire shape — patch-bumps `package.json`,
108
+ promotes the CHANGELOG, opens a `bot/template-label-*` PR, and turns on
109
+ **auto-merge**.
110
+
111
+ Auto-merge is safe for exit 3 but **not** for exit 2: an empty `computeDrift`
112
+ is a proof that the tools / system_prompt / beta headers / field orders are
113
+ byte-identical at the live version, so only the version string moves — the
114
+ same deterministic-bump risk class `cc-drift-watch.yml` already auto-merges for
115
+ `SUPPORTED_CC_RANGE.maxTested`. Auto-merge still gates on the required checks
116
+ (build ×3, compat, test, docker-cap-drop-smoke); a red check leaves the PR open
117
+ with the bot branch preserved. A shape rebake (exit 2) changes the wire-shape
118
+ contract, so a human reviews compat-test + the diff before merging.
119
+
120
+ ## Setting up the self-hosted runner
121
+
122
+ Any dedicated Linux host works. Hetzner / DO / EC2 / etc. The runner needs
123
+ Node 22, a logged-in `claude` CLI, and disk for a clone of the repo (~200 MB
124
+ including `node_modules`).
125
+
126
+ ### Prerequisites
127
+
128
+ ```bash
129
+ # 1. Node 22 + npm
130
+ curl -fsSL https://deb.nodesource.com/setup_22.x | sudo bash -
131
+ sudo apt-get install -y nodejs
132
+
133
+ # 2. GitHub CLI (`gh`) — the workflow's issue-open step shells out to it.
134
+ # Without this, --check correctly detects drift but the "Open / update
135
+ # drift issue" step fails with `gh: command not found`.
136
+ sudo apt-get install -y gh # or follow https://github.com/cli/cli#installation
137
+
138
+ # 3. CC + dario CLI (dario provides the headless OAuth flow)
139
+ sudo npm i -g @anthropic-ai/claude-code @askalf/dario
140
+
141
+ # 4. OAuth — manual flow for headless boxes. Run from the host's shell,
142
+ # follow the printed URL in any browser, paste the post-login callback
143
+ # URL back into the SSH session.
144
+ #
145
+ # SHARE the credential with any other CC clients that auto-refresh
146
+ # (e.g. a platform dario container running 24/7). Just run the standard
147
+ # `dario login --manual` against /root/.claude/.credentials.json and let
148
+ # that long-running refresh authority keep the token fresh. The workflows
149
+ # read whatever's current at fire time and never attempt a refresh from
150
+ # the runner side.
151
+ #
152
+ # History: v4.4.1 isolated the runner's credential at /root/.claude-runner
153
+ # to avoid OAuth refresh-token rotation races between the runner and other
154
+ # CC clients on the host. The isolation worked for races but introduced a
155
+ # different failure: the isolated token had no refresh authority between
156
+ # workflow fires (each <10 min, often hours apart), and Anthropic invalidates
157
+ # refresh tokens that idle too long. Result: `invalid_grant` on every
158
+ # workflow fire, recoverable only via interactive `dario login --manual`.
159
+ #
160
+ # Sharing with a 24/7 refresh authority (typical setup: the docker-stack
161
+ # dario container) fixes that. The race the isolation was protecting
162
+ # against is rare in practice — platform dario refreshes proactively
163
+ # when the access token has ~1h life remaining, so workflow runs hit a
164
+ # fresh token and don't need to refresh themselves.
165
+ dario login --manual
166
+
167
+ # 5. Smoke test:
168
+ echo "Reply with PONG" | claude --print # should print PONG
169
+ ```
170
+
171
+ ### One-time repo setup
172
+
173
+ The workflow's issue-open step calls `gh issue create --label cc-drift-template`
174
+ which fails if the label doesn't exist in the repo. Create it once before the
175
+ runner's first execution:
176
+
177
+ ```bash
178
+ gh label create cc-drift-template \
179
+ --description "Bundled CC template has drifted from live capture" \
180
+ --color FBCA04
181
+ ```
182
+
183
+ ### Register the runner
184
+
185
+ In a browser: `https://github.com/<owner>/<repo>/settings/actions/runners/new`.
186
+ Pick Linux x64. GitHub prints a `mkdir`/`curl`/`tar`/`./config.sh` snippet.
187
+ Paste it into the host. At the labels prompt, type **`dario-drift`** — the
188
+ workflow gates on `runs-on: [self-hosted, dario-drift]`, so the label is
189
+ load-bearing.
190
+
191
+ ### Install as a systemd service
192
+
193
+ ```bash
194
+ cd ~/actions-runner
195
+ sudo ./svc.sh install $(whoami) # run as the same user that owns ~/.claude
196
+ sudo ./svc.sh start
197
+ sudo ./svc.sh status # → "active (running)"
198
+ ```
199
+
200
+ If the runner runs as `root` and `~/.claude/.credentials.json` lives under
201
+ `/root/`, `RUNNER_ALLOW_RUNASROOT=1 ./config.sh ...` lets `./config.sh` run as
202
+ root; GitHub's runner otherwise refuses root by default.
203
+
204
+ ### Trigger once to verify
205
+
206
+ GitHub UI → Actions → **CC template drift watch (self-hosted)** → "Run
207
+ workflow." It should pick up the labeled runner within seconds and finish
208
+ within ~60s. Exit 0 (no drift) or exit 2 (issue auto-opened with the drift
209
+ report) both mean the pipeline is healthy. Exit 1 means the capture broke;
210
+ check the workflow logs.
211
+
212
+ After the first successful run, the `*/30 * * * *` cron takes over.
213
+
214
+ ## When --check fires an issue
215
+
216
+ The workflow opens (or comments on) an issue labeled `cc-drift-template`
217
+ containing the `[bake]` output — the list of differing slots, sizes, tool
218
+ names. From there:
219
+
220
+ ```bash
221
+ # On a maintainer machine with CC + a logged-in OAuth credential:
222
+ npm run build
223
+ node scripts/capture-and-bake.mjs # rewrites src/cc-template-data.json
224
+ git diff src/cc-template-data.json # review
225
+ # Open a PR with the re-bake; the auto-release pipeline publishes a patch
226
+ # version. The next clean --check cycle auto-closes the drift issue.
227
+ ```
228
+
229
+ ## Optional: PAT for downstream workflow triggers
230
+
231
+ Since v4.4.0, the watcher auto-opens a `bot/template-rebake-*` PR on detection. Since v4.3.0, `compat-test-self-hosted.yml` is supposed to run on PRs touching `src/cc-template-data.json`. **Without the setup below, it doesn't.** GitHub Actions has a deliberate restriction: workflows authenticated by the default `GITHUB_TOKEN` cannot trigger downstream workflow runs ([docs](https://docs.github.com/en/actions/security-for-github-actions/security-guides/automatic-token-authentication#using-the-github_token-in-a-workflow)). The auto-rebake PR is therefore invisible to compat-test, and the validation gate the v4.4.0 design promised is effectively bypassed.
232
+
233
+ To close the gap, create a fine-grained personal access token (PAT) scoped to this repo and expose it to the watcher as `DARIO_DRIFT_BOT_PAT`:
234
+
235
+ 1. **Generate** at `https://github.com/settings/personal-access-tokens/new`:
236
+ - Resource owner: your user (or org)
237
+ - Repository access: select `dario` only
238
+ - Permissions: **Contents: read & write**, **Pull requests: read & write**, **Issues: read & write**
239
+ - Expiration: whatever your security policy mandates (90 days / 1 year)
240
+
241
+ 2. **Store** at `Settings → Secrets and variables → Actions → New repository secret`:
242
+ - Name: `DARIO_DRIFT_BOT_PAT`
243
+ - Value: the PAT from step 1
244
+
245
+ 3. **Verify** on the next watcher cycle that detects drift. The auto-rebake PR's "Checks" tab should now include the `compat` job (which it didn't pre-v4.6.5).
246
+
247
+ The watcher workflow uses `GH_TOKEN: ${{ secrets.DARIO_DRIFT_BOT_PAT || secrets.GITHUB_TOKEN }}` for `gh` CLI ops, so the PAT is **optional** — the watcher keeps working without it, you just don't get compat-test gating on auto-rebake PRs (same behavior as v4.4.0 through v4.6.4). The fallback exists so a maintainer can defer the PAT setup without breaking the loop.
248
+
249
+ ## Runner credential rate-limit headroom
250
+
251
+ Workflows that exercise live `dario proxy` paths (compat-test, billing canary, future end-to-end probes) all consume against the runner credential's subscription pool. The cadence assumptions are:
252
+
253
+ | Workflow | Declared cadence | Observed cadence | Requests per fire |
254
+ |---|---|---|---|
255
+ | `cc-drift-template-watch.yml` (`--check`) | every 30 min (`*/30 * * * *`) | typically every 2-4h | 1 capture (no /v1/messages traffic — MITM-only) |
256
+ | `cc-billing-classifier-canary.yml` | daily 06:30 UTC | daily | 1 small haiku request |
257
+ | `compat-test-self-hosted.yml` | per qualifying PR | per qualifying PR | ~11 small requests |
258
+
259
+ **Cron scheduler reality.** GitHub Actions' free-tier cron scheduler is best-effort, not guaranteed. The class-B watcher declares `*/30 * * * *` but in practice GitHub honors it every 2-4 hours on this repo. The liveness alarm (added v4.4.2) has its threshold set to 8h (raised from 3h in v4.7.1) to absorb this skew — anything past that is signal, not scheduler noise. If you need a tighter SLA (sub-hour), self-host the runner *and* the cron driver (e.g. a cron entry on the same Hetzner box invoking `gh workflow run` directly).
260
+
261
+ At steady state, this is comfortably inside Pro/Max headroom. The failure mode to watch for is **batched firing** — manually re-triggering the same workflow several times in a single hour, or PRs landing in rapid succession that each fire compat-test. We tripped this during the v4.6.x rollout: a half-dozen manual re-runs in a 2-hour window 429'd the runner credential. Pro/Max accounts have per-hour rate caps as well as per-5h / per-7d pools, and the per-hour cap is what surfaces first.
262
+
263
+ If the runner credential is rate-limited and a workflow run reports 429s across the board, the right diagnosis order is: (a) check `claude --print` directly — if it 429s, the credential pool is dry, just wait an hour; (b) check the credential is still on a subscription account (`dario doctor`); (c) check workflow cadence assumptions haven't changed.
264
+
265
+ The runner shares its OAuth credential with any other long-running CC client on the box (typically the platform dario container, which auto-refreshes 24/7). Sharing is intentional: a workflow that fires sparsely cannot keep its own refresh token alive — Anthropic invalidates idle refresh tokens, and `invalid_grant` then breaks every subsequent run. Letting a 24/7 refresh authority own the token rotation eliminates that failure mode at the cost of competing for the same Pro/Max headroom. With current cadence (drift-template-watch every 30 min + compat per PR + canary daily), runner-side burn on the shared account is a manageable fraction of the headroom available on a Max plan; reducing cadence further is a knob if a particular workload needs more of it.
266
+
267
+ ## Why a self-hosted runner
268
+
269
+ GitHub-hosted runners can't capture CC. They have no Pro/Max subscription
270
+ session, no MITM cert trust for CC's loopback proxy, no way to authenticate
271
+ against `claude.ai/oauth`. Anything that needs real CC running against real
272
+ Anthropic has to live on a host you control with an account you've logged
273
+ in to.
274
+
275
+ The runner is read-only against the repo (`contents: read`) and only writes
276
+ to issues (`issues: write`). It cannot push, tag, or release.
277
+
278
+ ## Platform-superset preservation
279
+
280
+ CC ships different tools on different platforms — currently just `PowerShell`
281
+ on Windows, but the surface grows over time. The bundled template is meant to
282
+ be a **union** across platforms, and `filterToolsForPlatform()` strips it down
283
+ at request time. So a bake on Linux must not silently drop the Windows tool
284
+ set, or Windows dario users would lose those tools on the next release.
285
+
286
+ `scripts/capture-and-bake.mjs` preserves tools from the previous bundle whose
287
+ names are listed in `PLATFORM_ONLY_TOOLS` for a platform other than the
288
+ baking host's. The merged set is re-sorted alphabetically to match CC's wire
289
+ order. The runner can therefore bake from Linux without regressing Windows
290
+ users.