It described four capability kinds and said terminal, chat and files can never be granted. There are five, and those three moved to `confined` on 2026-08-11 — the kernel enforces the boundary because the account has its own Linux user, and a grant means nothing without one. The layout diagram was missing dockers/ and secrets/, and implied the paths are configured. They are derived from the working directory, which is why the pm2 cwd pin and assertInstallLayout exist. Adds what is switched off as of tonight: six core processes, every plugin router commented out beside its capability claim, the ecosystem files now generated, and .env down to three values with the keys in the secret store. First of a documentation sweep. 42 docs; this one first because it is the operational guide somebody actually reaches for. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
235 lines
12 KiB
Markdown
235 lines
12 KiB
Markdown
# Working on Officer
|
|
|
|
The guide for anyone — human or agent — changing this deployment. It assumes you are working from the
|
|
root of the install (the directory holding `platform/`, `capabilities/` and `data/`), which is where
|
|
agent sessions start.
|
|
|
|
Three directories sit there, and knowing which one a change belongs in is most of the job:
|
|
|
|
```
|
|
$OFFICER_ROOT/
|
|
├── platform/ the application — a git repo
|
|
├── capabilities/ what the agent can do — a separate git repo
|
|
├── data/ runtime state — NOT version controlled
|
|
├── dockers/ containers the app store provisioned
|
|
└── secrets/ the key store — 0600, and NOT in your data backup
|
|
```
|
|
|
|
None of those paths is configured. `src/servers/data-path.ts` derives the root as
|
|
`resolve(process.cwd(), '..')` and hangs the rest off it, which is why the pm2 `cwd` pin matters and
|
|
why `assertInstallLayout` refuses to boot from the wrong directory.
|
|
|
|
Officer is a self-hosted platform: an AI agent, a terminal, a file browser, a code editor, email, a
|
|
bitcoin wallet, a remote desktop and dashboards, behind one web app. **It is built around one owner**
|
|
— user id 1, role `Super Admin`, who bypasses every permission check — and since 2026-08-07 also
|
|
admits **additional accounts holding a strict subset of it**, governed by per-role capability grants.
|
|
|
|
So "which user" has three answers depending on the surface. For the **app** capabilities (gitea,
|
|
music, photos, email, calendar…) it is a real question with a real answer. For **confined** ones —
|
|
terminal, chat, files — it is also real, because the account has its own Linux user and the kernel
|
|
enforces the boundary; a grant there means nothing without that user, and `authorize.ts` drops it.
|
|
For **execution** — tasks, items, desktop, browser — it is still always the owner, and those can
|
|
never be granted at any level.
|
|
|
|
That is five kinds, not four: `core`, `app`, `confined`, `execution`, `admin`. Terminal, chat and
|
|
files moved from `execution` to `confined` on 2026-08-11 with per-user Linux accounts.
|
|
|
|
This paragraph said "there is no tenancy, no roles, no other users" until 2026-08-07. Four roles exist
|
|
and five non-owner accounts are live; treat the capability registry as the source of truth over any
|
|
prose, here or elsewhere.
|
|
|
|
## What is switched off (2026-08-13)
|
|
|
|
A core install runs **six** pm2 processes: `officer`, `officer-anthropic-proxy`,
|
|
`officer-claude-code`, `officer-opencode`, `officer-pty`, `officer-headscale`. Everything else is a
|
|
plugin, and every plugin router is commented out in `hono.ts` with its capability's `api` claim
|
|
commented beside it — they must move together or `assertCapabilityTotality` refuses to boot.
|
|
|
|
The implementations are all still on disk. Nothing was deleted; the mounts were switched off pending
|
|
extraction into the plugin system.
|
|
|
|
Also gone: the four ecosystem files (generated now, at setup, and gitignored), origin validation,
|
|
`OFFICER_OS_USERS` (per-user Linux accounts are unconditional), and the Task Logs feature.
|
|
|
|
`.env` holds three values — `PORT`, `PUBLIC_URL`, `POSTGRES_URL`. Every key lives in
|
|
`$OFFICER_ROOT/secrets/officer-keys.db`, one per purpose. See `docs/secret-store.md`.
|
|
|
|
`platform/` and `capabilities/` each have their own `CLAUDE.md` with detail. This file is the layer
|
|
above them: where things live, how to change them safely, and the things that are true of the running
|
|
system but written down nowhere else.
|
|
|
|
---
|
|
|
|
## Which directory does this change belong in?
|
|
|
|
**`capabilities/` — almost always start here.** Tasks, tools, skills, processes. It is *data*: plain
|
|
directories of Markdown and scripts, read fresh on every request. Adding a task, changing what a task
|
|
does, renaming a category — none of that needs a code change or a restart.
|
|
|
|
**`platform/` — only when the mechanism itself is missing.** If a task needs a form control that
|
|
doesn't exist, or an endpoint that isn't there, that's platform work. Adding a *capability* is not.
|
|
|
|
**`data/` — never edit by hand.** `DATA_PATH`. Holds the owner's managed home, per-account email
|
|
SQLite stores, job logs, the queue, sidecar state. It is not backed up by git; deleting things here
|
|
destroys the only copy.
|
|
|
|
A useful test: **would this differ between two Officer installs?** Domain, paths, credentials → `.env`.
|
|
Which tasks exist and what they're called → `capabilities/`. Everything else → `platform/`.
|
|
|
|
## Git
|
|
|
|
Both repos are on **`master`**, both push to `gitea.pastilhas.dev`. There is one branch; per-instance
|
|
differences live in `.env`, never in tracked files.
|
|
|
|
**Commit and push both repos** when a change spans them — a task usually pairs with the platform
|
|
support it needs, and a half-pushed pair leaves the deployment inconsistent.
|
|
|
|
Keep history linear: `git pull --rebase`, not `git merge`. The remote moves — the owner develops on
|
|
this box too — so expect to rebase before pushing. Say so before force-pushing anything.
|
|
|
|
Commit messages: simple lowercase, no prefixes, explaining *why*.
|
|
|
|
---
|
|
|
|
## Running and checking your work
|
|
|
|
The server runs under pm2 as `officer`, plus sidecars (`officer-anthropic-proxy`, `officer-claude-code`,
|
|
`officer-opencode`, `officer-email`, `officer-pty`, `officer-vnc`, `officer-music`, `officer-vault`,
|
|
`officer-slskd`, `officer-headscale`, `officer-transmission`, `officer-invoiceshelf`, `officer-wallet`).
|
|
`pm2 list` shows them; `pm2 logs officer` follows.
|
|
|
|
Two of those names are worth knowing apart: **`officer-anthropic-proxy` holds the Anthropic credential
|
|
and proxies API traffic; `officer-claude-code` is the process that actually runs `claude`.**
|
|
|
|
**Which process to restart.** A change under `src/servers/sidecar/<name>/` needs that sidecar restarted;
|
|
a change anywhere else needs `officer`. Both, if you changed the wire between them. Restarting `officer`
|
|
no longer costs you a running agent session or a terminal — every sidecar is a PM2 peer, and the terminal
|
|
and email sidecars serve their own listeners, so `officer` is not in their data path at all.
|
|
|
|
**Don't restart the owner's server to test.** Boot your own on a spare port instead — the running
|
|
instance holds `PORT` from `.env` (9010):
|
|
|
|
```bash
|
|
cd platform && PORT=59999 bun --env-file=.env src/server.tsx
|
|
```
|
|
|
|
Before claiming anything works:
|
|
|
|
```bash
|
|
cd platform
|
|
bunx tsgo --noEmit # must be 0 errors — the tree is clean, keep it that way
|
|
bun test # must stay green
|
|
bun format # prettier, changed files only
|
|
```
|
|
|
|
### Calling the API by hand
|
|
|
|
Protected routes validate the `Host` header against `PUBLIC_URL`, so a plain localhost request gets
|
|
**403 Invalid origin**. Send the real host explicitly:
|
|
|
|
```bash
|
|
TOKEN=$(bun --env-file=.env -e \
|
|
'import { sign } from "./src/servers/jwt.ts"; console.log(await sign({ id: 1, email: "<owner>" }, "10m"))')
|
|
curl -H "Authorization: Bearer $TOKEN" -H "Host: rezio.pastilhas.dev" http://127.0.0.1:9010/api/tasks
|
|
```
|
|
|
|
This trips people up repeatedly. It is also why script tasks are handed `OFFICER_API_HOST`.
|
|
|
|
### Services this box depends on
|
|
|
|
| port | what | used by |
|
|
|------|------|---------|
|
|
| 9010 | Officer itself | — |
|
|
| 9002 | Kokoro TTS | text-to-speech |
|
|
| 8178 | whisper.cpp | transcription |
|
|
| 1234 | vision model (Qwen2.5-VL) | OCR |
|
|
| 5432 | Postgres | the database |
|
|
|
|
URLs live in Settings → System, stored in the `server_config` table — **not** in `.env`. If
|
|
transcription or OCR fails, check the service is up before reading any code.
|
|
|
|
---
|
|
|
|
## The task system
|
|
|
|
This is what most requests will be about. Tasks appear in the file browser's right-click menu under
|
|
**Run Task**, grouped into submenus by category.
|
|
|
|
A task is a directory under `capabilities/tasks/<slug>/` with a `TASK.md` — frontmatter plus a body —
|
|
and, for script mode, a sibling `run.sh` / `run.py` / `index.ts`. **The directory name is the task's
|
|
identity**; renaming it breaks every reference to it.
|
|
|
|
`capabilities/CLAUDE.md` documents the format. It is accurate but **incomplete** — the following are
|
|
used heavily by real tasks and appear nowhere in it:
|
|
|
|
| convention | what it does |
|
|
|---|---|
|
|
| `category: Video` | which submenu the task appears in. Order comes from `capabilities/categories.yaml`; an unlisted category still works, sorting after the listed ones. A category with no tasks never renders. |
|
|
| `inline: true` | runs ephemerally in the modal instead of becoming a job |
|
|
| `inline: ask` | offers both — **Run here** and **Run as job** |
|
|
| `INPUT_INCLUDE` | newline-separated paths, injected by the modal on a multi-selection. **The single most used input in the library** — a task that ignores it silently processes the whole folder instead of the selection. |
|
|
| `INPUT_GROUP_CONFIG` | per-track-layout config for folder-wide video runs |
|
|
| input types `audio_tracks`, `subtitle_tracks`, `subtitle_edit` | render real track pickers, driven by probing the file |
|
|
| `config.perGroupTracks`, `config.folderKeepAll`, `config.perGroupAllFiles` | opt into the folder track-picker behaviour — on **script** tasks, though the docs describe `config` as pipeline-only |
|
|
|
|
Inputs reach a script twice: positionally in `args` order, and as `INPUT_<NAME>` environment
|
|
variables.
|
|
|
|
### Writing a task: copy an existing one
|
|
|
|
`convert-audio` is the plain template. `transcribe-audio` shows calling Officer's own API.
|
|
`extract-audio` shows handling multiple streams. `optimize-size` shows the `INPUT_INCLUDE` helper.
|
|
|
|
Two ways a task does work:
|
|
|
|
1. **Run a tool directly** (`ffmpeg`, `jq`, …). Right when the work is just a command.
|
|
2. **Call Officer's API**, when the logic already exists server-side — transcription and OCR do this,
|
|
so language detection, model config and output conventions stay in one place. Script tasks are
|
|
given `OFFICER_API_URL`, `OFFICER_API_HOST` and `OFFICER_AUTH_TOKEN` for exactly this.
|
|
|
|
House style for file-processing tasks, worth keeping consistent:
|
|
|
|
- Never delete or modify the source; write output beside it.
|
|
- Handle a single file *and* a directory, recursively.
|
|
- Honour `INPUT_INCLUDE`.
|
|
- No caching. Re-running redoes the work and overwrites — and say so in the body, because it also
|
|
overwrites edits.
|
|
- End with a summary line and a non-zero exit if anything failed.
|
|
|
|
### Debugging "my task didn't run"
|
|
|
|
1. Does `TASK.md` parse? `bun -e` with `getTaskByDirName` from `platform/src/servers/api/tasks/task-files.ts`.
|
|
2. Does the trigger match? Extension matching is on the **lowercased final extension only** — no
|
|
globs, no MIME.
|
|
3. Is the script executable, and does `bash -n run.sh` pass?
|
|
4. Run it directly with `INPUT_*` set, before blaming the platform.
|
|
|
|
---
|
|
|
|
## Things that will bite you
|
|
|
|
- **The vision model hallucinates on textless images.** A blank white PNG came back as "The quick
|
|
brown fox jumps over the lazy dog." OCR output on an image with no text is invented, confidently.
|
|
Never point OCR at a photo library.
|
|
- **Whisper's translate mode only outputs English.** It cannot translate into any other language.
|
|
Transcription is therefore always in the source language; translating would mean a second pass
|
|
through an LLM.
|
|
- **Agents run unsandboxed as the owner, with `--dangerously-skip-permissions`.** Deliberate — it is
|
|
the owner's own machine. Don't add a jail without being asked. The old bwrap sandbox was removed.
|
|
- **Schema changes use `bun db:push`, not migrations.** `drizzle-kit migrate` has never run here;
|
|
there is no `__drizzle_migrations` table. The schema code is the source of truth.
|
|
- **`PUBLIC_BUILD_ENV` fails closed.** Unset means hardened — origin checks, rate limits and password
|
|
rules all on. Only an explicit `dev`/`development` relaxes them.
|
|
- **`index.html` is a template.** It carries `__PUBLIC_URL__` placeholders; `scripts/gen-index.ts`
|
|
writes the real `index.gen.html` at predev/prestart. Edit the template, then re-run
|
|
`bun gen:index` — `--watch` will not do it for you.
|
|
|
|
## Verify, don't assume
|
|
|
|
Much of what is written down about this codebase has drifted from what it does. Several bugs found
|
|
recently were invisible in the code and obvious the moment something was actually run — a type
|
|
assertion that lied about a third-party response, a language detector whose result was computed and
|
|
then discarded, a menu entry pointing at a deleted screen.
|
|
|
|
Read the code, then run it. When reporting, say what you actually checked and what you didn't.
|