bobi 0.35.0__py3-none-any.whl
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.
- bobi/__init__.py +0 -0
- bobi/__main__.py +3 -0
- bobi/__version__.py +6 -0
- bobi/_deploy/Dockerfile +263 -0
- bobi/_deploy/docker/docker-entrypoint.sh +377 -0
- bobi/_deploy/docker/healthcheck.sh +31 -0
- bobi/_deploy/docker/noop-deps.sh +7 -0
- bobi/_deploy/scripts/destroy-instance.sh +52 -0
- bobi/_deploy/scripts/fleet.sh +99 -0
- bobi/_deploy/scripts/provision-instance.sh +477 -0
- bobi/agentui/__init__.py +12 -0
- bobi/agentui/remote.py +167 -0
- bobi/agentui/server.py +414 -0
- bobi/agentui/static/app.css +342 -0
- bobi/agentui/static/app.js +0 -0
- bobi/agentui/static/index.html +73 -0
- bobi/auth_bootstrap.py +386 -0
- bobi/brain/__init__.py +97 -0
- bobi/brain/base.py +135 -0
- bobi/brain/claude.py +213 -0
- bobi/brain/codex.py +229 -0
- bobi/browser.py +311 -0
- bobi/build_render.py +253 -0
- bobi/cli.py +3365 -0
- bobi/compose.py +760 -0
- bobi/concurrency_semaphore.py +87 -0
- bobi/config.py +473 -0
- bobi/costs.py +164 -0
- bobi/deploy.py +1280 -0
- bobi/doctor.py +384 -0
- bobi/env.py +121 -0
- bobi/event-server/package-lock.json +5989 -0
- bobi/event-server/package.json +28 -0
- bobi/event-server/src/adapters/chat-sdk-slack.ts +137 -0
- bobi/event-server/src/adapters/github.ts +108 -0
- bobi/event-server/src/adapters/index.ts +11 -0
- bobi/event-server/src/adapters/linear.ts +43 -0
- bobi/event-server/src/adapters/slack.ts +164 -0
- bobi/event-server/src/circuit-breaker.ts +326 -0
- bobi/event-server/src/core.ts +1310 -0
- bobi/event-server/src/dashboard/index.html +1208 -0
- bobi/event-server/src/deployment-session.ts +234 -0
- bobi/event-server/src/index.ts +529 -0
- bobi/event-server/src/internal-auth.ts +64 -0
- bobi/event-server/src/local.ts +680 -0
- bobi/event-server/tsconfig.json +42 -0
- bobi/events/__init__.py +27 -0
- bobi/events/adapters.py +265 -0
- bobi/events/channels.py +149 -0
- bobi/events/client.py +460 -0
- bobi/events/drain.py +275 -0
- bobi/events/publish.py +146 -0
- bobi/events/reactor.py +291 -0
- bobi/events/server.py +616 -0
- bobi/events/signing.py +60 -0
- bobi/events/subscriptions.py +95 -0
- bobi/history.py +399 -0
- bobi/http.py +138 -0
- bobi/inbox.py +344 -0
- bobi/kb/__init__.py +0 -0
- bobi/kb/embedder.py +149 -0
- bobi/kb/sidecar.py +160 -0
- bobi/kb/store.py +560 -0
- bobi/manager_health.py +181 -0
- bobi/mcp/__init__.py +1 -0
- bobi/memory.py +151 -0
- bobi/monitors/__init__.py +27 -0
- bobi/monitors/curator.py +231 -0
- bobi/monitors/framework_defaults.yaml +21 -0
- bobi/monitors/registry.py +181 -0
- bobi/monitors/scheduler.py +802 -0
- bobi/monitors/schema.py +253 -0
- bobi/monitors/script_cache_checks.py +1139 -0
- bobi/monitors/tool_checks.py +279 -0
- bobi/paths.py +248 -0
- bobi/prompts/__init__.py +16 -0
- bobi/prompts/base.md +171 -0
- bobi/prompts/curator.md +110 -0
- bobi/prompts/resolver.py +307 -0
- bobi/prompts/setup.md +166 -0
- bobi/reconcile.py +184 -0
- bobi/registry.py +455 -0
- bobi/scaffold.py +422 -0
- bobi/sdk.py +449 -0
- bobi/session.py +897 -0
- bobi/setup/__init__.py +24 -0
- bobi/setup/actions.py +393 -0
- bobi/setup/authoring.py +646 -0
- bobi/setup/automate.py +87 -0
- bobi/setup/digestion.py +317 -0
- bobi/setup/harness.py +118 -0
- bobi/setup/llm.py +139 -0
- bobi/setup/mcp_detect.py +544 -0
- bobi/setup/mcp_probe.py +352 -0
- bobi/setup/mcp_registry.py +149 -0
- bobi/setup/open_mode.py +289 -0
- bobi/setup/services.py +652 -0
- bobi/setup/state.py +233 -0
- bobi/setup/venn_cli.py +132 -0
- bobi/setup/webui/__init__.py +6 -0
- bobi/setup/webui/server.py +1165 -0
- bobi/setup/webui/static/app.css +634 -0
- bobi/setup/webui/static/app.js +1862 -0
- bobi/setup/webui/static/bobi-mark.svg +9 -0
- bobi/setup/webui/static/index.html +31 -0
- bobi/skills/bobi.md +129 -0
- bobi/skills/create-agent.md +306 -0
- bobi/skills/linear-setup.md +71 -0
- bobi/skills/slack-setup.md +145 -0
- bobi/slack.py +565 -0
- bobi/slack_manifest.py +65 -0
- bobi/spend_governor.py +110 -0
- bobi/state_version.py +80 -0
- bobi/subagent.py +1655 -0
- bobi/templates/slack-app.manifest.yaml +48 -0
- bobi/tool_library/codex/guide.md +44 -0
- bobi/tool_library/codex/tool.yaml +12 -0
- bobi/tool_library/openai/guide.md +36 -0
- bobi/tool_library/openai/tool.yaml +19 -0
- bobi/tool_library/venn/guide.md +44 -0
- bobi/tool_library/venn/tool.yaml +19 -0
- bobi/tool_library.py +198 -0
- bobi/transient.py +50 -0
- bobi/validate.py +373 -0
- bobi/venn.py +167 -0
- bobi/watchdog.py +488 -0
- bobi/workflow/__init__.py +8 -0
- bobi/workflow/cleanup.py +152 -0
- bobi/workflow/orchestrator.py +846 -0
- bobi/workflow/schema.py +105 -0
- bobi/workflow/state.py +157 -0
- bobi/workflow/triggers.py +80 -0
- bobi/workflow/variables.py +201 -0
- bobi-0.35.0.dist-info/METADATA +40 -0
- bobi-0.35.0.dist-info/RECORD +137 -0
- bobi-0.35.0.dist-info/WHEEL +4 -0
- bobi-0.35.0.dist-info/entry_points.txt +2 -0
bobi/__init__.py
ADDED
|
File without changes
|
bobi/__main__.py
ADDED
bobi/__version__.py
ADDED
bobi/_deploy/Dockerfile
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
# syntax=docker/dockerfile:1
|
|
2
|
+
#
|
|
3
|
+
# bobi instance image (containerized-8 / #338).
|
|
4
|
+
#
|
|
5
|
+
# ONE image, every tenant. Identity lives entirely in the mounted volume
|
|
6
|
+
# (project root + $HOME) and env vars — see docs/design/CONTAINERIZED_INSTANCES.md
|
|
7
|
+
# §2 (the instance contract). Nothing reaches in; the manager reaches out to the
|
|
8
|
+
# event server over WSS only.
|
|
9
|
+
#
|
|
10
|
+
# ONE Dockerfile, three BUILD modes (BOBI_BUILD build-arg) — the runtime stage
|
|
11
|
+
# is shared, so there's nothing to keep in sync:
|
|
12
|
+
# * source (default) — build the wheel from a repo checkout (`COPY . .`). Dev +
|
|
13
|
+
# the repo's own CI, so unreleased branch code is tested.
|
|
14
|
+
# * pypi — install a published `bobi==$BOBI_VERSION` from
|
|
15
|
+
# PyPI; the build context is just this file + docker/. This is what binary-mode
|
|
16
|
+
# `bobi deploy` uses, so deploying needs no checkout (DEPLOY_INTERFACE.md).
|
|
17
|
+
# * wheel — install a PREBUILT local wheel staged in dist/. The release
|
|
18
|
+
# pipeline (release.yml) builds the wheel once, then bakes THAT artifact so the
|
|
19
|
+
# canary smokes — and the fleet runs — the exact bytes published to PyPI.
|
|
20
|
+
#
|
|
21
|
+
# Design-mandated properties (CONTAINERIZED_INSTANCES.md §5, §6.1, §10 C8):
|
|
22
|
+
# * Runs the agent as a NON-ROOT user. Claude Code refuses bypassPermissions
|
|
23
|
+
# as root unless IS_SANDBOX=1; we drop privileges to `bobi` first.
|
|
24
|
+
# * No Node.js. The `claude` CLI is the native standalone binary (no npm).
|
|
25
|
+
# * fastembed model baked into the image at build (cold-start speed; immutable).
|
|
26
|
+
# * Pinned `claude` CLI; auto-updater disabled so the image version is frozen.
|
|
27
|
+
# * `bobi agent <name> start --foreground` as the entrypoint (C2); no tini — Fly's
|
|
28
|
+
# init is PID 1 (tini-on-Fly is a known boot-failure trigger).
|
|
29
|
+
#
|
|
30
|
+
# Build:
|
|
31
|
+
# docker build -t bobi:dev . # source mode
|
|
32
|
+
# docker build --build-arg BOBI_BUILD=pypi \
|
|
33
|
+
# --build-arg BOBI_VERSION=0.22.0 -t bobi:dev . # pypi mode
|
|
34
|
+
# docker build --build-arg BOBI_BUILD=wheel -t bobi:dev . # wheel (dist/*.whl)
|
|
35
|
+
|
|
36
|
+
# Which builder produces /opt/venv: `source`, `pypi`, or `wheel`. bobi deploy
|
|
37
|
+
# passes `pypi` (+ BOBI_VERSION) in binary mode; the release pipeline passes
|
|
38
|
+
# `wheel` (with the prebuilt artifact in dist/); a plain `docker build` defaults to
|
|
39
|
+
# `source` so the repo's own CI keeps building from the branch.
|
|
40
|
+
ARG BOBI_BUILD=source
|
|
41
|
+
|
|
42
|
+
#####################################################################
|
|
43
|
+
# Builder base — build tools live here only; runtime never sees them.#
|
|
44
|
+
#####################################################################
|
|
45
|
+
FROM python:3.11-slim AS builder-base
|
|
46
|
+
# apsw / native deps may need a compiler if no manylinux wheel is published.
|
|
47
|
+
RUN apt-get update \
|
|
48
|
+
&& apt-get install -y --no-install-recommends build-essential \
|
|
49
|
+
&& rm -rf /var/lib/apt/lists/*
|
|
50
|
+
|
|
51
|
+
#####################################################################
|
|
52
|
+
# builder-source — build the wheel FROM the repo (dev + repo CI). #
|
|
53
|
+
#####################################################################
|
|
54
|
+
FROM builder-base AS builder-source
|
|
55
|
+
RUN pip install --no-cache-dir build
|
|
56
|
+
WORKDIR /src
|
|
57
|
+
# --- deps layer (cached on pyproject only) -------------------------#
|
|
58
|
+
# Install the project's runtime deps + the lean kb deps into the venv,
|
|
59
|
+
# keyed on pyproject.toml alone: an ordinary code edit doesn't touch it, so
|
|
60
|
+
# this (network-heavy) layer stays cached and only the thin wheel layer below
|
|
61
|
+
# rebuilds. Read the dep list straight from [project.dependencies] (stdlib
|
|
62
|
+
# tomllib) so it never drifts from pyproject. Install fastembed/sqlite-vec
|
|
63
|
+
# EXPLICITLY — never the `[kb]` extra, which on some releases stale-lists
|
|
64
|
+
# sentence-transformers → torch + ~2 GB CUDA the CPU instance never uses.
|
|
65
|
+
COPY pyproject.toml ./
|
|
66
|
+
RUN python -m venv /opt/venv \
|
|
67
|
+
&& python -c "import tomllib; print(chr(10).join(tomllib.load(open('pyproject.toml','rb'))['project']['dependencies']))" > /tmp/reqs.txt \
|
|
68
|
+
&& /opt/venv/bin/pip install --no-cache-dir -r /tmp/reqs.txt "fastembed>=0.4" "sqlite-vec>=0.1.6"
|
|
69
|
+
# --- wheel layer (thin; rebuilds on any code change) ---------------#
|
|
70
|
+
# pyproject builds the wheel FROM the sdist, so the sdist must carry the
|
|
71
|
+
# force-included templates + event-server (it does — see pyproject sdist include).
|
|
72
|
+
# --no-deps: deps are already in the venv above, so this is just bobi.
|
|
73
|
+
COPY . .
|
|
74
|
+
RUN python -m build --wheel --outdir /dist \
|
|
75
|
+
&& /opt/venv/bin/pip install --no-cache-dir --no-deps /dist/*.whl
|
|
76
|
+
|
|
77
|
+
#####################################################################
|
|
78
|
+
# builder-pypi — install a published bobi (binary-mode deploy).#
|
|
79
|
+
#####################################################################
|
|
80
|
+
FROM builder-base AS builder-pypi
|
|
81
|
+
# Pinned to the operator's CLI so the instance runs the same code as the binary
|
|
82
|
+
# that deployed it.
|
|
83
|
+
ARG BOBI_VERSION
|
|
84
|
+
# Install the kb deps the code actually uses (fastembed — the lightweight ONNX
|
|
85
|
+
# embedder — and sqlite-vec) EXPLICITLY, not via the `[kb]` extra: some published
|
|
86
|
+
# releases stale-list `sentence-transformers` in `[kb]`, dragging in torch + ~2 GB
|
|
87
|
+
# of CUDA wheels the dark CPU instance never uses (and that can blow the build
|
|
88
|
+
# timeout). Keep in sync with pyproject's `[project.optional-dependencies].kb`.
|
|
89
|
+
RUN python -m venv /opt/venv \
|
|
90
|
+
&& /opt/venv/bin/pip install --no-cache-dir \
|
|
91
|
+
"bobi==${BOBI_VERSION}" "fastembed>=0.4" "sqlite-vec>=0.1.6"
|
|
92
|
+
|
|
93
|
+
#####################################################################
|
|
94
|
+
# builder-wheel — install a PREBUILT local wheel (release pipeline). #
|
|
95
|
+
#####################################################################
|
|
96
|
+
# The release pipeline builds the wheel ONCE in CI and stages it into the build
|
|
97
|
+
# context at dist/ (re-included in .dockerignore). We install THAT exact artifact
|
|
98
|
+
# here, so the canary smokes — and the fleet runs — the same bytes we publish to
|
|
99
|
+
# PyPI (not a separately source-rebuilt wheel). Deps come from the same
|
|
100
|
+
# pyproject-keyed layer as builder-source (cached across code-only changes); the
|
|
101
|
+
# wheel goes in --no-deps on top.
|
|
102
|
+
FROM builder-base AS builder-wheel
|
|
103
|
+
WORKDIR /src
|
|
104
|
+
COPY pyproject.toml ./
|
|
105
|
+
RUN python -m venv /opt/venv \
|
|
106
|
+
&& python -c "import tomllib; print(chr(10).join(tomllib.load(open('pyproject.toml','rb'))['project']['dependencies']))" > /tmp/reqs.txt \
|
|
107
|
+
&& /opt/venv/bin/pip install --no-cache-dir -r /tmp/reqs.txt "fastembed>=0.4" "sqlite-vec>=0.1.6"
|
|
108
|
+
# The prebuilt wheel staged by the release pipeline (exactly one *.whl in dist/).
|
|
109
|
+
COPY dist/ /dist/
|
|
110
|
+
RUN /opt/venv/bin/pip install --no-cache-dir --no-deps /dist/*.whl
|
|
111
|
+
|
|
112
|
+
# Select the builder. With BOBI_BUILD=pypi, builder-source isn't in the graph
|
|
113
|
+
# (its `COPY . .` never runs), so the tiny binary context needs no source tree.
|
|
114
|
+
# BOBI_BUILD=wheel (the release pipeline) installs the prebuilt dist/ wheel.
|
|
115
|
+
FROM builder-${BOBI_BUILD} AS builder
|
|
116
|
+
|
|
117
|
+
#####################################################################
|
|
118
|
+
# model-baker — pre-download the fastembed model. Keyed ONLY on the #
|
|
119
|
+
# pinned fastembed version, so this (the slowest layer — a multi- #
|
|
120
|
+
# minute model download) stays cached across every code/framework #
|
|
121
|
+
# change. Runtime COPYs the baked model in BELOW the volatile venv, #
|
|
122
|
+
# so a code-only rebuild never re-bakes it. (Install fastembed alone, #
|
|
123
|
+
# never `[kb]` — see builder-source for the torch-bloat rationale.) #
|
|
124
|
+
#####################################################################
|
|
125
|
+
FROM python:3.11-slim AS model-baker
|
|
126
|
+
ENV HF_HOME=/opt/bobi/models
|
|
127
|
+
RUN pip install --no-cache-dir "fastembed>=0.4" \
|
|
128
|
+
&& python -c "from fastembed import TextEmbedding; TextEmbedding(model_name='sentence-transformers/all-MiniLM-L6-v2')" \
|
|
129
|
+
&& chmod -R a+rX /opt/bobi/models
|
|
130
|
+
|
|
131
|
+
#####################################################################
|
|
132
|
+
# Runtime — slim image, no build tools, no Node. (Shared by both.) #
|
|
133
|
+
#####################################################################
|
|
134
|
+
FROM python:3.11-slim AS runtime
|
|
135
|
+
|
|
136
|
+
# Channel or exact version for the native `claude` installer. Default to the
|
|
137
|
+
# `stable` channel (one week behind latest, skips major regressions); pass an
|
|
138
|
+
# exact version (e.g. 2.1.89) for fully reproducible production builds.
|
|
139
|
+
ARG CLAUDE_VERSION=stable
|
|
140
|
+
ARG BOBI_UID=10001
|
|
141
|
+
# Pinned aichat (the general-purpose LLM gateway CLI). Bump deliberately via
|
|
142
|
+
# `gh api repos/sigoden/aichat/releases/latest --jq .tag_name`.
|
|
143
|
+
ARG AICHAT_VERSION=0.30.0
|
|
144
|
+
|
|
145
|
+
ENV PYTHONUNBUFFERED=1 \
|
|
146
|
+
PIP_NO_CACHE_DIR=1 \
|
|
147
|
+
PATH="/opt/venv/bin:/home/bobi/.local/bin:${PATH}" \
|
|
148
|
+
HF_HOME=/opt/bobi/models \
|
|
149
|
+
DISABLE_AUTOUPDATER=1 \
|
|
150
|
+
HOME=/home/bobi \
|
|
151
|
+
DATA_DIR=/data \
|
|
152
|
+
BOBI_HOME=/data/.bobi
|
|
153
|
+
# NB: HOME is the IMAGE home (above), NOT the volume — baked team tools
|
|
154
|
+
# (~/.claude/skills, ~/dev/gstack) are read in place. Claude's durable state
|
|
155
|
+
# (creds + transcripts) is redirected to the volume at runtime via
|
|
156
|
+
# CLAUDE_CONFIG_DIR, which the entrypoint sets (NOT a build ENV — build steps
|
|
157
|
+
# must write skills to the image ~/.claude, so they `env -u CLAUDE_CONFIG_DIR`).
|
|
158
|
+
|
|
159
|
+
# Runtime packages only:
|
|
160
|
+
# curl, ca-certificates — fetch the claude installer; TLS
|
|
161
|
+
# gosu — drop privileges from root setup to the bobi user
|
|
162
|
+
# git — agents clone/operate on repos
|
|
163
|
+
# NB: no tini. Fly Machines (the deploy target) inject their own PID-1 init that
|
|
164
|
+
# reaps zombies + forwards signals, and layering tini on top is a documented
|
|
165
|
+
# cause of "failed to spawn ... No such file or directory" boot failures there.
|
|
166
|
+
# For other container runtimes, run with an init (e.g. `docker run --init`).
|
|
167
|
+
RUN apt-get update \
|
|
168
|
+
&& apt-get install -y --no-install-recommends \
|
|
169
|
+
curl ca-certificates gosu git \
|
|
170
|
+
&& rm -rf /var/lib/apt/lists/*
|
|
171
|
+
|
|
172
|
+
# Non-root runtime user (see header: bypassPermissions-as-root guard).
|
|
173
|
+
RUN useradd --create-home --uid ${BOBI_UID} --shell /bin/bash bobi
|
|
174
|
+
|
|
175
|
+
# Layers are ordered stable → volatile so a code-only rebuild touches only the
|
|
176
|
+
# last (cheap) layers — the model download and `claude` fetch stay cached. See
|
|
177
|
+
# docs/design/CUSTOM_AGENT_DEPS.md §"three clocks" for the ordering rationale.
|
|
178
|
+
|
|
179
|
+
# --- stable layers (cached across code/framework changes) ----------#
|
|
180
|
+
# Pinned native `claude` CLI (no Node) installed as the bobi user so it
|
|
181
|
+
# lands in ~/.local/bin (on PATH above). Cache key is CLAUDE_VERSION alone.
|
|
182
|
+
USER bobi
|
|
183
|
+
RUN curl -fsSL https://claude.ai/install.sh | bash -s -- "${CLAUDE_VERSION}" \
|
|
184
|
+
&& /home/bobi/.local/bin/claude --version
|
|
185
|
+
USER root
|
|
186
|
+
|
|
187
|
+
# General-purpose LLM gateway CLI (aichat): lets any agent call out to other
|
|
188
|
+
# models over OpenAI-compatible endpoints — a one-shot model call, NOT agent
|
|
189
|
+
# delegation (that's codex). Baked into the base image for every team, like
|
|
190
|
+
# git/claude; the image stays generic — provider/model/key come from the team's
|
|
191
|
+
# `gateway` connection + env at runtime, never from this layer. Pinned,
|
|
192
|
+
# arch-detected static musl binary into /usr/local/bin (system-wide, on PATH).
|
|
193
|
+
# Cache key is AICHAT_VERSION alone, so a code-only rebuild never re-fetches it.
|
|
194
|
+
RUN arch="$(dpkg --print-architecture)" \
|
|
195
|
+
&& case "$arch" in \
|
|
196
|
+
amd64) target=x86_64-unknown-linux-musl ;; \
|
|
197
|
+
arm64) target=aarch64-unknown-linux-musl ;; \
|
|
198
|
+
*) echo "aichat: unsupported arch $arch" >&2; exit 1 ;; \
|
|
199
|
+
esac \
|
|
200
|
+
&& curl -fsSL "https://github.com/sigoden/aichat/releases/download/v${AICHAT_VERSION}/aichat-v${AICHAT_VERSION}-${target}.tar.gz" \
|
|
201
|
+
| tar -xz -C /usr/local/bin aichat \
|
|
202
|
+
&& chmod +x /usr/local/bin/aichat \
|
|
203
|
+
&& aichat --version
|
|
204
|
+
|
|
205
|
+
# Baked fastembed embedding model (cold-start speed; immutable). HF_HOME points
|
|
206
|
+
# here at both build and run, so it's a cache hit at runtime. Copied from
|
|
207
|
+
# model-baker, whose only cache key is the fastembed version — so an ordinary
|
|
208
|
+
# code change never re-downloads the model.
|
|
209
|
+
COPY --from=model-baker /opt/bobi/models /opt/bobi/models
|
|
210
|
+
|
|
211
|
+
# --- team-deps hook (C24 team-flavored images) ---------------------#
|
|
212
|
+
# A team's baked host tools (node, codex, gstack, …) as ONE stable layer,
|
|
213
|
+
# rendered from its `build:` spec by bobi/build_render.py and injected via
|
|
214
|
+
# the TEAM_DEPS build-arg. The default is a no-op, so a plain build / a no-deps
|
|
215
|
+
# team is byte-identical to the generic image. Positioned as the LAST stable
|
|
216
|
+
# layer — just BELOW the volatile venv COPY — so a code-only framework release
|
|
217
|
+
# rebuilds only the wheel, never re-runs the team's apt/npm/run. The hook runs
|
|
218
|
+
# as root (USER is root here); it `gosu`s to bobi to bake ~-relative tools
|
|
219
|
+
# into the image HOME (/home/bobi/.claude/skills, ~/dev/gstack), which the
|
|
220
|
+
# agent reads in place at runtime — the entrypoint redirects only Claude's
|
|
221
|
+
# durable state to the volume (CLAUDE_CONFIG_DIR) and symlinks skills back.
|
|
222
|
+
# See docs/design/CUSTOM_AGENT_DEPS.md §"three clocks".
|
|
223
|
+
ARG TEAM_DEPS=docker/noop-deps.sh
|
|
224
|
+
COPY ${TEAM_DEPS} /tmp/team-deps.sh
|
|
225
|
+
RUN bash /tmp/team-deps.sh && rm -f /tmp/team-deps.sh
|
|
226
|
+
|
|
227
|
+
# --- volatile layer (rebuilds on any framework/code change) --------#
|
|
228
|
+
# The prebuilt venv (bobi + deps) is the LAST heavy layer, so a code-only
|
|
229
|
+
# rebuild is just this copy plus the thin layers below — seconds, not minutes.
|
|
230
|
+
# Root-owned, world-readable.
|
|
231
|
+
COPY --from=builder /opt/venv /opt/venv
|
|
232
|
+
# Codex tool shells sanitize PATH and drop /opt/venv/bin. Surface the
|
|
233
|
+
# framework CLI in both user-local and system bins so agents can call
|
|
234
|
+
# `bobi` by bare name from Codex's current shell.
|
|
235
|
+
RUN mkdir -p /home/bobi/.local/bin \
|
|
236
|
+
&& printf '%s\n' '#!/bin/sh' 'exec /opt/venv/bin/bobi "$@"' \
|
|
237
|
+
> /usr/local/bin/bobi \
|
|
238
|
+
&& cp /usr/local/bin/bobi /home/bobi/.local/bin/bobi \
|
|
239
|
+
&& chown bobi:bobi /home/bobi/.local/bin/bobi \
|
|
240
|
+
&& chmod +x /usr/local/bin/bobi /home/bobi/.local/bin/bobi
|
|
241
|
+
|
|
242
|
+
COPY docker/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
|
243
|
+
COPY docker/healthcheck.sh /usr/local/bin/healthcheck.sh
|
|
244
|
+
RUN chmod +x /usr/local/bin/docker-entrypoint.sh /usr/local/bin/healthcheck.sh
|
|
245
|
+
|
|
246
|
+
# Persistent state lives on this volume. Bobi runtime state defaults to
|
|
247
|
+
# /data/.bobi; Claude/Codex durable auth state uses /data/claude and /data/codex.
|
|
248
|
+
VOLUME ["/data"]
|
|
249
|
+
# WORKDIR must NOT be under /data: a volume mounted there shadows the
|
|
250
|
+
# build-time dir, so the container's cwd ceases to exist at runtime and the
|
|
251
|
+
# platform init (e.g. Fly Machines) fails to spawn the entrypoint with ENOENT.
|
|
252
|
+
# The entrypoint binds BOBI_ROOT to the selected agent run directory.
|
|
253
|
+
WORKDIR /
|
|
254
|
+
|
|
255
|
+
# Liveness: read the manager's health port from the volume and probe /health.
|
|
256
|
+
# start-period is generous — first boot installs a team and warms a session.
|
|
257
|
+
HEALTHCHECK --interval=30s --timeout=5s --start-period=180s --retries=3 \
|
|
258
|
+
CMD ["/usr/local/bin/healthcheck.sh"]
|
|
259
|
+
|
|
260
|
+
# The entrypoint is PID 1 (under Fly's injected init): it does root-only volume
|
|
261
|
+
# setup, then `exec gosu`s to the bobi user running the selected agent under
|
|
262
|
+
# `bobi supervise`, so SIGTERM reaches the manager for graceful shutdown.
|
|
263
|
+
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
|
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
#
|
|
3
|
+
# bobi container entrypoint (containerized-8 / #338).
|
|
4
|
+
#
|
|
5
|
+
# Runs as root (PID 1, under Fly's injected init — no tini; see Dockerfile) only
|
|
6
|
+
# long enough to prepare the mounted volume, then drops to the non-root
|
|
7
|
+
# `bobi` user and exec's the manager. Because the final step is
|
|
8
|
+
# `exec gosu ... bobi supervise -- --foreground`, SIGTERM is forwarded to the
|
|
9
|
+
# selected agent manager, which shuts sessions down gracefully (C2).
|
|
10
|
+
#
|
|
11
|
+
# First-boot install (empty volume -> `bobi agents install`) lives here for now so
|
|
12
|
+
# the image is independently testable; #339 (C9) hardens the idempotency and
|
|
13
|
+
# edge cases of this path.
|
|
14
|
+
set -euo pipefail
|
|
15
|
+
|
|
16
|
+
APP_USER="bobi"
|
|
17
|
+
DATA_DIR="${DATA_DIR:-/data}"
|
|
18
|
+
log() { echo "[entrypoint] $*"; }
|
|
19
|
+
fatal() { echo "[entrypoint] FATAL: $*" >&2; exit 1; }
|
|
20
|
+
# $HOME stays on the IMAGE (/home/bobi) — that's where baked team tools
|
|
21
|
+
# (~/.claude/skills, ~/dev/gstack) live, read in place, never copied. Only
|
|
22
|
+
# Claude's DURABLE state (credentials + transcripts + session history) is
|
|
23
|
+
# redirected onto the volume via CLAUDE_CONFIG_DIR, the supported override.
|
|
24
|
+
# Splitting the two this way (vs. seeding tools onto a volume HOME) keeps build
|
|
25
|
+
# HOME == runtime HOME, so the image's `verify: requires` proves the live paths.
|
|
26
|
+
export HOME="${HOME:-/home/bobi}"
|
|
27
|
+
[ -n "${BOBI_HOME:-}" ] || fatal "BOBI_HOME is required. The image sets BOBI_HOME=/data/.bobi."
|
|
28
|
+
export BOBI_HOME
|
|
29
|
+
export CLAUDE_CONFIG_DIR="${CLAUDE_CONFIG_DIR:-${DATA_DIR}/claude}"
|
|
30
|
+
|
|
31
|
+
if [ -n "${BOBI_AGENT:-}" ]; then
|
|
32
|
+
AGENT_NAME="${BOBI_AGENT}"
|
|
33
|
+
elif [ -n "${BOBI_INSTANCE:-}" ]; then
|
|
34
|
+
AGENT_NAME="${BOBI_INSTANCE}"
|
|
35
|
+
elif [ -n "${BOBI_ROOT:-}" ] && [ "$(basename "${BOBI_ROOT}")" = "run" ]; then
|
|
36
|
+
AGENT_NAME="$(basename "$(dirname "${BOBI_ROOT}")")"
|
|
37
|
+
else
|
|
38
|
+
fatal "No Bobi Agent selected. Set BOBI_INSTANCE, BOBI_AGENT, or BOBI_ROOT."
|
|
39
|
+
fi
|
|
40
|
+
RUN_ROOT="${BOBI_ROOT:-${BOBI_HOME}/agents/${AGENT_NAME}/run}"
|
|
41
|
+
export BOBI_ROOT="${RUN_ROOT}"
|
|
42
|
+
PACKAGE_DIR="${RUN_ROOT}/package"
|
|
43
|
+
|
|
44
|
+
# The agent brain (#485). Decides the provider API key (api_key/subscription),
|
|
45
|
+
# the durable OAuth credential dir on the volume, and the credential file the
|
|
46
|
+
# first-boot subscription bootstrap waits for. Default claude for entrypoint
|
|
47
|
+
# branching only; do not export that default because agent.yaml must still be
|
|
48
|
+
# able to select a non-Claude brain when BOBI_BRAIN was not explicit.
|
|
49
|
+
ENTRYPOINT_BRAIN="${BOBI_BRAIN:-claude}"
|
|
50
|
+
|
|
51
|
+
configure_brain_paths() {
|
|
52
|
+
case "$ENTRYPOINT_BRAIN" in
|
|
53
|
+
codex)
|
|
54
|
+
BRAIN_SHADOW_KEY="OPENAI_API_KEY"
|
|
55
|
+
BRAIN_CRED_DIR="${DATA_DIR}/codex" # ~/.codex symlinks here
|
|
56
|
+
BRAIN_HOME_LINK="${HOME}/.codex"
|
|
57
|
+
BRAIN_CRED_FILE="auth.json"
|
|
58
|
+
;;
|
|
59
|
+
*) # claude (default): durable state already lives under CLAUDE_CONFIG_DIR
|
|
60
|
+
BRAIN_SHADOW_KEY="ANTHROPIC_API_KEY"
|
|
61
|
+
BRAIN_CRED_DIR="${CLAUDE_CONFIG_DIR}"
|
|
62
|
+
BRAIN_HOME_LINK="${HOME}/.claude"
|
|
63
|
+
BRAIN_CRED_FILE=".credentials.json"
|
|
64
|
+
;;
|
|
65
|
+
esac
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
validate_auth_mode() {
|
|
69
|
+
# The provider API key is brain-specific (ANTHROPIC_API_KEY / OPENAI_API_KEY);
|
|
70
|
+
# ${!BRAIN_SHADOW_KEY} is its live value via bash indirect expansion.
|
|
71
|
+
case "${BOBI_AUTH:-api_key}" in
|
|
72
|
+
api_key)
|
|
73
|
+
[ -n "${!BRAIN_SHADOW_KEY:-}" ] \
|
|
74
|
+
|| fatal "BOBI_AUTH=api_key but ${BRAIN_SHADOW_KEY} is unset."
|
|
75
|
+
;;
|
|
76
|
+
subscription)
|
|
77
|
+
# The provider API key silently outranks subscription OAuth creds and bills
|
|
78
|
+
# the API instead — it must be entirely absent in this mode (§6.1).
|
|
79
|
+
[ -z "${!BRAIN_SHADOW_KEY:-}" ] \
|
|
80
|
+
|| fatal "BOBI_AUTH=subscription but ${BRAIN_SHADOW_KEY} is set; it overrides subscription auth. Unset it."
|
|
81
|
+
;;
|
|
82
|
+
*)
|
|
83
|
+
fatal "unknown BOBI_AUTH='${BOBI_AUTH}' (expected api_key|subscription)."
|
|
84
|
+
;;
|
|
85
|
+
esac
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
resolve_configured_brain() {
|
|
89
|
+
[ -z "${BOBI_BRAIN:-}" ] || return 0
|
|
90
|
+
[ -f "${PACKAGE_DIR}/agent.yaml" ] || return 0
|
|
91
|
+
|
|
92
|
+
local configured
|
|
93
|
+
if configured="$(BOBI_ROOT="${RUN_ROOT}" python - <<'PY' 2>/dev/null
|
|
94
|
+
import os
|
|
95
|
+
from pathlib import Path
|
|
96
|
+
|
|
97
|
+
from bobi.config import Config
|
|
98
|
+
|
|
99
|
+
print(Config.load(Path(os.environ["BOBI_ROOT"])).brain_kind or "", end="")
|
|
100
|
+
PY
|
|
101
|
+
)" && [ -n "${configured}" ]; then
|
|
102
|
+
ENTRYPOINT_BRAIN="${configured}"
|
|
103
|
+
fi
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
resolve_configured_brain
|
|
107
|
+
configure_brain_paths
|
|
108
|
+
|
|
109
|
+
materialize_codex_api_key_auth() {
|
|
110
|
+
local cred_dir="$1"
|
|
111
|
+
[ -n "${OPENAI_API_KEY:-}" ] || return 0
|
|
112
|
+
log "Writing Codex API-key auth file from OPENAI_API_KEY"
|
|
113
|
+
mkdir -p "${cred_dir}"
|
|
114
|
+
CODEX_CRED_DIR="${cred_dir}" OPENAI_API_KEY="${OPENAI_API_KEY}" python - <<'PY'
|
|
115
|
+
import json
|
|
116
|
+
import os
|
|
117
|
+
from pathlib import Path
|
|
118
|
+
|
|
119
|
+
path = Path(os.environ["CODEX_CRED_DIR"]) / "auth.json"
|
|
120
|
+
path.write_text(json.dumps({"OPENAI_API_KEY": os.environ["OPENAI_API_KEY"]}) + "\n")
|
|
121
|
+
path.chmod(0o600)
|
|
122
|
+
PY
|
|
123
|
+
chown -R "${APP_USER}:${APP_USER}" "${cred_dir}"
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
codex_auth_uses_api_key() {
|
|
127
|
+
local cred_dir="$1"
|
|
128
|
+
CODEX_CRED_DIR="${cred_dir}" python - <<'PY'
|
|
129
|
+
import json
|
|
130
|
+
import os
|
|
131
|
+
import sys
|
|
132
|
+
from pathlib import Path
|
|
133
|
+
|
|
134
|
+
try:
|
|
135
|
+
data = json.loads((Path(os.environ["CODEX_CRED_DIR"]) / "auth.json").read_text())
|
|
136
|
+
except Exception:
|
|
137
|
+
sys.exit(1)
|
|
138
|
+
sys.exit(0 if isinstance(data, dict) and "OPENAI_API_KEY" in data else 1)
|
|
139
|
+
PY
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
AUTH_VALIDATED=0
|
|
143
|
+
if [ -n "${BOBI_BRAIN:-}" ] \
|
|
144
|
+
|| [ -f "${PACKAGE_DIR}/agent.yaml" ] \
|
|
145
|
+
|| { [ -z "${BOBI_TEAM_URL:-}" ] && [ -z "${BOBI_TEAM:-}" ]; }; then
|
|
146
|
+
validate_auth_mode
|
|
147
|
+
AUTH_VALIDATED=1
|
|
148
|
+
fi
|
|
149
|
+
|
|
150
|
+
# --- 1. Prepare the volume (root) -------------------------------------------
|
|
151
|
+
# Only durable state lives on the volume: BOBI_HOME, the selected run root, and
|
|
152
|
+
# Claude's config dir (CLAUDE_CONFIG_DIR). HOME is on the image and needs no
|
|
153
|
+
# volume prep.
|
|
154
|
+
mkdir -p "${BOBI_HOME}" "${RUN_ROOT}" "${RUN_ROOT}/workspace" "${CLAUDE_CONFIG_DIR}"
|
|
155
|
+
|
|
156
|
+
# Fly/EC2/k8s mount fresh volumes owned by root. Take ownership once so the
|
|
157
|
+
# non-root user can write; a stamp keeps subsequent boots from re-walking a
|
|
158
|
+
# large, already-correct tree.
|
|
159
|
+
if [ ! -e "${DATA_DIR}/.bobi-owned" ]; then
|
|
160
|
+
log "Taking ownership of ${DATA_DIR} for ${APP_USER} (first boot)"
|
|
161
|
+
chown -R "${APP_USER}:${APP_USER}" "${DATA_DIR}"
|
|
162
|
+
: > "${DATA_DIR}/.bobi-owned"
|
|
163
|
+
else
|
|
164
|
+
chown "${APP_USER}:${APP_USER}" "${DATA_DIR}" "${BOBI_HOME}" "${RUN_ROOT}" "${CLAUDE_CONFIG_DIR}"
|
|
165
|
+
fi
|
|
166
|
+
|
|
167
|
+
# --- 1b. Make ~/.claude coincide with the durable volume config dir (C24) -----
|
|
168
|
+
# $HOME stays on the image (baked tools read in place), but Claude's DURABLE
|
|
169
|
+
# state (creds, transcripts, settings) lives under CLAUDE_CONFIG_DIR on the
|
|
170
|
+
# volume. Point the whole ~/.claude AT that dir, so any tool/skill that hardcodes
|
|
171
|
+
# ~/.claude/{projects,settings.json,skills,…} sees Claude's real state — one
|
|
172
|
+
# coherent home tree, split underneath only by storage lifecycle (image vs
|
|
173
|
+
# volume), invisible to anything using `~`.
|
|
174
|
+
#
|
|
175
|
+
# Personal skills are baked OUTSIDE ~/.claude at /opt/bobi/skills (immutable
|
|
176
|
+
# image content; build-render.py put them there) and surfaced via the config
|
|
177
|
+
# dir's skills/ entry — a symlinked DIRECTORY is safe (files read inside it) and
|
|
178
|
+
# resolves to the exact path the build's `verify: requires` checked. No baked
|
|
179
|
+
# skills (generic image) → that link is skipped; Claude finds project skills.
|
|
180
|
+
if [ -d /opt/bobi/skills ]; then
|
|
181
|
+
log "Linking ${CLAUDE_CONFIG_DIR}/skills -> /opt/bobi/skills (baked team skills)"
|
|
182
|
+
ln -sfn /opt/bobi/skills "${CLAUDE_CONFIG_DIR}/skills"
|
|
183
|
+
fi
|
|
184
|
+
# Replace the image's real ~/.claude (created at build) with a symlink to the
|
|
185
|
+
# volume config dir. Idempotent: rewrite only when it isn't already that link
|
|
186
|
+
# (a fresh image rootfs each deploy ships the real dir; a same-machine restart
|
|
187
|
+
# already has the link). rm -rf only ever discards ephemeral image content —
|
|
188
|
+
# the durable state and the baked skills both live elsewhere.
|
|
189
|
+
if [ "$(readlink "${HOME}/.claude" 2>/dev/null)" != "${CLAUDE_CONFIG_DIR}" ]; then
|
|
190
|
+
log "Pointing ${HOME}/.claude -> ${CLAUDE_CONFIG_DIR} (durable config on volume)"
|
|
191
|
+
rm -rf "${HOME}/.claude"
|
|
192
|
+
ln -s "${CLAUDE_CONFIG_DIR}" "${HOME}/.claude"
|
|
193
|
+
fi
|
|
194
|
+
|
|
195
|
+
cd "${RUN_ROOT}"
|
|
196
|
+
|
|
197
|
+
# Carry HOME (the image home), BOBI_HOME/BOBI_ROOT (the durable selected Bobi
|
|
198
|
+
# runtime), and CLAUDE_CONFIG_DIR (the volume dir holding durable
|
|
199
|
+
# creds/transcripts) into every privilege drop.
|
|
200
|
+
as_app() {
|
|
201
|
+
if [ -n "${BOBI_BRAIN:-}" ] || [ "${ENTRYPOINT_BRAIN}" != "claude" ]; then
|
|
202
|
+
gosu "${APP_USER}" env "HOME=${HOME}" "BOBI_HOME=${BOBI_HOME}" "BOBI_ROOT=${BOBI_ROOT}" "CLAUDE_CONFIG_DIR=${CLAUDE_CONFIG_DIR}" "BOBI_BRAIN=${ENTRYPOINT_BRAIN}" "$@"
|
|
203
|
+
else
|
|
204
|
+
gosu "${APP_USER}" env "HOME=${HOME}" "BOBI_HOME=${BOBI_HOME}" "BOBI_ROOT=${BOBI_ROOT}" "CLAUDE_CONFIG_DIR=${CLAUDE_CONFIG_DIR}" "$@"
|
|
205
|
+
fi
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
# --- 2. First boot: install a team if the volume has no agent (C9 hardens) ---
|
|
209
|
+
# Team source precedence: a public BOBI_TEAM_URL (fetched at boot — the
|
|
210
|
+
# dark instance reaches out, nothing reaches in) wins over BOBI_TEAM (a
|
|
211
|
+
# bundled/registry name).
|
|
212
|
+
#
|
|
213
|
+
# With NEITHER set on an empty volume the instance enters the "wait for team"
|
|
214
|
+
# state instead of crashing: it was provisioned blank for ssh-push delivery
|
|
215
|
+
# (`bobi deploy` with a local `team:` package — DEPLOY_INTERFACE.md). The
|
|
216
|
+
# operator pushes the team over `fly ssh` (sftp the tarball + `bobi agents install`),
|
|
217
|
+
# which lands run/package/agent.yaml on the volume; we poll for it, then start.
|
|
218
|
+
# This is the single-developer "I built it, ship it — no hosting" path, and it
|
|
219
|
+
# keeps PID 1 alive so the Fly machine stays "started" while we wait.
|
|
220
|
+
if [ ! -f "${PACKAGE_DIR}/agent.yaml" ]; then
|
|
221
|
+
if [ -n "${BOBI_TEAM_URL:-}" ]; then
|
|
222
|
+
log "First boot: installing team from URL ${BOBI_TEAM_URL} (non-interactive)"
|
|
223
|
+
as_app bobi agents install "${BOBI_TEAM_URL}" --name "${AGENT_NAME}" --non-interactive
|
|
224
|
+
elif [ -n "${BOBI_TEAM:-}" ]; then
|
|
225
|
+
log "First boot: installing team '${BOBI_TEAM}' (non-interactive)"
|
|
226
|
+
# BOBI_TEAM must resolve to something the INSTANCE can see: a path on the
|
|
227
|
+
# volume (e.g. an ssh-pushed /mnt/team) or a local package — there is NO team
|
|
228
|
+
# registry baked into the image, so a bare name won't resolve. Fail LOUD with
|
|
229
|
+
# the actionable alternative instead of letting `set -e` crash-loop the Fly
|
|
230
|
+
# machine on a bare pipefail trace (C9/#339).
|
|
231
|
+
if ! as_app bobi agents install "${BOBI_TEAM}" --name "${AGENT_NAME}" --non-interactive; then
|
|
232
|
+
log "ERROR: couldn't install team '${BOBI_TEAM}'. The container has no"
|
|
233
|
+
log " team registry, so BOBI_TEAM only resolves a path/package the"
|
|
234
|
+
log " instance can already see. To deliver a PUBLISHED team, set"
|
|
235
|
+
log " BOBI_TEAM_URL=<https .tar.gz> instead; to deliver a LOCAL"
|
|
236
|
+
log " package, use 'bobi deploy <name>' (ssh-push, no team source"
|
|
237
|
+
log " on the instance). See DEPLOYMENT.md / DEPLOY_INTERFACE.md."
|
|
238
|
+
exit 1
|
|
239
|
+
fi
|
|
240
|
+
else
|
|
241
|
+
log "No team source and empty volume — blank instance, waiting for an"
|
|
242
|
+
log "ssh-push team delivery (bobi deploy). Poll for ${PACKAGE_DIR}/agent.yaml..."
|
|
243
|
+
waited=0
|
|
244
|
+
while [ ! -f "${PACKAGE_DIR}/agent.yaml" ]; do
|
|
245
|
+
sleep 2
|
|
246
|
+
waited=$((waited + 2))
|
|
247
|
+
# Heartbeat every ~2 min so `fly logs` shows the instance is alive, not hung.
|
|
248
|
+
if [ $((waited % 120)) -eq 0 ]; then
|
|
249
|
+
log "Still waiting for a pushed team (${waited}s)..."
|
|
250
|
+
fi
|
|
251
|
+
done
|
|
252
|
+
log "Team appeared on the volume after ${waited}s — proceeding to start."
|
|
253
|
+
fi
|
|
254
|
+
fi
|
|
255
|
+
|
|
256
|
+
resolve_configured_brain
|
|
257
|
+
configure_brain_paths
|
|
258
|
+
|
|
259
|
+
# --- 3. Validate auth mode --------------------------------------------------
|
|
260
|
+
# First-boot team installs can define ``brain.kind`` in agent.yaml, so their
|
|
261
|
+
# shadow-key check may need to wait until after install. Blank/no-team boots and
|
|
262
|
+
# existing installs validate before the wait-for-team loop so auth mistakes fail
|
|
263
|
+
# fast instead of hanging as a blank instance.
|
|
264
|
+
if [ "${AUTH_VALIDATED}" != "1" ]; then
|
|
265
|
+
validate_auth_mode
|
|
266
|
+
fi
|
|
267
|
+
|
|
268
|
+
# --- 3b. Codex's durable OAuth dir on the volume (#485) ---------------------
|
|
269
|
+
# Same idea for codex: ~/.codex (where `codex login`/`codex exec` keep auth.json)
|
|
270
|
+
# points at a volume dir so the ChatGPT subscription survives a redeploy. claude
|
|
271
|
+
# already gets this via CLAUDE_CONFIG_DIR above; codex has no config-dir override,
|
|
272
|
+
# so we symlink the home dir directly.
|
|
273
|
+
if [ "${ENTRYPOINT_BRAIN}" = "codex" ]; then
|
|
274
|
+
mkdir -p "${BRAIN_CRED_DIR}"
|
|
275
|
+
chown "${APP_USER}:${APP_USER}" "${BRAIN_CRED_DIR}"
|
|
276
|
+
if [ -d /opt/bobi/skills ]; then
|
|
277
|
+
log "Linking baked team skills into ${BRAIN_CRED_DIR}/skills for codex"
|
|
278
|
+
mkdir -p "${BRAIN_CRED_DIR}/skills"
|
|
279
|
+
chown "${APP_USER}:${APP_USER}" "${BRAIN_CRED_DIR}/skills"
|
|
280
|
+
for existing_skill in "${BRAIN_CRED_DIR}/skills"/*; do
|
|
281
|
+
[ -L "${existing_skill}" ] || continue
|
|
282
|
+
existing_target="$(readlink "${existing_skill}")"
|
|
283
|
+
case "${existing_target}" in
|
|
284
|
+
/opt/bobi/skills/*)
|
|
285
|
+
if [ ! -e "${existing_target}" ]; then
|
|
286
|
+
log "Removing stale baked codex skill link ${existing_skill}"
|
|
287
|
+
rm -f "${existing_skill}"
|
|
288
|
+
fi
|
|
289
|
+
;;
|
|
290
|
+
esac
|
|
291
|
+
done
|
|
292
|
+
for skill_path in /opt/bobi/skills/*; do
|
|
293
|
+
[ -e "${skill_path}" ] || continue
|
|
294
|
+
skill_name="$(basename "${skill_path}")"
|
|
295
|
+
skill_dest="${BRAIN_CRED_DIR}/skills/${skill_name}"
|
|
296
|
+
if [ -e "${skill_dest}" ] || [ -L "${skill_dest}" ]; then
|
|
297
|
+
if [ -L "${skill_dest}" ]; then
|
|
298
|
+
skill_dest_target="$(readlink "${skill_dest}")"
|
|
299
|
+
case "${skill_dest_target}" in
|
|
300
|
+
/opt/bobi/skills/*) ;;
|
|
301
|
+
*)
|
|
302
|
+
log "Leaving existing codex skill link at ${skill_dest}; baked skill ${skill_name} not linked"
|
|
303
|
+
continue
|
|
304
|
+
;;
|
|
305
|
+
esac
|
|
306
|
+
else
|
|
307
|
+
log "Leaving existing codex skill at ${skill_dest}; baked skill ${skill_name} not linked"
|
|
308
|
+
continue
|
|
309
|
+
fi
|
|
310
|
+
fi
|
|
311
|
+
ln -sfnT "${skill_path}" "${skill_dest}"
|
|
312
|
+
done
|
|
313
|
+
fi
|
|
314
|
+
if [ "$(readlink "${BRAIN_HOME_LINK}" 2>/dev/null)" != "${BRAIN_CRED_DIR}" ]; then
|
|
315
|
+
log "Pointing ${BRAIN_HOME_LINK} -> ${BRAIN_CRED_DIR} (durable codex creds on volume)"
|
|
316
|
+
rm -rf "${BRAIN_HOME_LINK}"
|
|
317
|
+
ln -s "${BRAIN_CRED_DIR}" "${BRAIN_HOME_LINK}"
|
|
318
|
+
fi
|
|
319
|
+
fi
|
|
320
|
+
|
|
321
|
+
# The Codex CLI also exists as an auxiliary tool for Claude-brained teams
|
|
322
|
+
# (`tool_library: [codex]`). Unlike Claude, Codex does not read OPENAI_API_KEY
|
|
323
|
+
# directly; it expects ~/.codex/auth.json. In subscription mode, never turn an
|
|
324
|
+
# ambient API key into Codex auth: subscription OAuth must remain authoritative.
|
|
325
|
+
if [ "${BOBI_AUTH:-api_key}" != "subscription" ]; then
|
|
326
|
+
if [ "${ENTRYPOINT_BRAIN}" = "codex" ]; then
|
|
327
|
+
materialize_codex_api_key_auth "${BRAIN_CRED_DIR}"
|
|
328
|
+
else
|
|
329
|
+
materialize_codex_api_key_auth "${HOME}/.codex"
|
|
330
|
+
fi
|
|
331
|
+
elif [ -n "${OPENAI_API_KEY:-}" ]; then
|
|
332
|
+
log "Subscription mode: leaving OPENAI_API_KEY out of Codex auth materialization"
|
|
333
|
+
fi
|
|
334
|
+
|
|
335
|
+
if [ "${BOBI_AUTH:-api_key}" = "subscription" ]; then
|
|
336
|
+
if [ "${ENTRYPOINT_BRAIN}" = "codex" ]; then
|
|
337
|
+
codex_dir="${BRAIN_CRED_DIR}"
|
|
338
|
+
else
|
|
339
|
+
codex_dir="${HOME}/.codex"
|
|
340
|
+
fi
|
|
341
|
+
if codex_auth_uses_api_key "${codex_dir}"; then
|
|
342
|
+
log "Subscription mode: removing Codex API-key auth file so OAuth can be used"
|
|
343
|
+
rm -f "${codex_dir}/auth.json"
|
|
344
|
+
fi
|
|
345
|
+
fi
|
|
346
|
+
|
|
347
|
+
# --- 4. Subscription auth: bootstrap login over Slack if no creds yet (C23) --
|
|
348
|
+
# Idempotent: a no-op once the credentials exist. They live under
|
|
349
|
+
# CLAUDE_CONFIG_DIR (the volume), not HOME — that's the durable state we keep.
|
|
350
|
+
if [ "${BOBI_AUTH:-api_key}" = "subscription" ] \
|
|
351
|
+
&& [ ! -f "${BRAIN_CRED_DIR}/${BRAIN_CRED_FILE}" ]; then
|
|
352
|
+
log "Subscription mode, no ${ENTRYPOINT_BRAIN} credentials on volume — running login bootstrap"
|
|
353
|
+
as_app bobi agent "${AGENT_NAME}" login-bootstrap
|
|
354
|
+
fi
|
|
355
|
+
|
|
356
|
+
# --- 5. Hand off to the manager as the non-root user ------------------------
|
|
357
|
+
# Agent UI on by default IN THE CONTAINER (the manager starts it on the private
|
|
358
|
+
# 6PN; reach it with `bobi agent <name> ui <deployment>` / `fly proxy`). It's image
|
|
359
|
+
# behavior, not a per-instance flag, so existing instances pick it up on their
|
|
360
|
+
# next image swap. Disable with BOBI_UI=0 in the Fly env. The dark instance
|
|
361
|
+
# has no public route, so this exposes nothing — see DESIGN.md "Agent UI".
|
|
362
|
+
export BOBI_UI="${BOBI_UI:-1}"
|
|
363
|
+
log "Starting manager under self-heal watchdog (user=${APP_USER}, agent=${AGENT_NAME}, run=${RUN_ROOT}, home=${HOME}, bobi_home=${BOBI_HOME}, claude_config=${CLAUDE_CONFIG_DIR})"
|
|
364
|
+
# #464: launch the manager under `bobi supervise` instead of directly.
|
|
365
|
+
# The supervisor is the entrypoint process (parent); it spawns the manager as a
|
|
366
|
+
# child, watches the director's progress via the health endpoint, and restarts
|
|
367
|
+
# a wedged director from below — the one recovery layer stall-recovery cannot
|
|
368
|
+
# provide. It runs no agent loop, so it cannot wedge from the same cause; on
|
|
369
|
+
# restart-budget exhaustion it exits non-zero and Fly's machine restart policy
|
|
370
|
+
# escalates. `healthcheck.sh` is unaffected (the manager child still writes the
|
|
371
|
+
# port file). The forwarded `--foreground` keeps the manager a supervisable
|
|
372
|
+
# child rather than letting it daemonize.
|
|
373
|
+
if [ -n "${BOBI_BRAIN:-}" ] || [ "${ENTRYPOINT_BRAIN}" != "claude" ]; then
|
|
374
|
+
exec gosu "${APP_USER}" env "HOME=${HOME}" "BOBI_HOME=${BOBI_HOME}" "BOBI_ROOT=${BOBI_ROOT}" "CLAUDE_CONFIG_DIR=${CLAUDE_CONFIG_DIR}" "BOBI_BRAIN=${ENTRYPOINT_BRAIN}" "BOBI_UI=${BOBI_UI}" bobi supervise -- --foreground "$@"
|
|
375
|
+
else
|
|
376
|
+
exec gosu "${APP_USER}" env "HOME=${HOME}" "BOBI_HOME=${BOBI_HOME}" "BOBI_ROOT=${BOBI_ROOT}" "CLAUDE_CONFIG_DIR=${CLAUDE_CONFIG_DIR}" "BOBI_UI=${BOBI_UI}" bobi supervise -- --foreground "$@"
|
|
377
|
+
fi
|