d273dc6a14d1d64fe673bb1df36936b22f993f7f
2098
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
d273dc6a14 |
install a plugin from a git URL, with no registry involved
the catalogue was the only way a remote plugin could arrive, so "build one and give it to a friend" had no route. the first real case is a plugin that must never be listed: fofos/fofos-admin is one website's admin panel, useful to exactly one person. MUCH smaller than the todo assumed. fetchPlugin already cloned, validated the URL, refused to overwrite an existing directory, checked for a manifest and removed a failed clone. exactly one line tied it to the marketplace — where the string came from. so this is a source parameter threaded through, and a route. THE NAME COMES FROM THE REPOSITORY, because appName is the DIRECTORY name and is never declared in a manifest — the id cannot disagree with where the code sits. that decision is inherited here rather than remade: the repo name becomes the directory, the route segment, the table prefix and the sidecar suffix. so it is checked against ^[a-z0-9]+(-[a-z0-9]+)*$ BEFORE anything is cloned; a half-clone under an unusable name reads as a broken plugin rather than an absent one. the URL is validated identically whether it came from the catalogue or from a person, because the catalogue is not a trust boundary — it is just where a string came from, and the same string reaches the same argv. what differs is who may ask: this route is owner-only, and the owner is choosing what code runs rather than choosing from a list somebody curated. validation is not review, and nothing here reads what it cloned. caught by testing: the source reached installPlugin and stopped there — the fetchPlugin call still had the old two arguments, so a URL install fell back to the catalogue and failed with "no catalogue entry". the guard tests are what surfaced it, because the error named the wrong cause. verified end to end against fofos-admin — cloned, schema applied, mounted, dock pinned — then purged, since officer-dev is not that website's machine. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
4e0a065f0e |
a host can drop a core sidecar it does not use
`OFFICER_DISABLED_SIDECARS` in .env, comma separated, filtered out of the generated ecosystem file. core processes are a fixed array because the platform's own features depend on them. that is still true — what is not true is that every host runs every feature. fofos is a 1.9 GB machine serving a website, and officer-opencode sat on it costing 456 MB for a harness nobody there uses. it froze hard twice today, and the tell is what is MISSING from the journal: no OOM kill, no panic, no shutdown — the log simply stops mid-session and resumes at the manual reboot. that is memory exhaustion with no headroom left to kill anything cleanly, on a box with 1.9 GB and a 2 GB swapfile that was already 440 MB into swap one minute after boot. .env rather than a database setting, and that is the load-bearing part: this file is regenerated during a plugin install, so a host that dropped a process needs it to stay dropped across one. per-host is also exactly the scope of the decision. not validated. naming `officer` or `officer-pty` will break that host, and which processes are safe to drop depends on what the host is for — which this script cannot know and should not guess. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
f4685eeafe |
one plugins directory, because the second one was symlinks
`officer-dev/plugins/` is gone. it was created to hold every repo in the org side by side, and by today it held 22 symlinks into platform/plugins, one duplicate clone of a plugin that is installed here, and a README describing a state that had already been fixed. it could not have been more than that. a plugin cannot live outside the platform: bun resolves a symlink to its real path, so from there `@@/sidecar/create-proxy` and every relative import fail. tested both, plus `--preserve-symlinks`, which bun 1.3.14 accepts and ignores. WHICH MATTERS BEYOND TIDYING, because marketplace-sync leaned on the distinction. its comment said the second directory was "where a release is actually made", so publishing from it would advertise released versions rather than installed ones. that was never true — symlinks meant both paths read the same working copy. the bug it describes (21 plugins tagged, the store still showing 1.0.0) was never addressed by the thing built to address it. the honest fix is in the comment now: each plugin directory IS a git repo with its own tags, so the released manifest can be read out of the tag — `git show <tag>:manifest.ts` — without a second checkout. not written yet, and said plainly rather than left implied. finances is deleted, at the owner's request: not installed, no pm2 entry, no ecosystem entry, no plugin_installs row, no tables. both clones were clean and pushed. the gitea repo is untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
87d1740caf |
deploy/2026.08.28.01 — seven hosts, one process each, and a probe that had to wait
First release under the new watcher loop. 7 of 7 target hosts to 9c8af9d4; officer-dev skipped as the host running the rollout. No db:push and no plugins:update, per the plan — so officer was the only process that moved anywhere. Route probe returned 401 on all seven; three answered 000 first because pm2 returns before the server binds. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
9c8af9d424 |
deploy/2026.08.28.01 — sign in as a user
two commits, one feature, and a plan that leaves things out for stated reasons. NO SCHEMA, NO DEPENDENCY, NO PLUGIN CHANGE — checked with git rather than asserted, because asserting it is exactly what sank 2026.08.27.01. db:push is deliberately not a step. yesterday's report established that it prints "Changes applied" when it applies nothing, so its output is not evidence, and on this fleet it re-applies a default on command_hq_sessions.reference_paths every time plus churns pk_music_now_playing on two hosts. running it here would be noise against a database this release does not touch. so the rollout is a merge and one restart. the verification step is an unauthenticated POST to the new route: 401 means mounted, 404 means the bundle did not move — a check that works without a session, on an owner-only feature. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043deploy/2026.08.28.01 |
||
|
|
fb0cc880ac |
sign in as a user, from user management
the support case this exists for is "it does not work for them and it does for me". reproducing that with a password reset means locking somebody out of their own account in order to look at it, which is worse than the bug. THE TOKEN SAYS WHO IS REALLY HOLDING IT. `impersonatedBy` rides on the claim and reaches every handler, because user-middleware puts the whole payload on the context. without it an impersonated action is indistinguishable from that person's own — in a log, in a transcript, in anything keyed on a user id — and a support tool that can forge activity as somebody else is a different and much worse thing than a support tool. ONE HOUR, not thirty days. a session lasts a month because a person uses their own account daily; impersonation is a task you finish. an hour is long enough to reproduce a problem and short enough that a forgotten tab stops being a live credential for somebody else's account by lunchtime. IT CANNOT CHAIN, and that is the guard that matters. `ownerGate` refuses any token carrying `impersonatedBy`, so impersonating another Super Admin does NOT hand user management back — which would have made this a way to launder owner actions through somebody else's id. stated twice, in the gate and in the handler, because one file away is too far for this one. THE WAY BACK IS A STASHED TOKEN, not a route. nothing on the server mints an owner session — a far more dangerous thing to own than this whole feature. the cost is that clearing browser storage strands you as the other person until the hour expires, and signing in again fixes that. the banner is loud and sits ABOVE the header, outside everything a fullscreen panel can hide. the failure it prevents is not a hole, it is forgetting: reading a mailbox or deleting something while believing it is yours. it asks the server who it is rather than decoding the token, so it cannot keep showing a name after the hour has run out. verified end to end against a throwaway account, since this host has one user: impersonate 200 with the target's id and impersonatedBy naming the owner, 60 minute lifetime, /auth/me answering as them; self 400, unknown user 404, chaining 403, user management while impersonating 403; one [SECURITY] line per start. the probe account was deleted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
02ad4d3bdf |
deploy/2026.08.27.02 — seven hosts, and a push that lies about applying changes
Every target host is at the tag. The one destructive step ran on six of them and was guarded by a count each time; alpha kept its table and its 8 rows. The finding is that `bun db:push` prints "Changes applied" unconditionally — it said so on an unchanged database, twice in a row. `--verbose` shows the truth: what it re-applies everywhere is pre-existing composite-key drift, and nothing touches dav_app_passwords on any host. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
91147b403b |
deploy/2026.08.27.02 — a plan with commands, and a schema claim that is checked
2026.08.27.01 was REFUSED before a single host was contacted, and the commander was right twice over. its plan described the release instead of instructing the rollout — no step list at all, where deploy/README.md defines a plan as "what to run, in order, on which hosts" and steps 4 and 5 of the procedure are plan-driven. and it asserted "nothing here changes a schema" on the day dav/schema.ts was deleted out of core. that second one was not a formatting mistake. dav_app_passwords now lives in plugins/caldav, and gen-plugin-schemas follows plugin DIRECTORIES — so on a host without that directory the table is in the database and not in the schema, and `db:push` proposes dropping it. on alpha that is eight device credentials. so this plan answers it from a survey rather than a guess. alpha has the plugin and 8 rows; the other six have no plugin and an empty leftover table; zero hosts run officer-marketplace, which retires the `tags` column question entirely. the orphan table is dropped EXPLICITLY, guarded by a count, and skipped on alpha — never as a db:push side effect. step 6 is new and would have been missed: officer-opencode has to restart or none of the skills, agent-identity or credential work in this release does anything. ALSO THE SIGN-OUT. a single 401 from any endpoint wiped the token from both storages and reloaded — which signs the owner out of every tab and keeps them there, since nothing is left to recover from. that is the right response to a revoked session and far too violent a response to one endpoint answering 401 for its own reasons. it now confirms against /auth/me first, and treats an unreachable server as emphatically not a revocation: rebooting the box must not end the session. reported mid-demo, with nothing in the logs and a freshly signed token working against every route the app loads. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043deploy/2026.08.27.02 |
||
|
|
ec6c793143 |
deploy/2026.08.27.01 — the plan, and the version to match it
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>deploy/2026.08.27.01 |
||
|
|
421e742590 |
a dock tile for /bots, which had a screen and no way in
the route, the screen and the list have existed since bots did — `/bots` and `/bots/:sessionId` are both in App.tsx. what was missing was any way to GET there: no entry in CORE_DOCK_ITEMS, so it could not even be added to a dock, and no `nav.bots` label in any of the six locales. placed beside Command HQ, which is the neighbouring idea: a commander runs a job, a bot watches one thing. left as "Bots" in all six locales — a borrowed technical noun, and inventing translations for it would be worse than not. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
6e805c3607 |
the new wordmark in the header, following the theme
two marks, same 1062x220 crop: white "Off" for a dark background, dark "Off" for a light one, yellow "icer" untouched in both. the dark variant is the light one with only its DESATURATED pixels inverted, so the yellow is bit-identical and the alpha and dimensions never move. CLASSIFIED BY LUMINANCE, not by the mode comment. only 22 of the 227 theme files declare one, and the cost of guessing wrong is a white wordmark on a white bar — so each theme's own `--background` is read and thresholded. 203 dark, 24 light. the signed-out screen is deliberately untouched and still uses the inline SVG. AND THE BUG THIS UNCOVERED, which was not about logos at all. `useThemeAsset` read the computed style inside an effect, and react runs CHILD effects before parent ones — every consumer is a descendant of `ThemeProvider`, which sets `data-theme` in ITS effect. so the read always saw the PREVIOUS theme's value: switch themes and the logo was one theme behind, switch again and behind again. it reads as a race and is deterministic, which is why it reproduced every time somebody watched for it. the read moved into requestAnimationFrame — past every effect in that commit and past style recalculation — plus a MutationObserver on data-theme/class for the case a frame cannot cover: a theme installed at runtime arrives as a <link> that loads whenever it loads. the wallpaper uses the same hook for `--page-bg-src` and had the same lag. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
bf8bfa6859 |
start a commander from a plugin chat, without knowing where anything lives
pointing a commander at a mailbox meant typing its path, which meant a file
browser and an account allowed to walk the disk. only the owner has that — so
"watch my email and tell me when X" was a super-user feature, in a product whose
whole point is that it should not be.
every plugin chat already mounts `ChatHeader` with a `chatContext`, and that
context IS the address: the server turns `{context:'email', contextId:'me@…'}`
into the mailbox's own directory, beside its `emails.db`. so one button on that
header reaches every plugin chat there will ever be, and a new plugin gets it by
declaring the context it already had to declare to have a chat at all. that is
what makes this common to all those chats rather than copied into each.
THE CLIENT SENDS NO PATH, because it has none. a plugin's chats live under
$DATA_PATH/<email>/plugins/<app>/chats and an email chat beside its own database;
both are built server-side and deliberately never sent to the browser. the
resolver moved out of chat.ts into `chat-context.ts` so command-hq uses the SAME
mapping — two copies would be two chances for a commander to watch a directory
its own chat has never been in.
it NAVIGATES rather than creating on the spot. a commander with no first turn has
no transcript, and the grid filters those rows out — so "create it silently"
makes something the person cannot then find. the setup screen is also where the
first turn happens, which is where the job actually gets settled. the button
carries the context there, pre-fills the name, and leaves one button to press.
the reference is shown as what it WATCHES rather than as a path, because there is
no path to show.
caught by testing rather than by reading: `resolveChatContextDir` reads the query
string, and command-hq takes its context in a JSON body — so the first version
resolved every commander to the owner's home. that failure is invisible, since a
commander watching the wrong directory just answers about the wrong thing
forever. the value-taking `chatContextDir` is what command-hq calls.
verified live: context 'email' resolves to email_accounts, plugin/proxy-manager
to its own chats directory.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
|
||
|
|
9ec60d6d75 | caldav 2026.08.27.03 — filters on calendar and contacts | ||
|
|
15392ae6c2 |
preview a commander's files, the way /files does
the commander file panel could list a directory and not open anything in it. it already mounts the same `FileBrowserApp` as /files — what it did not mount is the VIEWER, which on /files is a separate ephemeral panel and not part of the browser at all. THE PARAM COULD NOT BE READ, which is why this is more than mounting a panel. on /files the folder is in the PATHNAME, so `?view=notes.md` is a name inside it and that is all it needs to be. a browser mounted anywhere else keeps its folder in panel-local state, so the same name is unresolvable by anything except the component that wrote it — the commander panel was writing `?view=notes.md` with no reader able to tell which of its folders that meant. so off the files route the param carries the whole path, and `viewRoot` with it: a panel that overrides the root is exactly the case where "home" is the wrong guess. `viewRoot` joins VIEWER_PARAMS so closing an overlay clears it — a stale root outliving its view would send the next file to the wrong tree. the split is VERTICAL where /files is horizontal. this panel is already the narrow half of a screen whose other half is a transcript; splitting it sideways again leaves two columns too thin to read anything in. closing the file clears the overlay params by NAME rather than resetting the query, because `files=1` — the thing keeping this whole panel open — lives in the same string, and the obvious version closes the browser along with the file. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
02f20d419d |
an opencode chat you can reopen: it has a name, its messages, and its model
three complaints, one root cause and two small ones. THE TRANSCRIPT CAME BACK EMPTY because the client read `/session/<id>/message` and this build answers that with `[]` — 200, well-formed, and wrong. the same session returns 4 messages on `/api/session/<id>/message`. an empty transcript is indistinguishable from a new conversation, so reopening a chat said "send a message to start" and every message the owner had sent looked deleted. that also explains the third complaint. the model was read from the assistant turns, so no messages meant no model, so the detail reported a bare `opencode`, which the composer cannot match — and it fell back to claude code and sonnet. it now also reads the model off the session record, which is populated before any assistant turn lands. the message shape moved with the endpoint: `type` where it was `info.role`, `content` where it was `parts`, and a user message's text sits on the message rather than in a part. both shapes are accepted rather than pinned to today's build — this reader is the only thing between a working transcript and a chat that looks erased, and a host on a different opencode should lose features, never its history. AND THE ORDER WAS BACKWARDS. the endpoint returns newest first and nothing downstream re-sorted, so the answer sat above the question that produced it. sorted here, because this is the only place that still has timestamps. NAMING: opencode is meant to title a session from its contents and does not, on this surface — sessions stay at `New session - <timestamp>` through a finished turn and past it, which is why the list was a column of "(untitled)". `isPlaceholderOpenCodeTitle` exists precisely because that name leaks into the UI. so the sidecar names a NEW session from the first line of its first prompt, which is what the claude side shows. a resumed session keeps what it has, and if opencode ever does title one it writes after us and wins. verified live on the session that prompted this: 4 messages, in order, model opencode/kimi-k3. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
3b1d4cea84 |
permissions off wholesale, by every documented mechanism, plus a backstop
read the documentation instead of the binary, which is what should have happened
hours ago. it says three things this code was getting wrong.
- "permission": "allow" — a BARE STRING, not the object form. that is the
documented way to say "no prompting at all".
- most permissions already default to allow. the exceptions are
`external_directory` and `doom_loop`, plus `read` on `.env`. those are what
was actually stopping turns.
- an agent's own rules take precedence over the global config, and an agent
that declares none inherits the built-in defaults — so a global allow does
not reach it.
so: `permission: "allow"` in the serve config, AND the agent moved to the
markdown FILE form with `permission: allow` in its frontmatter. the file form is
what the docs recommend "for anything non-trivial", and it is the only place an
agent's permissions are honoured — the inline definition ignores both `permission`
and `prompt`, while accepting `description` and `mode`, which is the worst kind of
partial.
WHAT I COULD NOT MAKE TRUE. with all of that applied, the serve still reports
`external_directory: ask`, `doom_loop: ask` and `read *.env: ask` on the resolved
agent. i cannot get that list to come back clean, and i am not going to guess at
it again.
which is why the event-stream approver stays and is the thing actually doing the
work: whatever opencode asks, the platform answers `always`. that is the analogue
of the claude harness's `bypassPermissions` and it does not depend on predicting
the shape of the next gate. the config above is the belt; that is the braces.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
|
||
|
|
3082949fac |
answer opencode's permission prompts, because nothing else can
the spinner had a cause and it was none of the four things fixed before it. the
serve was holding a permission request:
{"action":"skill","resources":["calendar-and-contacts"],"source":{"type":"tool"}}
opencode asks before loading a skill. in its own terminal a person answers. here
it goes to a UI that has never heard of it, and the turn does not fail — it SITS
THERE. no error, no log line, nothing in the transcript. three models were blamed
for it, and a paid provider, and the skill itself.
replying `always` by hand released it instantly: the skill loaded and the agent
went straight to the curl the skill describes. so the whole chain was correct and
one unanswered question was holding all of it.
CONFIG IS NOT ENOUGH, and this is the part worth keeping. the serve's config
carries `permission: {skill: {"*": "allow"}}`, the agent's own ruleset reports
`skill * allow`, and it asked anyway. answering is the only approach that does not
depend on guessing the shape of the next gate correctly — and there will be a next
gate, because `external_directory` was the last one.
not a widening: the claude harness runs `bypassPermissions` deliberately, because
CLAUDE.md is explicit that an unsandboxed agent on the owner's own machine is the
point. this is the same stance for the other harness. `always` rather than `once`
so a repeated tool does not re-ask every turn.
when opencode reaches members this becomes a real decision. it is owner-only today
for larger and unrelated reasons, and it goes on the list with them.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
|
||
|
|
c226e8a46d |
copy the skills, do not link them — a link is what made it hang
the agent called `skill calendar-and-contacts` and sat there. not an error, not a refusal: a spinner, on three different models. opencode grants `external_directory` for the paths it discovered — literally `~/.config/opencode/skills/<name>/*` — and a symlink RESOLVES to the plugin directory, which is not in that list. so reading the skill body fell through to `external_directory: "*": "ask"`, and a server session has nobody to ask. the request waits forever. the permission list the serve reports says it plainly: every skill path is allowed at its LINK location and nothing allows the target. copying makes the real path the allowed path. the cost is drift — a skill edited in its plugin is not live until this sidecar restarts — which is why the copy is rewritten from scratch on every boot, and it is a good trade against a hang with no message. copies are marked so a skill somebody wrote there by hand is never clobbered and ours are replaced rather than merged, so a file deleted upstream cannot survive in the copy. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
f0801808c4 |
read the documentation: four things wrong, none of them the model
an opencode agent could not look at a calendar all afternoon. i treated it as a
framing problem, then a regression, then a free-vs-paid model problem, and it was
none of those. the documentation says all four, plainly.
1. A SKILL'S `name` IS AN IDENTIFIER, not a label. it must match
^[a-z0-9]+(-[a-z0-9]+)*$ and equal its directory. every skill here carried a
human title there — "Calendar and contacts" — so every one was LISTED and none
could be LOADED. the agent found the skill, called
skill({name:"calendar-and-contacts"}), and got "Unable to load skill". `title`
is now a separate field and keeps the prose.
2. THERE IS NO `skills.paths` CONFIG KEY. it is in the binary's own config
example and not in the docs, and the difference is exactly this: skills
configured that way are offered to the agent and cannot be opened. skills load
from fixed discovery paths only, so each directory is now SYMLINKED into
~/.config/opencode/skills. links, not copies — one source of truth, and a skill
edited in its plugin is live immediately.
3. `prompt` IS A FILE REFERENCE — "{file:./path}". passing the text is parsed
without complaint and never applied. this is what made it look like a model
problem: the agent existed, the prompt was on its record, and the model had
never seen a word of it.
4. THE `skill` PERMISSION DEFAULTS TO ASKING, and a server session has nobody to
ask, so the call errors rather than waiting. {"skill":{"*":"allow"}}.
also: the platform cached opencode's model list for the lifetime of `officer`,
so signing into a provider and restarting the SIDECAR left the old catalogue in
the OTHER process — six free models, no paid ones, nothing saying why. the cache
is dropped when the sidecar reports a new port, which is the one event that
always accompanies a new serve.
verified: the prompt reaches the model — asked who it is, it answered "Officer,
running on your home server at officer-dev under your user account". 10 skills
discovered through the linked path with loadable names. NOT verified end to end:
a turn that opens the calendar skill and answers from it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
|
||
|
|
a873690e27 |
opencode gets an identity, not just a coding agent's
three turns in a row could not look at a calendar while holding the calendar skill, and each failure had a different cause. FIRST: opencode's built-in `build` agent opens with "You are an AI coding agent. Help the user accomplish software engineering tasks." asked about a calendar it reasoned, in its own words, "this isn't a coding task, and I don't have access to any calendar" and never called the skill tool. the index was in its context the whole time. the plumbing was right and the identity was wrong, which is the harder of the two to see. so officer defines its own primary agent — mode primary, `default_agent`, and named explicitly on session create because a default is a preference and this is a requirement. only NEW sessions: a resumed one keeps the agent it was made with. SECOND: setting a custom `prompt` traded one problem for the other. with the built-in agent the index was there and the framing was wrong; with ours the framing was right and the index was gone, so the turn went looking for .ics files in the home directory. `prompt` replaces the assembled system text rather than adding to it. the index is now compiled INTO the prompt by `skillsBlock()` — the same function a claude session uses — so both halves arrive together. ours also carries the PATH to each SKILL.md, which opencode's own block does not: a path can be opened with the file tools every harness already has. WHAT IS NOT FIXED, AND IS NOT THIS FILE'S FAULT. a third attempt still failed, and a diagnostic turn reported system instructions beginning "Tool infrastructure You can call" — not ours — and named a skill (`frontend-design`) that exists nowhere in this install. the only provider this machine can reach is opencode's own hosted gateway on free models; there is no auth.json here. so either the gateway supplies its own system prompt, or a free model is confabulating about its instructions. those are different problems and neither can be told apart without a model whose credential we own. verified: the agent is registered and its prompt is 4,717 characters containing "Calendar and contacts" and the SKILL.md paths; the serve's credential works (a tool process fetched the owner's collections); 10 skills discovered. NOT verified: that any of it reaches the model on this box. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
e867fe72af |
notify one named account — and unshadow /broadcast, which never worked
"can you send a notification to George" had no answer. an agent key reaches its
own holder and nothing else, and the only route that spoke about somebody else
sent to EVERYONE. so the choice was the whole household or nobody.
POST /api/notify/user, ownerOnly, same authority and same reasoning as
/broadcast: everything else under this permission acts on the account asking, and
reaching another person's phone is administration however few phones it is. it
returns the target's email so the caller can see who was reached rather than
trusting an id they typed, and 404s an unknown account rather than letting the
sidecar's silence read as delivery.
AND THE PART THAT MATTERS MORE. /broadcast has answered 404 since the day it
landed. `createSidecarProxy` mounts `router.all('/*')` when it is CREATED, and
hono matches in registration order — so every route added to the proxy's own
router afterwards was forwarded to the sidecar, which has never heard of it.
three days, on a route whose entire purpose was to stop people POSTing to the
sidecar's ephemeral port by hand. a shadowed route does not fail like a missing
one; it fails like a sidecar that has not implemented it, so the 404 reads as
somebody else's problem.
caldav's router learned this on 2026-08-21 and wrote it down. this file now does
the same thing in the same words: the platform's own routes first, the catch-all
proxy mounted LAST.
verified: /broadcast now answers 400 to an empty body where it answered 404
before, /user delivers and reports the address, /_officer/devices still forwards.
found while trying to reach around the notify sidecar's producer secret to do
this by hand — which is exactly what that secret was added to stop, and the
comment above it says so. it stopped me. the route is the way.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
|
||
|
|
e6da5d8c77 |
opencode gets the skills and a credential, which it had neither of
an opencode agent could not have used the calendar skill written this morning, and would not have failed usefully either — it had no idea the skill existed and no key to call the platform with. two gaps, one file each. SKILLS: OPENCODE ALREADY HAD THE MECHANISM. it has a `skill` tool, an index compiled into its system prompt, and `skills.paths` in config taking directories of <name>/SKILL.md — the same progressive disclosure claude does over the same layout the platform already stores. so this points it at the SAME FILES rather than compiling a second index in a second format. skills/load.ts says a skill is a path because every harness can read a file; this is the better version of that bet — the harness had the feature all along. verified: `/skill?directory=…` now returns all nine platform skills, "Calendar and contacts" among them. THE CREDENTIAL, AND THE THING TO KNOW ABOUT IT. claude spawns a subprocess per session, so its key is per session and a member's resolves to their own grants. opencode cannot: ONE serve runs every session, so the only environment there is is the serve's own, minted once at sidecar start, belonging to the owner. that is sound today for exactly one reason — opencode is owner-only, enforced at every door in chat.ts because one serve runs as one user. when that lifts, this key becomes a privilege escalation. it is the same blocker as the two already there, not a new one, but it lives in a sidecar where nobody looking at chat.ts would find it, so chat.ts now names it. all three move together. verified end to end: a tool process inside an opencode session ran curl with $OFFICER_AGENT_KEY and got the owner's calendars back. OPENCODE_CONFIG_CONTENT rather than editing ~/.config/opencode/opencode.jsonc — it is inline json, it MERGES into the same chain as the config files (checked in the binary), and it dies with the process. the alternative is the platform editing a dotfile it does not own and leaving state behind that outlives the install. affordances ride the `instructions` key as one composed file, written beside the serve's state. loaded — `/config` shows it — but NOT model-verified: this box has no provider credential in auth.json, so a turn 401s before reaching a model. whoever adds one should check that first. the credential module moved out of sidecar/claude/. it was never claude-shaped; it was just there first, and it had one importer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
0918dd7443 |
todo: what the calendar API deliberately cannot write
recurrence, alarms, attendees and EXDATE. all four read correctly and none can be authored — which is what the agent skill still drops to raw DAV for. filed with an order to do them in (EXDATE first: "cancel next Tuesday" is a thing the owner will say and the honest answer today is a refusal) and the reason the API stops where it does. new theme, because burying a calendar gap under plugin-repos is where it goes to be lost. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
3fac1460cd | close the request-body todo: the proxy claims only what it forwards | ||
|
|
f00abe816e |
a sidecar proxy claims only the sub-path it forwards
registerProxiedPrefix tells bodyParser to leave a prefix's request bodies alone. that is correct for forwarded traffic and load-bearing for uploads: a parsed multipart body is re-serialised with a new boundary while the original content-type header is still forwarded, and every upload fails. but a plugin mounts its OWN routes under the same prefix, and those are not forwarded — they are served in-process and they need their bodies. claiming the whole prefix meant every field on them read as missing. caldav hit it the day it was extracted: POST /api/caldav/provision/ios answered "deviceLabel is required" for a request that carried one. its app passwords used to live at /api/dav, a second prefix that was not proxied, and one prefix per plugin ended that arrangement. so `proxiedSubPath` — caldav's sidecar answers `_officer/*` and nothing else, so it claims `/api/caldav/_officer` and the plugin's own routes are parsed normally. omitted means the whole prefix, which is right for every sidecar with no routes of its own, so nothing else changes. this affects any plugin mixing its own routes with a proxy, which is most of them. they have not hit it because their own routes are GETs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
6762a32ea6 |
todo: turn a chat into a commander or a bot, carrying its context
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
68f3643df7 |
a pull leaves origin/master where it found it, so every checkout looked ahead
plugins:check reported "2 commit(s) NOT PUSHED" for all twenty-one installed plugins on a machine where nothing had been committed. the check was right about what it measured and the cause was here. `git fetch origin HEAD` writes FETCH_HEAD and nothing else. refs/remotes/origin/ <branch> keeps whatever it had, so after a successful pull the checkout sits at the newest commit while origin/master still points at an older one — and every `rev-list origin/master...HEAD` reports the checkout as ahead by however many commits it just pulled. so the remote-tracking ref is refreshed too, explicitly, after the fetch that cannot do it. best effort, because the local branch may not exist on the remote under that name — which is the same reason the fetch asks for HEAD rather than for a branch. fetch also asks for --tags now. tags are how releaseState decides whether there is anything to update to, so a fetch that skipped them left the Update button answering from refs older than the release it was offering. verified by staling a ref on purpose: one false positive before, none after a single pullPlugin. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
0d5c2c0083 |
servers.md: the eighth machine, and the port that is not 9000
two facts that each cost time today and neither was written down. macbruno was missing entirely — 100.64.0.22, login brunoalcadapadezmonteirodaconceicao, ~/officerdev/platform, pm2 under Homebrew. nothing on the fleet knew it existed. and THE PORT IS NOT ALWAYS 9000. macbookpro and production-pertento-bridge run on 9010. a health check against 9000 on production hit one of Pertento's own apps, which answered 403, and that was read as officer failing after a deploy. while there: production runs eight processes that are NOT officer — Dashboard, Tracking, Uptime and five Crons. they are production-critical and belong to Pertento. anything sweeping pm2 list must filter to officer and officer-*. added a section on pm2 not being on the PATH of a non-interactive shell, because that read as "pm2 is not installed" three times in one day and twice sent someone diagnosing a healthy machine. zsh reads ~/.zshenv for non-interactive shells and neither .zshrc nor .zprofile, so an nvm or Homebrew install is invisible to an orchestrator. fixed at the source on the two hosts it bit; the section says how to check rather than assume. every row verified by reading the machine rather than recalling it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
b4cf1c6427 |
publish the catalogue from the release clones, and make the cards look like cards
three small things. marketplace-sync reads PLUGINS_DIR now instead of assuming the directory it sits next to. platform/plugins/ is what a machine has INSTALLED — updated by plugins:update, which is a git pull — while the release is made in the workspace clones. publishing from the install directory means the catalogue advertises the version this machine happens to be running: 21 plugins were tagged and the store went on showing 1.0.0 until this changed. the grid cards used bg-white/40. this app's surfaces are a DARKENING of the panel behind them — every other card and section here is bg-duck-dark/5 — so a translucent white read as a pale box floating on a dark theme. picking a colour instead of a token, and it only looked right against a background this app does not have. hover now moves the BORDER and nothing else: a background swap across twenty-one cards makes the whole grid twitch as the pointer crosses it. the tag pills were the same value as the card surface, so they rendered as text with no chip at all. and the marketplace todo said the UI was not started. it is. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
8705dc3a7c |
todo: a server registry for terminal ssh and agent awareness
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
c14b686782 |
pm2 save when the process list changes, and a check that does not lie about symlinks
the owner asked whether anything runs `pm2 save` when a sidecar is added or removed. nothing did. PM2 resurrects from ~/.pm2/dump.pm2, written only by `pm2 save`, so the dump was whatever a human last happened to save. today it was FIVE DAYS old: 13 processes saved against 26 running. a reboot would have silently lost every sidecar installed since — bitcoin-wallet, desktop, gitea, invoices, iris, jellyfin, minesweeper, music, photos, soulseek, transcribe, transmission, vault. thirteen features gone, machine healthy, nothing saying why. saved after start AND after delete. removal matters as much: a dump that keeps resurrecting a deleted sidecar brings it back after a reboot as a crash loop for code no longer on the machine. a RESTART does not save — the same processes are running and the dump is already right. best effort and never fatal: a failed save leaves the estate correct and only the dump stale, which is where things already were. failing an install over bookkeeping would be the worse trade. separately, plugins:check filtered directories on the DIRENT's isDirectory(), which answers false for a symlink to a directory. platform/plugins is symlinked now, so the check would have reported "0 plugin repositories, all well-formed" — a green check over nothing, which is worse than a red one. it stats the path instead, the way discover.ts already does. dump saved once by hand as well, since five days of drift was already there: 26 saved, 26 running, no difference. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
68d6dd942e |
update means something now: it depends on tags
"update" was `git pull` and re-install, unconditionally, for every plugin.
nothing could say whether there was anything to pull, so the button was always
there and usually did nothing — and "update all" was twenty-one no-ops with a
spinner on it. the button's own comment admitted it: gating on `outdated` would
hide it for ever, because nothing bumped a version.
versions are dated and tagged now, so the rule the owner asked for is possible:
· a checkout NOT at a tag is always updateable — it is between releases, no
claim is being made about it, pulling is always reasonable
· a checkout AT a tag is updateable only when a NEWER tag exists
the part worth defending is how "newer" is decided. the obvious version-string
comparison is wrong the first time a plugin is tagged `v2` or `1.0.0-rc1`, and
wrong SILENTLY — a plugin that never offers an update looks like one nobody has
released. so newness is asked of git: the newest tag reachable from the REMOTE
branch, compared against the tag at HEAD. that holds for any names anyone
chooses, and there is a test with a tag called `beta` that sorts before `v1` to
keep it honest.
it never fetches. every answer comes from refs already on disk, because this
runs once per plugin on a request that renders a list and twenty-one network
round trips would make the screen feel broken. tags arrive at clone and at the
fetch `pullPlugin` already does.
the VERB refuses too, not just the UI. a plugins list is fetched once and a tag
can be pushed a second later, so the copy someone clicks is routinely older than
the truth — and `updateAll` iterates a list rather than asking per plugin.
"up to date · <tag>" is said out loud rather than shown as an absent button. no
button and up-to-date look identical, and only one of them is information.
verified on the real installs: example pulled to its tag and flipped to
up-to-date, caldav on an older commit stayed updateable, and updatePlugin on
example answered "already at 2026.08.27.01, the newest release" without pulling.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
|
||
|
|
d58f58813c |
the marketplace is browsed by tag, not scrolled
/marketplace was a list of every plugin on the left and a preview on the right. that is a fine control panel and a poor shop: twenty-one rows sorted by nothing anybody cares about, where "something for my calendar" means reading all of them. now: tags down the left with counts, cards in a grid on the right, and clicking a card takes over the panel with a back link. three things about the shape. BOTH the filter and the selection are in the URL — ?tag= and ?selected=. a filter in component state is lost on Back, so filtering, opening a plugin and pressing Back would drop you in an unfiltered grid having lost your place. browser Back works for free for the same reason. selecting a tag also CLEARS the selection, because a detail for a plugin outside the new filter is a page the grid you are now looking at cannot return to. the detail REPLACES the grid rather than sitting beside it. a grid needs the width and a detail beside one gets half of it; neither reads well. the back link drops ?selected= and keeps ?tag=, which is the entire point of putting the filter there. the panel KEYS are unchanged though both components were replaced. a stored layout names them, and renaming would drop everyone back to the default arrangement for nothing — the key identifies a slot, not an implementation. two things found on the way. PluginItem and AvailablePlugin had no tags, so the api now carries them on both branches — from the manifest for a plugin that is here, from the catalogue for one that is not, same field either way. and the screen used useDashboardState, which persists the whole tree: the panels this ships would never have reached anyone who had opened it before. useAuthoredLayout takes the structure from source and persists only sizes, which is what an authored screen wants. 21/22 -> 22/78 as well: the left panel holds seven tags now, not twenty-one rows, and the width is worth more to the grid. verified: bundle contains the new panels, api returns tags for all 21 with none missing the field. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
276b12607a |
every brief the platform writes, where both harnesses will find it
opencode discovers AGENTS.md and nothing else — its only target is
H.up({targets:["AGENTS.md"]}), verified in the binary. claude code discovers
CLAUDE.md. no overlap.
every brief the platform wrote was CLAUDE.md, so under opencode a commander got
none of its standing brief, a bot none of its arming instructions, and a plugin
chat none of its plugin's guidance. nothing failed and nothing was logged: the
agent simply knew nothing, which is indistinguishable from the feature not
existing, and is why it lasted this long. commanders can already run under
opencode, so this was live.
writeAgentBrief puts the content in AGENTS.md and a POINTER in CLAUDE.md. a
pointer rather than two copies because these are rewritten every turn, two
copies of that will eventually disagree, and the stale one is the one somebody
reads.
three writers, and the third needed a flag the others must not have:
bots rewritten every turn ON PURPOSE, so a template change reaches them
plugin chats same
commanders onlyIfAbsent — a commander EXTENDS this file over its life, and
rewriting it when the template changed would delete the job it
had agreed with the owner
two things that would have quietly defeated it. the file listing handed to a
commander said "CLAUDE.md — this file", which would have had it editing the
pointer. and backfill-commander-credentials appended to CLAUDE.md, which on a
new commander means burying a credential section inside six lines nobody reads —
it targets AGENTS.md where one exists, CLAUDE.md where it does not, so old
commanders still work.
plugins ship these too: email and proxy-manager's chat/CLAUDE.md are now
chat/AGENTS.md. the platform accepts BOTH source names, because a plugin is its
own repository on its own release cadence and dropping the old name would
un-brief any installed copy that had not updated — silently, again.
tested: both files written, pointer is not a second copy, idempotent, rewrites
on change, and onlyIfAbsent leaves an extended brief alone. verified on disk
after a restart.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
|
||
|
|
1c28a48299 |
the check can look at either plugins directory, and knows what is not a plugin
there are two now. `platform/plugins/` is what this machine has INSTALLED; `officer-dev/plugins/` is every repo in the org cloned side by side, which is where a plugin is worked on from now on. they hold different sets, so the check has to be pointable rather than assuming the one it sits next to. and it skips a repo with no manifest.ts instead of reporting it broken. `av-club` is the case — a standalone media service in the plugins org, meant to become a plugin in time. checking it against a plugin's conventions would flag its branch name as a defect for ever, and a check that cries wolf is one people stop reading. presence is the declaration here exactly as in discover.ts: the manifest is what makes something a plugin, so its absence is what makes something not one yet. |
||
|
|
5d51212cca |
write down what today left unfinished
three, so none of it is re-derived: agents-md-remaining — the five doc pairs landed; the runtime writers did not, and that half is BROKEN rather than untidy. commanders, bots and plugin chats write CLAUDE.md, opencode reads only AGENTS.md, so a commander run under opencode is briefed with nothing and nothing reports it. names the three writers by file. the 21 plugin repos are marked as the owner's to lead — not to sweep unprompted. marketplace-browse-by-tag — data layer done, UI not started. carries the two questions that decide the shape: "All" as default, and whether the detail replaces the grid or sits beside it, which is master-detail vs list-then-page and contradicts how the screen is deliberately built today. calendar-skill-never-used-by-an-agent — every recipe was run BY HAND and works; no agent has ever read the skill and acted on a sentence. those are different claims and only one of them is tested. |
||
|
|
a865bcfdfc |
put back the orientation the sweep overwrote
|
||
|
|
d381140d54 |
plugins say what they are for, and their checkouts stop lying about themselves
two things, and the second is why the first was painful. TAGS. the marketplace is going to be browsed rather than scrolled: tags on the left, the plugins carrying one on the right. a plugin declares its own in the manifest and the catalogue caches it on sync, exactly as it already does for label and summary — the alternative is editing a table every time a plugin ships, and a plugin that cannot say what it is. the vocabulary is CLOSED (PLUGIN_TAGS) because free text becomes homelab, home-lab, Home Lab and selfhosted inside a month, and a filter list of synonyms is worse than no filter. an unknown tag is refused at discovery BY NAME, so the author is told which tag rather than which array index. all 21 are tagged, each with a one-line note saying why, so a later disagreement is with a stated reason rather than a mystery. every tag has at least three plugins behind it — no filter opens onto an empty page. PLUGINS:CHECK. getting one commit into 21 repositories failed seven times and none of the reasons were about the commit: six remotes were HTTPS and cannot authenticate unattended, three checkouts were behind their remote, one URL had no .git suffix so the catalogue published a clone URL unlike every other, and offscale's fetch refspec named a branch that does not exist. that last one is why this is a script and not a habit. every command reported on a phantom — `git push` said "Everything up-to-date" while the branch looked diverged — and the repository was fine the whole time. twenty minutes spent in a repo with nothing wrong with it. none of it is visible until you try to push, which is the worst moment to learn it: mid-task, across 21 repositories, and every failure looks like it is about your change. so it checks the facts with one right answer — remote URL, fetch refspec, branch, upstream, dirty tree, ahead/behind — and exits non-zero. `--fix` is a separate flag so running the check can never be what changes your tree, and divergence is never auto-repaired because it needs a decision about content. verified by breaking each case on purpose and watching it catch them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
2360a3ef60 |
agents.md holds the instructions, claude.md points at it
opencode discovers only AGENTS.md, Claude Code only CLAUDE.md — so with the content living in CLAUDE.md, every doc here was invisible to opencode. Swaps all five pairs (root, src/apps, src/databases, themes, plugins): AGENTS.md now holds the full text verbatim, CLAUDE.md is a short pointer. docs-check's permission-count regex now reads root AGENTS.md instead of CLAUDE.md; the workspace-level officer-dev/CLAUDE.md check is untouched, out of scope. |
||
|
|
185aa3168e |
two todos from the caldav extraction
setting up a phone should be one link inside officer — open Calendar, the screen already knows this phone is not set up, click, done. the endpoint for it exists (POST /api/caldav/provision/ios) and nothing in the UI calls it, so the smooth path is built and hidden while the path a new person walks has four fields. it also does not currently work: the profile is refused by iOS 26.6 as "Invalid Profile". the todo records what was RULED OUT with evidence rather than reasoning, because that is the expensive part to redo — delivery works, the phone really received it (the claim is logged now), the server side is correct, and removing PayloadScope did not fix it. signing is untested and is the first experiment. second: a plugin with a sidecar proxy loses the request body on its own routes. createSidecarProxy registers the whole mount prefix and bodyParser skips it, which is right for forwarded traffic and load-bearing for uploads — but it covers the plugin's own routes too. caldav works around it locally; the real fix is the proxy claiming only the sub-path it serves, which is shared machinery and not a thing to change with a phone waiting. |
||
|
|
fdf50ce2a4 |
ship screenArtUrl, which the last commit left behind
|
||
|
|
52f209c69f |
caldav leaves the platform
calendar and contacts are a plugin now — plugins/caldav, its own repository like every other one. a fresh machine runs five processes instead of seven and gets a calendar by installing one plugin, which is the whole point: the platform stops carrying a python calendar server for people who may not want one. what left: the radicale sidecar, four routers, the panel apps, the dav_app_passwords table, the two screens, the two dock tiles, the permission registry entry, the settings section, and radicale from every package list. three platform things had to change first, each committed on its own: |
||
|
|
e0df28ff5d |
a first-party plugin may claim a root path the platform reserved
caldav is not only /api/caldav. four of its surfaces are top-level and above the account gate on purpose, because DAVx5, iOS and Thunderbird send HTTP Basic on every request and have nowhere to put a JWT: /dav, the ios profile under it, and the two .well-known discovery paths a client probes before it has any credential. the mechanism for that already existed — unauthenticatedRootPaths, exclusive claims, first come wins. what stopped caldav using it was RESERVED_ROOT, and what that list was recording is worth being exact about: `dav` and `.well-known` are in it because the PLATFORM serves them today, not because they are dangerous in themselves. once caldav is a plugin, the thing the reservation protects IS the plugin, and keeping it would mean the platform permanently owning routes for a feature it no longer contains. so a reserved name is claimable by a first-party plugin and refused to everyone else. the owner drew that line today: fine for a plugin we wrote, and letting a stranger's plugin claim /dav is a question that needs thinking about properly rather than falling out of a flag. /api stays refused to us too — everything of officer's lives under it and protocolHeaders is the only sanctioned way in. .well-known needed two segments, which the single-segment rule refuses. allowed by NAME rather than by pattern, and the reason is in the same namespace: acme-challenge is how this machine gets its TLS certificates, so a plugin owning it could take the domain. caldav and carddav are on the list; nothing else is, for us as much as for anyone. the OPTIONS carve-out moved with it. hono's cors() answers every OPTIONS as a preflight and never calls the route beneath — which to a DAV client is not a preflight but the question "what can you do", answered by a header iOS refuses the account without. that was a literal list of four DAV paths; it asks the claim registry now, so it follows whoever owns the route rather than naming it here. verified: server boots (totality passes), /.well-known/caldav still 301s and /dav/ still asks for basic auth. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
6b5c87da10 |
a plugin can be more than one screen
a plugin was one screen because the two things it declares — a mount prefix and a frontend — happened to be the same string. caldav is where that stops holding: one sidecar, one permission, one install, and two screens that have nothing to do with each other on screen. calendar and contacts are not sections of one page, and forcing them into one would be a worse product than the core screens they replace. nothing else moved. all eighteen plugins with a frontend use flat web/panels.ts, which is now "one screen, at the mount prefix, labelled by the manifest" — the same file, the same route, the same persisted layout key. the nested form is opt-in and caldav is its only user. the split of who declares what follows the rule this file already had. the DIRECTORY declares that a screen exists (web/<segment>/panels.ts); the MANIFEST says what to call it and which glyph to draw, because a directory listing cannot say either and those are a human's choice. the two are cross-checked and a mismatch is refused rather than reconciled: a segment with no directory renders nothing, a directory with no entry has no tile and no label, and both are the quiet kind of broken. the part that needed the most care is that a segment is a URL the plugin CHOSE — /calendar, not /caldav/calendar. mountPrefix is what makes it impossible for a plugin to shadow a core route, and a chosen segment steps straight out of it. so this is first-party only, refused at discovery, and tested: without that check this field is a way for any installed plugin to take over /files. screenId, not appName, is now the layout key and the panel allow-list's owner. two screens sharing one would persist over each other's sizes and each would accept the other's panels. existing plugins get screenId === appName, so no stored layout is orphaned — verified by regenerating. |
||
|
|
131af603ff |
remove launcher_config.json, an unused stray from the initial commit
Nothing in this codebase reads or writes it — no code, no docs, no reference to port 3333 anywhere. Its own last_updated timestamp (Dec 2025) predates this repo, and its shape (host/port/enable_request_logging, Python-isoformat timestamp) doesn't match anything Officer generates; there is no Python in this repo at all. Swept into the first commit by accident, not something the platform depends on. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
8a3047b06b |
fix_ssh_permissions never checked the home directory itself
Asked to look at user provisioning and .ssh dir creation — the recurring pain point was "the .ssh permissions are never right even when I do everything correctly", and tracing it: fix_ssh_permissions() only ever touched ~/.ssh and its contents. sshd's StrictModes (on by default; nothing here turns it off) also refuses authorized_keys if the HOME DIRECTORY is group- or world-writable — not just .ssh. An account created by this script's own adduser call gets a safe 0755 home by default, but one that already existed some other way (cloud-init, a prior useradd, LDAP, or simply an account that was already there) might not, and the script never checked or reported on it. From the user's side that looks exactly like the complaint: .ssh is 0700, the key is present and correctly formatted, and login still fails, with nothing in the script's own output pointing at why. Fixed by extending fix_ssh_permissions() — called on every path that adds or verifies a key — to check the home directory's mode first and clear only the group/other write bits if set (go-w), reported with a warn rather than done silently, since it's genuinely surprising when it fires. Deliberately not forced to a fixed mode: 0755 vs 0750 is a real choice some setups make on purpose, and this only removes the specific bits sshd refuses to see. Verified with two simulations: a 775 home directory gets warned about and corrected to 755 (.ssh and authorized_keys already-wrong modes fixed too, as before); a 750 home directory is left untouched with no warning. The stat call tries GNU's -c first (this step only runs on Linux) with a BSD -f fallback, same pattern officer-setup's layout.sh already uses for the same reason — not a live concern here since MACOS_SKIP keeps this step off macOS entirely, but consistent rather than a call that would silently do nothing if that ever changed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
2e7fb84454 |
fix three officer-setup bugs found on a general review, unrelated to macOS
env_get() never matched its own file. write_env() always writes OFFICERDEV_-prefixed keys, but env_get() compared against the bare name — verified against a real written .env: `env_get PORT` -> [], `env_get OFFICERDEV_PORT` -> [9000]. Every one of its 5 call sites uses the bare name, so this always came back empty. Two consequences: the "recover what earlier runs already decided" block never recovers anything — its own comment says it exists to fix "Build announced PUBLIC_URL not set on a machine whose .env had been written twenty minutes earlier", and that exact bug was still live — and re-running the Environment step showed the wrong default (9000, tailnet URL) instead of the machine's actual current config. Fixed to check the prefixed key first, falling back to the bare one for an old unprefixed file, matching the runtime's own `OFFICERDEV_X ?? X` priority. pg_password_from_env_file() returned success with an empty string when the compose .env existed but had no POSTGRES_PASSWORD= line. The caller (`pg_compose_exists && PG_EXISTING_PASSWORD="$(pg_password_from_env_file)"`) would then treat that as "reuse this password" and build POSTGRES_URL with a blank credential rather than falling through to provision fresh. Only reachable via a hand-edited or corrupted file — write_pg_compose always writes the line — but "silently reuse a blank password" was the wrong direction to fail in. Now returns 1 when the line is missing or empty. SETUP_DOCKER_NETWORK was read independently by both scripts, each defaulting to "services", but never actually persisted across the machine-setup -> officer-setup boundary — not one of the three keys load_machine_answers recognised. A customised bridge name from machine-setup would silently not carry over, and Proxy would land back on "services" regardless of what Docker was actually told to use. Now saved by machine-setup's save_answers(), recognised by officer-setup's load_machine_answers(), and PROXY_NET is re-derived from it right after that load — proxy.sh's own top-level assignment runs at source time, before the answers file could have populated the variable, same ordering issue ENV_PORT/ENV_PUBLIC_URL/POSTGRES_URL already had to solve. All three verified with isolated reproductions before and after: env_get against a real prefixed .env and a legacy bare one, pg_password_from_env_file against a normal and a corrupted compose .env, and the full save/load/re-derive chain for SETUP_DOCKER_NETWORK. bash -n clean on every touched file. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
5dc9f2d5f7 |
neovim had no macOS install path at all — it always tried the Linux tarball
Asked to check how the script installs Neovim on Mac, and the answer was: it doesn't. nvim_install() had zero `$OS` branching, so on macOS it unconditionally fetched nvim-linux-<arch>.tar.gz — a URL that 404s there. Not a hard crash: the caller's `if nvim_install; then … else warn …` catches it and reports "Neovim: install failed" rather than aborting the run, which is exactly the shape that's easy to miss in a long scrollback. brew has a current, real neovim formula (0.12.5), so that's the macOS branch — no keg-only linking needed, unlike the Postgres client fix. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
d02aac1eb8 |
drop htop from the core package list, unused next to btop
Nothing references it — btop is the one the System Monitor app actually embeds (docs/navigation-audit.md, /system-monitor/btop). htop was just sitting beside it on all four package managers with no caller anywhere, and its own first-run message asks for sudo to be useful at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
e37fe8330e |
skip radicale and officer-caldav on a macOS dev install, not the platform
Hit live: `brew install radicale` fails outright, "No available formula with the name radicale" — it is a pure-Python package (pip/pipx), not something Homebrew carries, unlike every other name in that list. apt/pacman/dnf all package it for real and are untouched. Narrower than removing the feature: officer-caldav (Calendar/Contacts) stays CORE on Linux. On macOS the sidecar has nowhere to find Radicale — resolveRadicale() failing is exactly the "took a whole sidecar down on a fresh install" case its own test already guards against — so CORE_PROCESSES leaves it out on Darwin rather than let it crash-loop. Calendar/Contacts is the one core feature not available on a Mac dev install; nothing else changes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |