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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,14 @@
All notable changes to `codesema` (the npm package in `packages/cli`) are documented here.
Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning: [SemVer](https://semver.org).

## [0.17.0] - 2026-08-27

### Added

- **The arm's lifecycle is no longer one-way.** `codesema brain disconnect` clears the locally stored brain credentials (`syncUrl`/`syncWorkspaceId`/`syncSecret`), idempotent and non-interactive like the rest of `brain-commands.ts`, and reminds the caller to also revoke the arm server-side, in the repository's dashboard Settings — this command never talks to the brain itself, only the local file `brain connect` wrote. `codesema brain install-service` writes and enables a systemd `--user` unit for `codesema brain serve`, generated from the same template already shipped at `assets/systemd/codesema-brain.service` (its path resolved from the installed package the way `serve.ts` already resolves its embedded `web-dist`), pinned to the invoking repository's root and the actual binary running the command — never a bare `codesema` relying on `PATH`, which a systemd unit is not guaranteed to see the same way an interactive shell does — with an optional `--env-file` for secrets and `loginctl enable-linger` enabled best-effort (a failure, common in containers and WSL, is reported to the caller rather than failing the install). `codesema brain uninstall-service` reverses it; both commands are idempotent, the same doctrine `brain stop` already has for an absent pidfile.

- **An arm can now be installed on a server you already have, not only a freshly provisioned VM.** `packages/cli/assets/deploy/install.sh` is a runner-style installer, the same binary/OS split gitlab-runner's own installer uses: it checks Node.js (>= 20), `gh`, a container runtime (docker or rootless podman) and `codesema`/`claude-code` before installing any of them, clones the target repository through `gh auth setup-git` so no token ever lands in `.git/config`, and hands the systemd unit itself to `codesema brain install-service` rather than writing it by hand. `cloud-init.yaml.example`'s own embedded `provision.sh` is now a thin bootstrap — just enough Node.js to run `npm i -g codesema` — that calls this exact same script from the installed package, so the fresh-VM and existing-server paths share one implementation instead of two that could drift apart; `brain.env.example` grew `CODESEMA_BRAIN_URL` (defaulted to the production brain) and `REPO_URL` alongside the three secrets it already carried, one file feeding both paths. `docs/deploy-vm-arm.md` gains an "Existing server (BYOC)" runbook and an "Uninstall" section covering both paths, and states the same order-channel security gate for a server install as it already does for a real VPS — the constraint is the brain's order channel, not how the machine was provisioned.

## [0.16.0] - 2026-08-27

### Added
Expand Down
74 changes: 70 additions & 4 deletions docs/deploy-vm-arm.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,14 @@ gate below is lifted — on a real server, from the exact same artifact.

The provisioning content lives in `packages/cli/assets/deploy/`:

- `cloud-init.yaml.example` — the full cloud-init file. Copy it to
`cloud-init.local.yaml` (gitignored) and fill in every `__PLACEHOLDER__`.
- `brain.env.example` — the three secrets `cloud-init.local.yaml` needs, with
one line each on where to mint them.
- `cloud-init.yaml.example` — the full cloud-init file for a fresh machine.
Copy it to `cloud-init.local.yaml` (gitignored) and fill in every
`__PLACEHOLDER__`.
- `brain.env.example` — the five values `cloud-init.local.yaml` (and
`install.sh`, below) need, with one line each on where to mint them.
- `install.sh` — the same installer `cloud-init.yaml.example` calls under
the hood, runnable directly against a server you already have instead of
a fresh VM. See [Existing server (BYOC)](#existing-server-byoc) below.

## Security gate

Expand Down Expand Up @@ -156,3 +160,65 @@ Once the order-channel hardening has shipped, the same `cloud-init.local.yaml`
targets a real VPS unchanged: hand it to the provider's cloud-init field at
creation time instead of `multipass launch --cloud-init`. Nothing else in
this runbook changes.

## Existing server (BYOC)

Already have a server — a VPS, a machine in your own fleet — instead of
provisioning a fresh one? Skip the VM and cloud-init entirely and run the
installer directly on it. Same artifact, same end state: `install.sh` is
exactly what `cloud-init.yaml.example`'s own `provision.sh` calls once it
has bootstrapped just enough (Node.js, `npm i -g codesema`) to run it, so
there is one place — not two — that knows how to turn a machine into a
running arm.

```bash
REPO_URL=https://github.com/org/repo.git \
GH_TOKEN=... \
CLAUDE_CODE_OAUTH_TOKEN=... \
CODESEMA_BRAIN_TOKEN=csk_... \
bash packages/cli/assets/deploy/install.sh
```

The same five values `brain.env.example` documents (steps 1-2 above mint
them the same way), passed as environment variables instead of pasted into
a YAML file — `CODESEMA_BRAIN_URL` defaults to `https://codesema.com` if
left unset. Any of the other four left unset is prompted for interactively
when the script is run from a terminal; a piped or otherwise non-interactive
run fails loudly instead of hanging on a prompt nobody can answer.

`install.sh` is idempotent, the same "check before acting" doctrine
throughout this file: Node.js (>= 20, else nodesource), `gh` (else the
official apt repo), a container runtime (docker or podman, installing
rootless podman only if neither is present) and `codesema`/`claude-code` are
all checked before anything is installed, the repository is cloned only if
not already there, and the run ends by calling `codesema brain
install-service` itself — it never writes the systemd unit by hand.

**Same gate as step 7 above (a real server).** This puts a 24/7 arm on a
machine whose order channel has no signature or confirmation and no kill
switch yet — the constraint is the brain's order channel, not how the
machine was provisioned, so it applies here exactly as it does to a fresh
VPS. Do not point this at a real, internet-facing server until that
hardening has shipped.

## Uninstall

**VM**: `multipass delete codesema-arm --purge`. Separately, in the
dashboard, switch the repository's execution mode back from arm to server —
deleting the VM does not do that for you. In-flight tickets need no manual
cleanup: a claim's lease expires on its own once nothing renews it with a
heartbeat.

**Existing server**:

```bash
codesema brain uninstall-service # stops and removes the systemd --user unit
codesema brain disconnect # clears the locally stored brain credentials
npm uninstall -g codesema @anthropic-ai/claude-code
```

Then, in the dashboard, revoke this arm from the repository's Settings —
`brain disconnect` only clears this machine's own copy of the credentials,
it does not revoke them server-side — and switch the repository's execution
mode back from arm to server if you want the brain's own scheduler to pick
this repository back up.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "codesema-tools",
"version": "0.16.0",
"version": "0.17.0",
"private": true,
"type": "module",
"workspaces": [
Expand Down
21 changes: 16 additions & 5 deletions packages/cli/assets/deploy/brain.env.example
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
# codesema brain daemon environment, loaded by the systemd unit through
# EnvironmentFile= (see cloud-init.yaml.example). Copy, fill in the three
# values, then paste them into cloud-init.local.yaml's write_files block —
# or scp this file straight to /etc/codesema/brain.env on a provisioned host.
# Full minting steps: docs/deploy-vm-arm.md.
# One file, everything install.sh and cloud-init.yaml.example need to stand
# up a codesema arm — copy, fill in every __PLACEHOLDER__, then either paste
# these five lines into cloud-init.local.yaml's write_files block, or export
# them directly before running install.sh by hand on an existing server (or
# scp this file to /etc/codesema/brain.env and `set -a; . brain.env; set +a`
# first). Full minting steps: docs/deploy-vm-arm.md.
#
# Only CLAUDE_CODE_OAUTH_TOKEN and GH_TOKEN are read by the running daemon
# (systemd's EnvironmentFile=, every ticket); the other three are consumed
# once, at install time, by install.sh and `codesema brain connect`.

# Long-lived token from `claude setup-token`, run on any machine with a browser.
CLAUDE_CODE_OAUTH_TOKEN=__PLACEHOLDER__
Expand All @@ -12,3 +17,9 @@ GH_TOKEN=__PLACEHOLDER__

# csk_<workspaceId>.<secret>, minted with `codesema link` against a scratch CODESEMA_CONFIG_DIR.
CODESEMA_BRAIN_TOKEN=__PLACEHOLDER__

# The brain to connect to. Real default already filled in: change it only for a self-hosted brain.
CODESEMA_BRAIN_URL=https://codesema.com

# HTTPS clone URL of the repository this arm works.
REPO_URL=__PLACEHOLDER__
147 changes: 48 additions & 99 deletions packages/cli/assets/deploy/cloud-init.yaml.example
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@
# once the order-channel hardening gate is lifted, the real VPS deployment
# described in docs/deploy-vm-arm.md. Same file, same content, either target.
#
# This is the "fresh machine" path. For an EXISTING server you already
# manage, use assets/deploy/install.sh directly instead — this file's own
# provision.sh is a thin bootstrap (just enough to get npm running, chicken-
# egg) that ends by calling that exact same script, so there is only one
# place that knows how to turn a bare machine into a running arm.
#
# Usage: cp cloud-init.yaml.example cloud-init.local.yaml, replace every
# __PLACEHOLDER__ below (brain.env.example says where each one comes from),
# then:
Expand Down Expand Up @@ -42,46 +48,22 @@ write_files:
# defer: true — cloud-init's default module order runs write_files BEFORE
# users-groups, so an `owner: codesema:...` here would fail with "no such
# user" unless the write is deferred past user creation.
#
# All five values install.sh needs, in the one file it (and the systemd
# unit it installs, through EnvironmentFile=) reads from — see
# brain.env.example for where each one comes from. CODESEMA_BRAIN_URL
# ships with a real default rather than a placeholder: override it only
# for a self-hosted brain.
- path: /etc/codesema/brain.env
owner: root:codesema
permissions: '0640'
defer: true
content: |
# Filled in from cloud-init.local.yaml; loaded by the systemd unit
# below through EnvironmentFile= (see brain.env.example for how to
# mint each value).
CLAUDE_CODE_OAUTH_TOKEN=__PLACEHOLDER__
GH_TOKEN=__PLACEHOLDER__
CODESEMA_BRAIN_TOKEN=__PLACEHOLDER__

- path: /home/codesema/.config/systemd/user/codesema-brain.service
owner: codesema:codesema
permissions: '0644'
defer: true
content: |
# Derived from packages/cli/assets/systemd/codesema-brain.service:
# only WorkingDirectory/EnvironmentFile/ExecStart are pinned to this
# VM's layout, nothing else changed. Stop with
# `systemctl --user stop codesema-brain.service`, never a raw `kill`
# or `codesema brain stop`: Restart=on-failure relaunches either.
[Unit]
Description=codesema brain daemon
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
WorkingDirectory=/home/codesema/codesema-bench
EnvironmentFile=/etc/codesema/brain.env
# Absolute path: systemd --user units do not reliably inherit an
# interactive shell's PATH, and this is where npm puts global bins
# when node itself comes from the nodesource package (prefix /usr).
ExecStart=/usr/bin/codesema brain serve
Restart=on-failure
RestartSec=5

[Install]
WantedBy=default.target
CODESEMA_BRAIN_URL=https://codesema.com
REPO_URL=__PLACEHOLDER__

# No defer needed: root:root, no dependency on the codesema user existing.
- path: /opt/codesema/provision.sh
Expand All @@ -92,45 +74,30 @@ write_files:
exec > >(tee -a /var/log/codesema-provision.log) 2>&1
echo "[codesema-provision] starting at $(date -u +%FT%TZ)"

# The bench repo this arm works; replace before launch, same as the
# three secrets in /etc/codesema/brain.env.
BENCH_REPO_URL="__CODESEMA_BENCH_REPO_URL__"

# Fail fast and loud on a forgotten placeholder rather than let the
# service crashloop later with no clue why.
# Fail fast and loud on a forgotten placeholder rather than let
# install.sh (or the service it enables) crashloop later with no clue
# why.
if grep -q '__PLACEHOLDER__' /etc/codesema/brain.env; then
echo "[codesema-provision] /etc/codesema/brain.env still has __PLACEHOLDER__ values, aborting" >&2
exit 1
fi
if [ "$BENCH_REPO_URL" = "__CODESEMA_BENCH_REPO_URL__" ]; then
echo "[codesema-provision] BENCH_REPO_URL was not replaced, aborting" >&2
exit 1
fi

# --- Node.js 22 LTS (nodesource): packages: above only installs from
# repos that already exist, so the repo-add step lives here.
# --- Node.js 22 LTS (nodesource): the one step install.sh cannot do
# for itself here — chicken-egg, npm does not exist yet to run it.
# install.sh's own node check finds this already satisfied and skips
# it, same as it would on a server that already had node.
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
apt-get install -y nodejs

# --- GitHub CLI, official repo (same reasoning as nodejs above).
install -d -m 0755 /usr/share/keyrings
curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
-o /usr/share/keyrings/githubcli-archive-keyring.gpg
chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \
> /etc/apt/sources.list.d/github-cli.list
apt-get update
apt-get install -y gh

npm install -g codesema@latest @anthropic-ai/claude-code

# Asserted rather than assumed: the systemd unit's ExecStart is a
# hardcoded absolute path, so a silent prefix drift here must fail
# the provisioning run, not the service five seconds after boot.
test -x /usr/bin/codesema || { echo "[codesema-provision] codesema not at /usr/bin/codesema, fix ExecStart" >&2; exit 1; }
# Asserted rather than assumed: install.sh is invoked below by its
# installed path, so a silent prefix drift here must fail the
# provisioning run, not that invocation five seconds later.
test -x /usr/bin/codesema || { echo "[codesema-provision] codesema not at /usr/bin/codesema, fix nodesource's npm prefix" >&2; exit 1; }

# write_files ran before this user existed; own its home now, before
# any command below runs as codesema (gh/git both write under $HOME).
# any command below runs as codesema (gh/git/npm all write under $HOME).
mkdir -p /home/codesema
chown -R codesema:codesema /home/codesema

Expand All @@ -144,55 +111,37 @@ write_files:
# the entire point of running this as a boot-time daemon.
loginctl enable-linger codesema

# `gh auth setup-git` wires env-based auth (GH_TOKEN) into git's own
# HTTPS credential helper, so the clone below never puts the token in
# this repo's .git/config: only ~/.gitconfig gets a `credential.helper`
# line naming gh, which reads GH_TOKEN again at call time.
runuser -u codesema -- bash -c '
set -euo pipefail
set -a; . /etc/codesema/brain.env; set +a
gh auth setup-git
'
runuser -u codesema -- env BENCH_REPO_URL="$BENCH_REPO_URL" bash -c '
set -euo pipefail
set -a; . /etc/codesema/brain.env; set +a
git clone "$BENCH_REPO_URL" /home/codesema/codesema-bench
'
# codesema's user manager must actually be up before install.sh's own
# `systemctl --user`/`loginctl` calls (inside `codesema brain
# install-service`) can reach it — enable-linger above only spawns it
# asynchronously through logind, hence the bounded retry. `-M
# codesema@` reaches the manager through systemd-logind directly
# (systemd >= 248, shipped since Ubuntu 20.10), which is more robust
# here than hand-rolling XDG_RUNTIME_DIR this soon after enable-linger.
for _ in $(seq 1 10); do
systemctl --user -M codesema@ daemon-reload 2>/dev/null && break
sleep 1
done
systemctl --user -M codesema@ daemon-reload

# agent/isolation written FIRST: `codesema brain connect` below loads
# this same file and merges its own three keys into it — it does not
# overwrite what is already there.
install -d -o codesema -g codesema -m 0700 /home/codesema/.config/codesema
cat > /home/codesema/.config/codesema/config.json <<'JSON'
{
"agent": "claude -p",
"isolation": "container"
}
JSON
chown codesema:codesema /home/codesema/.config/codesema/config.json
chmod 0600 /home/codesema/.config/codesema/config.json

runuser -u codesema -- bash -c '
# Once warm, install.sh's own PLAIN `systemctl --user` calls (no -M
# addressing: that is a cloud-init-specific concern install.sh has no
# reason to know about) need XDG_RUNTIME_DIR themselves. runuser's PAM
# session does not reliably set it (a known systemd/util-linux gap:
# https://github.com/systemd/systemd/issues/10574), so it is exported
# explicitly instead of assumed. install.sh reads the rest of its
# configuration (REPO_URL, the brain URL/token, the two runtime
# secrets) straight out of brain.env itself.
runuser -u codesema -- env XDG_RUNTIME_DIR="/run/user/$(id -u codesema)" bash -c '
set -euo pipefail
set -a; . /etc/codesema/brain.env; set +a
codesema brain connect --url "https://codesema.com" --token "$CODESEMA_BRAIN_TOKEN"
"$(npm root -g)/codesema/assets/deploy/install.sh"
'

# Warms the checks image ahead of the first ticket instead of paying
# for the pull on that ticket's critical path.
runuser -u codesema -- podman pull docker.io/library/node:26

# `-M codesema@` reaches codesema's user@<uid> manager through
# systemd-logind directly (systemd >= 248, shipped since Ubuntu
# 20.10): avoids hand-rolling XDG_RUNTIME_DIR, which is fragile this
# soon after enable-linger. The retry covers logind's own startup lag.
for _ in $(seq 1 10); do
systemctl --user -M codesema@ daemon-reload 2>/dev/null && break
sleep 1
done
systemctl --user -M codesema@ daemon-reload
systemctl --user -M codesema@ enable --now codesema-brain.service

echo "[codesema-provision] done at $(date -u +%FT%TZ)"

runcmd:
Expand Down
Loading
Loading