-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathdocker-compose.yml
More file actions
189 lines (186 loc) · 8.76 KB
/
Copy pathdocker-compose.yml
File metadata and controls
189 lines (186 loc) · 8.76 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
# Self-hosted deployment: Caddy for TLS + single app container.
# For managed hosting (Railway, PikaPods), just run the app service.
services:
caddy:
image: caddy:2.9-alpine
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
environment:
- DOMAIN=${DOMAIN}
- FRONTEND_ORIGIN=https://${DOMAIN}
# Caddy needs a valid site label even when the optional shared service
# gateway is off. The .invalid default is inert and cannot trigger
# public ACME/DNS work.
- MOBIUS_SERVICE_GATEWAY_ORIGIN=${MOBIUS_SERVICE_GATEWAY_ORIGIN:-http://services.invalid}
depends_on:
app:
condition: service_healthy
app:
build:
context: .
args:
BUILD_SHA: ${BUILD_SHA:-unknown}
BUILD_DATE: ${BUILD_DATE:-unknown}
# Tag the built image so the recoveryd service can reuse the SAME image
# (no second build, shared layers) rather than rebuilding the context.
image: ${MOBIUS_IMAGE:-mobius}
container_name: mobius
init: true
restart: unless-stopped
# Bubblewrap is the security boundary for reviewed `background_agent` app
# jobs. Nested Docker blocks its namespace setup by default. The image's
# setuid bwrap keeps only these setup capabilities and drops them before the
# unprivileged job starts; recoveryd deliberately receives none of them.
cap_add:
- SYS_ADMIN
- NET_ADMIN
- SYS_PTRACE
security_opt:
# Docker's default profiles block mount/pivot_root even after bwrap has
# entered its private namespace. The outer service remains unprivileged at
# runtime; bwrap supplies the per-job read-only/masked filesystem policy.
- seccomp=unconfined
- apparmor=unconfined
environment:
- SECRET_KEY=${SECRET_KEY}
- DOMAIN=${DOMAIN}
- FRONTEND_ORIGIN=https://${DOMAIN}
# No application default: every public_surface fails closed unless the
# operator explicitly configures the same shared origin consumed by
# Caddy above. One gateway serves all owner-trusted service slugs.
- MOBIUS_SERVICE_GATEWAY_ORIGIN=${MOBIUS_SERVICE_GATEWAY_ORIGIN:-}
# GitHub device-flow sign-in. Defaults to the Möbius OAuth app's
# public client id (keep in sync with config.py's baked default —
# an empty env var here would override it and disable device flow).
# Self-hosters: set GITHUB_OAUTH_CLIENT_ID in .env to use your own
# OAuth app, or to "" to disable the device flow (PAT still works).
- GITHUB_OAUTH_CLIENT_ID=${GITHUB_OAUTH_CLIENT_ID:-Ov23liMpOLS6qp5YV8Vk}
- DATABASE_URL=sqlite:////data/db/ultimate.db
- DATA_DIR=/data
volumes:
- app_data:/data
expose:
- "8000"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/api/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 20s
# Cap the platform's memory so a leak can't starve the host into OOM-killing
# the recovery floor; pairs with recoveryd's kill-last oom_score_adj below
# (Codex HIGH; plan _148 1.6). Env-overridable for bigger/smaller hosts.
mem_limit: ${MOBIUS_APP_MEM_LIMIT:-6g}
oom_score_adj: 200
# Frozen recovery floor (recoveryd) — its OWN container so a broken or
# crash-looping platform cannot take recovery down (different cgroup,
# different pid1). Same image as `app`, different command + its own
# `restart: unless-stopped`. Mounts the SAME app_data volume so it can write
# /data/.recover-pending + /data/.platform-restart-requested, which the
# platform container's entrypoint poller (O1) acts on to git-restore and
# restart. Imports zero app.* code; self-checks its frozen bundle at startup.
#
# recoveryd is the SOLE recovery surface — the in-process `/recover`
# implementation has been retired. Both fronts route `/recover*` here:
# in prod the external edge proxy proxies `/recover*` ->
# recoveryd:8001 (recoveryd joins the deploy network so Caddy can reach
# it), and the bundled Caddy (`Caddyfile`) routes it with a
# `handle /recover* { reverse_proxy recoveryd:8001 }` block ahead of its
# catch-all `reverse_proxy app:8000`. `expose` withholds a host port; it
# is NOT an access-control boundary (recoveryd enforces auth + cross-site
# checks on every request).
recoveryd:
build:
context: .
args:
BUILD_SHA: ${BUILD_SHA:-unknown}
BUILD_DATE: ${BUILD_DATE:-unknown}
image: ${MOBIUS_IMAGE:-mobius}
container_name: mobius-recoveryd
init: true
restart: unless-stopped
# GUARDRAIL: the recovery agent runs as full root, but recoveryd's root
# filesystem is READ-ONLY, so even root gets EROFS trying to write
# recovery's OWN code (/app, /app/recovery) — sudo overrides permissions
# but not a read-only mount. The agent keeps unlimited power over the
# WRITABLE surfaces (the /data + /recovery-live volumes = the platform it
# must fix) plus tmpfs scratch, and physically cannot corrupt its own
# lifeboat. Kernel-enforced, not by trust.
read_only: true
# Drop CAP_SYS_ADMIN so a full-root agent can't remount the
# read-only recovery floor back to writable — makes the lockdown airtight.
cap_drop:
- SYS_ADMIN
tmpfs:
- /tmp
- /run
command: ["python3", "-P", "/app/recovery/recoveryd.py"]
# recoveryd serves on 8001, not the app's 8000. Without this override it
# inherits the image HEALTHCHECK (curl :8000/api/health), can never reach it,
# and reports `unhealthy` forever even though /recover answers fine. Nothing
# depends on this status, but a recovery floor showing "unhealthy" is alarming.
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://localhost:8001/recover/health || exit 1"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
environment:
- DATA_DIR=/data
# Writable HOME on tmpfs: under read_only:true the default root HOME is
# read-only, and the agent's Claude/Codex CLI needs a writable home for
# its cache. Config still lives under /data (CLAUDE_CONFIG_DIR/CODEX_HOME).
- HOME=/tmp
- DATABASE_URL=sqlite:////data/db/ultimate.db
- RECOVERY_PORT=8001
- RECOVERY_PLATFORM_HEALTH_URL=http://app:8000/api/health
# Root of recoveryd's PRIVATE live-copy store (the recovery_live volume
# below). Kept OFF /data deliberately — see the volume comment.
- RECOVERY_LIVE_ROOT=/recovery-live
# Set RECOVERY_ALLOWED_HOSTS to the public DOMAIN at cutover so the
# Origin fallback check is active for clients without Sec-Fetch-Site.
- RECOVERY_ALLOWED_HOSTS=${DOMAIN:-}
volumes:
# nocopy is load-bearing — see the same option on recoveryd in
# docker-compose.test.yml for the full rationale. recoveryd shares the
# fresh app_data volume with `app`; the image bakes /data/db etc., so on
# a FRESH volume both containers race on the image->volume copy-up
# (`mkdir _data/db` -> "file exists") and one fails to start. This is
# latent on an existing prod deploy (populated volume = no copy-up) but
# bites exactly when it must not: first boot, and after a recovery
# factory-reset WIPES the volume — the container would fail to come back
# up. nocopy makes `app` the sole populator; recoveryd needs no /data at
# boot, and stays independent of app startup (the recovery floor's whole
# point), so no depends_on.
- app_data:/data:nocopy
# recoveryd's PRIVATE live-copy store, mounted into THIS service ONLY —
# the `app` (agent) container never mounts it. The self-updating floor
# pulls the latest recovery here, root-owned, and execs it. Because the
# agent (a non-root user with write access to /data) has NO filesystem
# path to this volume, it cannot rename, unlink, or swap the live copy
# between recoveryd's trust check and its exec — which is what closes the
# root-escalation rename race that an agent-writable parent (/data) would
# otherwise allow. Do NOT add this mount to the `app` service.
- recovery_live:/recovery-live
expose:
- "8001"
# Protect the recovery floor from the host OOM-killer (earlyoom is active):
# kill-last, so a platform leak / heavy swap sacrifices the app + the rescue
# child before recoveryd is ever selected (Codex HIGH; plan _148 1.6).
oom_score_adj: -900
volumes:
caddy_data:
caddy_config:
app_data:
# recoveryd's private live-copy store — mounted into the recoveryd service
# ONLY (never `app`). Keeping the pulled live recovery off the agent-writable
# /data volume is what makes it un-swappable by the agent (root-escalation
# TOCTOU close).
recovery_live: