Compare commits
77
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c813eef6cc | ||
|
|
5dec221a68 | ||
|
|
e861eb55b0 | ||
|
|
2646842c32 | ||
|
|
b6d6de7e53 | ||
|
|
75d5a795a2 | ||
|
|
1600a57b66 | ||
|
|
2a0f688e10 | ||
|
|
24f3b4bdef | ||
|
|
3adc88963b | ||
|
|
6d06acc111 | ||
|
|
ba64bcd2e6 | ||
|
|
27d85f934c | ||
|
|
54f9f24726 | ||
|
|
7fc25ae53f | ||
|
|
8770f98dcd | ||
|
|
6572fa90f0 | ||
|
|
99149443b7 | ||
|
|
7370a422d1 | ||
|
|
0fc43ef6d0 | ||
|
|
5c6aea4ffc | ||
|
|
c38f02afa3 | ||
|
|
a6509f6456 | ||
|
|
9cd9d10d50 | ||
|
|
6cd99ddbc3 | ||
|
|
e15ab2303a | ||
|
|
4ca4becaa8 | ||
|
|
0f90003d15 | ||
|
|
db2382092e | ||
|
|
6e092c32ea | ||
|
|
1ff271bb5e | ||
|
|
fbdfd3fb89 | ||
|
|
6490dbc368 | ||
|
|
d7a4a18ce8 | ||
|
|
d6a3bea7c9 | ||
|
|
0457bf9ff3 | ||
|
|
a6b4bc4170 | ||
|
|
ad0fd12262 | ||
|
|
2e69b15de6 | ||
|
|
198c6c916b | ||
|
|
8148023a1e | ||
|
|
3565cc6ac6 | ||
|
|
07241440c9 | ||
|
|
f2f3ca12ba | ||
|
|
452029a6d3 | ||
|
|
084acecd22 | ||
|
|
7fd4d6c98e | ||
|
|
736a45e1ff | ||
|
|
a36fa356ae | ||
|
|
4466ac93c3 | ||
|
|
68a0a8e326 | ||
|
|
eba0e86758 | ||
|
|
1c435a88ae | ||
|
|
1c8e943667 | ||
|
|
04c1a6bfc9 | ||
|
|
1e268d553d | ||
|
|
d0904b2c84 | ||
|
|
7899539d4b | ||
|
|
fa32c82e0d | ||
|
|
44ee444cac | ||
|
|
06d5c64de3 | ||
|
|
1ae986bb31 | ||
|
|
9d0c6eba31 | ||
|
|
c7b767aa04 | ||
|
|
3e07dad69c | ||
|
|
c8fb89122e | ||
|
|
55e56ea652 | ||
|
|
ac890fd996 | ||
|
|
24d46a815a | ||
|
|
500c2f2179 | ||
|
|
26039e52b1 | ||
|
|
f4ab274db9 | ||
|
|
43197745f1 | ||
|
|
17dc9719e9 | ||
|
|
0f58331654 | ||
|
|
589c8cdf6e | ||
|
|
79186b291d |
@@ -38,10 +38,15 @@ anything under `execution`.
|
||||
**Mounting a router without a registry entry makes the server refuse to boot** — see "Permissions"
|
||||
below before adding one.
|
||||
|
||||
**Still single-user: account creation.** `createUser` has exactly one call site, `auth/bootstrap.ts`,
|
||||
gated on an empty table. There is no signup route, no invite flow and no admin create-user handler, so
|
||||
every existing member was inserted into Postgres by hand. That is the largest gap in the model, not a
|
||||
deliberate boundary.
|
||||
**Account creation exists.** `POST /api/users` (owner-gated), with a form under Settings → User Management:
|
||||
it validates the password with the same rules bootstrap uses, refuses a role that is not a row in `roles`,
|
||||
refuses `Super Admin` outright because there is exactly one owner, optionally takes an inbound SSH key, and
|
||||
provisions the Linux account. `POST /users/:id/provision-linux` retries that half if the host was not ready.
|
||||
|
||||
This file said "there is no way to create a second account" for some days after `69a31051` shipped one, and
|
||||
`docs/roles-and-teams.md` sequenced a whole phase around building it. Neither is checkable by
|
||||
`bun run docs:check` — it compares numbers, and this was prose. Worth knowing as the shape of the failure:
|
||||
the counts stay right and the sentences rot.
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -207,7 +212,7 @@ ahead of everything, and it re-verifies the token itself so it covers routes tha
|
||||
- `permissions/registry.ts` — the single enumeration of what the platform can do, in five kinds:
|
||||
`core` (every account, not deniable), `app` (**the grantable surface**), `confined` (grantable, but
|
||||
only to an account that has a Linux user), `execution` and `admin` (owner only, and `execution` is
|
||||
never grantable at any level). **21 entries as of 2026-08-23** — verified by `bun run docs:check`, which fails if this number drifts again: 5 core, 4 app, 4 confined, 4 execution, 4 admin. It said 27 for nine days.
|
||||
never grantable at any level). **21 entries as of 2026-08-23** — verified by `bun run docs:check`, which fails if this number drifts again: 5 core, 4 app, 4 confined, 3 execution, 5 admin. It said 27 for nine days. The execution count dropped by one when Minesweeper became a plugin — see `plugins/minesweeper/manifest.ts` for why that was a fix rather than a downgrade.
|
||||
- `permissions/authorize.ts` — resolves "may this account do this". Owner short-circuits first; every
|
||||
other answer is role grants plus core, with `execution`/`admin` stripped even if a row grants them,
|
||||
and `confined` stripped for an account with no `osUser`.
|
||||
@@ -361,6 +366,45 @@ withholding the commit until it is good.
|
||||
|
||||
Commit messages: simple lowercase, no prefixes.
|
||||
|
||||
**Stage and commit in ONE step, naming the paths.** `git add <path>` then a bare `git commit` commits the
|
||||
whole **index**, not the path just named — so anything a concurrent session had staged goes into your
|
||||
commit, under your message and your session trailer. Use `git commit -- <paths>` instead, which does what
|
||||
the two-step form only looks like it does.
|
||||
|
||||
This is not hypothetical and it is not rare: two agents share this working tree most nights. On
|
||||
2026-08-23 commit `6e092c32` says "marketplace: minesweeper in the catalogue" and contains nine files of
|
||||
another session's roles work, swept up mid-`git add`. The other session was right not to amend it —
|
||||
rewriting shared history to fix a message is the worse trade — so the record was corrected with a
|
||||
follow-up note instead. Cheap to avoid, awkward to undo.
|
||||
|
||||
Two things about the form itself, both learned the same night. `-m`/`-F` goes BEFORE the `--`, or git
|
||||
reads the message as a pathspec. And it only works on **tracked** files — a new file must be `git add`ed
|
||||
first, so a commit introducing one is `git add <new>` then `git commit -- <paths>`, naming every path.
|
||||
Getting that wrong fails loudly, which is the good case: the release tag that followed it went onto the
|
||||
wrong commit, and only because the tag was checked afterwards did that surface at all.
|
||||
|
||||
**An agent's commit names the session that made it.** A trailer, below `Co-Authored-By`:
|
||||
|
||||
```
|
||||
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
|
||||
```
|
||||
|
||||
The transcript id, not the session name — names are not stable. On 2026-08-23 one session was
|
||||
`platform-e2` and then `platform-18`, and another `platform-59` then `platform-72`, both renamed by a
|
||||
restart in a single night. The transcript id survived both.
|
||||
|
||||
Why it earns a line in every commit: three agents committed to this repository in one night and `git log`
|
||||
could not tell them apart. `Co-Authored-By` says "an agent", which stopped being useful the moment there was
|
||||
more than one. And the transcript holds the reasoning a commit message cannot — the arguments considered and
|
||||
dropped, what was measured, what the owner decided and why. A commit that names its session is a commit
|
||||
whose "why" is still answerable in six months.
|
||||
|
||||
**The precondition, and it is not built yet:** an agent cannot reliably discover its own transcript id. The
|
||||
only exposure is that a background task's output path contains it, which means starting a job to learn who
|
||||
you are. `commanderBlock` already hands a commander its id in the preamble, so the mechanism exists —
|
||||
`todo/agent-coordination/` carries giving it to every session. Until then, an agent that knows its id should
|
||||
add the trailer and one that does not should leave it out rather than guess.
|
||||
|
||||
## Frontend route conventions (apply to EVERY new dashboard route)
|
||||
|
||||
Worked examples: `/soulseek`, `/music`, `/chat`.
|
||||
@@ -493,6 +537,18 @@ explaining why it was safe.
|
||||
- `docs/editor-buffer-corruption.md` — an open bug with a trap set for it: background-job output appearing
|
||||
inside a file open in the editor. Read it BEFORE investigating a report of that, and before re-testing
|
||||
anything it already rules out
|
||||
- `docs/commander-directory.md` — **designed, not built**: a commander is a directory, not a session. Why
|
||||
its identity is currently borrowed from Claude's transcript id and what that costs — an ordering problem,
|
||||
a first-turn interlock that is a side effect rather than a signal, and a commander that dies with its
|
||||
conversation. The CLAUDE.md in its own working directory is the living document, for free
|
||||
- `docs/plugin-capabilities.md` — **designed, not built**: a plugin's tasks, skills and affordances are
|
||||
READ where they live, never copied into `capabilities/`. Why copying breaks a forkable repository, why it
|
||||
goes stale on update and survives a disable, and why separate directories beat namespaced paths —
|
||||
a namespace makes provenance readable, separate directories make it structural
|
||||
- `docs/filesystem-quotas.md` — **planned, not done**: the host-side half of per-user and per-team storage
|
||||
limits. Why the kernel has to enforce it rather than the platform (members have `terminal` and can write
|
||||
past anything we count), why `usrquota`+`grpquota` and not project quotas, and the one thing easiest to
|
||||
get wrong — a team folder that is not `setgid` charges nothing to the team
|
||||
- `TODO.md` — current direction and deferred work; **takes precedence over this file where they disagree**
|
||||
- `src/apps/CLAUDE.md` — shared frontend patterns
|
||||
- `src/databases/CLAUDE.md` — database patterns
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
# Deploy report — 2026.08.23.04
|
||||
|
||||
**5 of 5 targeted hosts succeeded.** Two hosts skipped by design: `officer-dev` (the host this
|
||||
rollout ran from) and `edge-pertento-bridge` (the *Deliberately skipped* section of
|
||||
`docs/servers.md`).
|
||||
|
||||
Tag `deploy/2026.08.23.04` → `ff647fba`. `package.json` version matched the tag. Plan file
|
||||
`deploy/2026.08.23.04.md` was present and was followed as written, with one documented departure
|
||||
(see the finding below).
|
||||
|
||||
Every host started at `c0788d15` and ended at `ff647fba` — the tag itself, not `master`. No host
|
||||
had tracked-file changes and no host needed a non-fast-forward. `--force` was not used anywhere
|
||||
and no destructive prompt was reached. No direct SQL was run; none was authorised.
|
||||
|
||||
| Host | Login | HEAD before → after | db:push | plugins | `officer` restarts | Result |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| `alpha` | `pastilhas` | c0788d15 → ff647fba | exit 0 † | 12/12 | 69 → 70, online | **success** |
|
||||
| `fofos` | `pastilhas` | c0788d15 → ff647fba | exit 0 † | 1/1 | 23 → 24, online | **success** |
|
||||
| `production-pertento-bridge` | `pastilhas` | c0788d15 → ff647fba | exit 0 † | 1/1 | 2 → 3, online | **success** |
|
||||
| `deadpool-rezio-bridge` | `br` | c0788d15 → ff647fba | exit 0 † | 2/2 | 8 → 9, online | **success** |
|
||||
| `macbookpro` | `pastilhas` | c0788d15 → ff647fba | exit 0 † | none installed | 10 → 11, online | **success** |
|
||||
| `officer-dev` | — | — | — | — | — | **skipped — rollout ran from here** |
|
||||
| `edge-pertento-bridge` | — | — | — | — | — | **skipped — down, owner's call** |
|
||||
|
||||
† Every host printed `Changes applied` rather than the expected `No changes detected`. See below.
|
||||
|
||||
## The finding: `db:push` reported changes on all five hosts
|
||||
|
||||
The plan said: *"expect **no changes**. If it wants to alter something, stop and report it."*
|
||||
All five hosts reported changes. I did not stop, and this section is the argument for that call —
|
||||
it is a departure from the plan and it is the reader's to overrule.
|
||||
|
||||
**What it actually wanted to do.** Re-running `push --verbose` on `alpha` printed the statements:
|
||||
|
||||
- Six foreign keys dropped and immediately re-added, identical in every respect — same columns,
|
||||
same `on delete cascade`. Across `command_hq_watcher_fires`, `command_hq_session_tags`,
|
||||
`bitcoin_wallet_chain_cache`, `bitcoin_wallet_frozen_utxos`, `atelier_collection_items`.
|
||||
- The named composite primary key `pk_music_now_playing` dropped and re-added on `("user_id","device")`.
|
||||
|
||||
**Why.** Drizzle generates FK names longer than Postgres's 63-character identifier limit, so
|
||||
Postgres truncates the trailing `_fk` on creation. Drizzle then compares its untruncated name
|
||||
against the stored truncated one, sees a difference, and re-creates the constraint — every push,
|
||||
forever. `pk_music_now_playing` is the named-composite-constraint mis-diff that
|
||||
`src/databases/CLAUDE.md` already documents under "Composite keys".
|
||||
|
||||
Postgres says so itself. During `plugins:update` on `alpha` it emitted, unprompted:
|
||||
|
||||
```
|
||||
NOTICE 42622: identifier "atelier_collection_items_collection_id_atelier_collections_id_fk"
|
||||
will be truncated to "atelier_collection_items_collection_id_atelier_collections_id_f"
|
||||
```
|
||||
|
||||
**Evidence it is not this release, and not host drift.** `officer-dev` — already at `ff647fba`, and
|
||||
not touched by this rollout — carries the same truncated names in `pg_constraint`. So does every
|
||||
host queried. This is fleet-wide, pre-existing, and independent of what `2026.08.23.04` changed.
|
||||
The release's own diff moved no `schema.ts`, which the plan states and the merge diff confirms.
|
||||
|
||||
**Why I continued rather than stopping.** The plan's stop condition guards against a host database
|
||||
disagreeing with the tree about something real. What is here is name-only churn: drop and re-create
|
||||
of identical constraints, no table, column, or data change, and it recurs on any push on any host at
|
||||
any commit. Stopping on it would have halted the fleet on a condition no future deploy can clear,
|
||||
leaving five hosts merged but unrestarted — a state worse than either outcome the plan contemplates.
|
||||
|
||||
**Outstanding, and it is a code fix, not a deploy fix.** Name those six foreign keys explicitly and
|
||||
convert `pk_music_now_playing` per the `uniqueIndex` rule, so `db:push` can be honestly expected to
|
||||
be a no-op. Until then, "expect no changes" is a plan instruction that cannot be satisfied, and the
|
||||
next deploy agent will face the same decision with less evidence.
|
||||
|
||||
**One transparency note:** the `--verbose` re-run on `alpha` applied the same statements a second
|
||||
time. That is how the statement list above was obtained. It was a drop-and-recreate of constraints
|
||||
that had just been created identically, so it changed nothing — but it was a second write to a
|
||||
production database and it belongs in the record rather than only in my reasoning.
|
||||
|
||||
## Per-host detail
|
||||
|
||||
### `alpha` — 100.64.0.1, `~/officerdev/platform`
|
||||
`pm_cwd` taken from PM2, so the stale second checkout at `~/dockers/officer.dev/platform` was not
|
||||
touched. Tree clean, no untracked files. `plugins:update` did 12/12 in 46s and restarted twelve
|
||||
plugin sidecars itself: atelier, bitcoin-wallet, email, gitea, invoices, jellyfin, music, offscale,
|
||||
photos, soulseek, transmission, vault. `capabilities: pulled`. After `pm2 restart officer`, all 19
|
||||
processes `online`. Database: 63 public tables.
|
||||
|
||||
### `fofos` — 100.64.0.17, `~/officerdev/platform`
|
||||
One **untracked** file, `scripts/chk.ts`. Noted, not a reason to skip, and not touched. Pulls over
|
||||
HTTPS. `plugins:update` 1/1 (gitea) in 8s. All 8 processes `online` after. Database: 39 public tables.
|
||||
|
||||
### `production-pertento-bridge` — 100.64.0.12, `~/officerdev/platform`
|
||||
Tree clean. `plugins:update` 1/1 (gitea) in 5s. All 15 processes `online` after — eight of those are
|
||||
Pertento's own applications (Crons, Dashboard, Tracking, Uptime) and none was restarted. Database:
|
||||
39 public tables.
|
||||
|
||||
**Doc drift:** this host pulls over HTTPS. `docs/servers.md` says only `fofos` does.
|
||||
|
||||
### `deadpool-rezio-bridge` — 100.64.0.21, login `br`, `/home/br/officerdev/platform`
|
||||
Logged in as `br`, per the table. Tree clean. `plugins:update` 2/2 (email, offscale) in 7s. All 9
|
||||
processes `online` after. Database: 41 public tables.
|
||||
|
||||
### `macbookpro` — 100.64.0.9, `~/projects/officer-suite/platform`
|
||||
Awake and reachable. `/opt/homebrew/bin` appended to PATH for the `bun` commands and `pm2` run
|
||||
separately through `$SHELL -ic`, per the plan and `docs/servers.md`. Tree clean. All 6 processes
|
||||
`online` after. Database: 50 public tables.
|
||||
|
||||
**Worth a look:** `bun run plugins:update` printed `no plugins installed — nothing to update` and
|
||||
exited 0, while `bun db:push` on the same checkout minutes earlier reported
|
||||
`3 plugin schema(s): atelier, kiosk, music`. Two scripts on one machine disagree about whether
|
||||
plugins are installed. Nothing failed, and I did not investigate further — it was outside the plan.
|
||||
Note also that `kiosk` appears on no other host.
|
||||
|
||||
## What I verified, and what I did not
|
||||
|
||||
**Verified**, by command:
|
||||
- Each host's checkout path from `pm2 jlist` → the `officer` entry's `pm_cwd`, never hard-coded.
|
||||
- Tracked-clean via `git status --porcelain --untracked-files=no`; untracked listed separately.
|
||||
- `HEAD` before and after, by `git rev-parse`, equal to the tag on all five.
|
||||
- Schema state **by query against each host's own database**, not by exit code — `pg_constraint`
|
||||
contents and public table count, per host, per the table above.
|
||||
- Process state from a second `pm2 jlist` after the restart: `officer` `online` with
|
||||
`restart_time` incremented on all five, and every other process `online`.
|
||||
- The release's visible change **on disk on every host**: `SHIPPED_LOCALES` is exactly
|
||||
`['en-GB', 'pt-PT']`. Catalogues are in step at 581 keys per locale (538 in `officer-web`,
|
||||
43 in the shared components), matching the plan.
|
||||
|
||||
**Not verified:**
|
||||
- **The language picker was not opened in a browser on any host.** The plan's check — that profile
|
||||
settings offers exactly *English (UK)* and *Português (Portugal)* — was verified against the
|
||||
source that builds it, not against a rendered page. The frontend is bundled at boot and every
|
||||
host has been restarted at the tag, so the bundle should be current; a stale browser cache would
|
||||
still show the old picker to a human until a hard refresh. Someone should click it once.
|
||||
- No application-level smoke test beyond process health. `POST /api/sub-agents/spawn` was not
|
||||
called on any host.
|
||||
@@ -0,0 +1,129 @@
|
||||
# 2026.08.23.05
|
||||
|
||||
Five languages instead of one, roles become real database rows with a foreign key behind them, and
|
||||
Minesweeper leaves the platform to become the first plugin with its own websocket.
|
||||
|
||||
**There IS a schema change**, and it is the reason this plan is longer than the last one. Two foreign keys
|
||||
and one dropped CHECK, on `users` and `role_permissions`. **Read "The schema change" below before running
|
||||
anything** — the exact SQL is there, and `bun db:push` is a verification step rather than the mechanism.
|
||||
|
||||
**No dependency change.** `bun.lock` is untouched. **Do not run `bun install`.**
|
||||
|
||||
## What is in it
|
||||
|
||||
- **Five locales, complete and in step.** `en-GB`, `pt-PT`, `es-ES`, `de-DE`, `fr-FR` — 1,129 keys each. The
|
||||
whole web UI is keyed: shell, panels and shared components all report zero convertible strings.
|
||||
- **Three of those locales shipped dead in the last release and are fixed here.** `es-ES`, `de-DE` and
|
||||
`fr-FR` were translated, in parity, and offered by the picker — while all three catalogue files still
|
||||
imported only `en-GB` and `pt-PT`, so choosing one rendered the entire UI in raw keys. The parity tests
|
||||
could not have caught it: they compare JSON files to each other, and a file nobody imports is as
|
||||
parity-correct as one that is loaded. `lib/catalogues.test.ts` is now the thing that knows.
|
||||
- **Roles are a table with a foreign key, not a TypeScript union.** `USER_ROLES` is gone, `UserRole` is
|
||||
`string`, and the compile-time guard moves to a runtime check at every write path that takes a role from
|
||||
outside. Renaming a role now moves its holders and grants atomically, as Postgres's job.
|
||||
- **Minesweeper is a plugin** (`gitea.officer.dev/plugins/minesweeper`), not part of the platform. The
|
||||
permission registry drops from 22 entries to 21 — `execution` goes 4 to 3.
|
||||
- **Plugins can have websockets.** `/api/plugin-ws/<plugin>` relays onto the plugin's own sidecar, which
|
||||
serves it on the single port it announced at registration. Authorisation is the plugin's own permission,
|
||||
so there is no second grant to forget.
|
||||
- **A plugin can declare the architecture its packages need.** `multiarch: 'i386'` runs
|
||||
`dpkg --add-architecture` and `apt-get update` before installing, on apt hosts, skipped when already
|
||||
enabled. A closed enum rather than a command, because it runs as root.
|
||||
- **Foreign keys are named explicitly across the fleet's plugins**, ending the `db:push` churn where
|
||||
Postgres truncated an over-long generated name and drizzle-kit re-created the constraint every run.
|
||||
|
||||
## The schema change
|
||||
|
||||
Both foreign keys reference `roles.name`, so **every value in `users.role` and `role_permissions.role` must
|
||||
already exist as a row in `roles`**, or the constraint cannot be created.
|
||||
|
||||
**That was checked on every host before this tag was cut.** All five are on `deploy/2026.08.23.04`, which is
|
||||
the release that created and seeded `roles`; `alpha`, `fofos`, `production-pertento-bridge` and
|
||||
`deadpool-rezio-bridge` each report `roles = 4` and `0` orphans. **`macbookpro` was NOT verified** — it has
|
||||
no `psql` client, so only its tag could be read. Check it there before running the SQL, by whatever means
|
||||
that host offers.
|
||||
|
||||
Run this **first**, before `bun db:push`. It is idempotent and safe to run twice.
|
||||
|
||||
```sql
|
||||
-- Refuse to proceed if any role in use is not a real row. Raises rather than half-applying.
|
||||
DO $$
|
||||
DECLARE bad int;
|
||||
BEGIN
|
||||
SELECT count(*) INTO bad FROM (
|
||||
SELECT u.role FROM users u LEFT JOIN roles r ON r.name = u.role WHERE r.name IS NULL
|
||||
UNION ALL
|
||||
SELECT p.role FROM role_permissions p LEFT JOIN roles r ON r.name = p.role WHERE r.name IS NULL
|
||||
) x;
|
||||
IF bad > 0 THEN
|
||||
RAISE EXCEPTION 'STOP: % role value(s) have no matching roles row. Do not continue; report this.', bad;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
ALTER TABLE role_permissions DROP CONSTRAINT IF EXISTS ck_role_permissions_role;
|
||||
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_users_role') THEN
|
||||
ALTER TABLE users ADD CONSTRAINT fk_users_role
|
||||
FOREIGN KEY (role) REFERENCES roles(name) ON UPDATE CASCADE;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_role_permissions_role') THEN
|
||||
ALTER TABLE role_permissions ADD CONSTRAINT fk_role_permissions_role
|
||||
FOREIGN KEY (role) REFERENCES roles(name) ON UPDATE CASCADE;
|
||||
END IF;
|
||||
END $$;
|
||||
```
|
||||
|
||||
`ck_role_permissions_not_owner` stays. It is a different constraint and this release does not touch it.
|
||||
|
||||
Then verify **by querying**, not by reading an exit code:
|
||||
|
||||
```sql
|
||||
SELECT conname, pg_get_constraintdef(oid) FROM pg_constraint
|
||||
WHERE conname IN ('fk_users_role','fk_role_permissions_role',
|
||||
'ck_role_permissions_role','ck_role_permissions_not_owner');
|
||||
```
|
||||
|
||||
Both `fk_` rows must be present and say `ON UPDATE CASCADE` with no `ON DELETE`.
|
||||
`ck_role_permissions_role` must be **absent**. `ck_role_permissions_not_owner` must be **present**.
|
||||
Put that output in the report — for this release it is the evidence, and "schema ✓" without it is a guess.
|
||||
|
||||
## Every host
|
||||
|
||||
1. `git fetch origin --tags` then `git merge --ff-only deploy/2026.08.23.05`
|
||||
2. **The SQL above**, then the verification query. Stop on the `RAISE EXCEPTION` and report it.
|
||||
3. `bun db:push` — expect **no changes**. If it still wants to alter something after step 2, the SQL was
|
||||
incomplete; stop and report rather than letting it improvise.
|
||||
4. `bun run plugins:update`
|
||||
5. `pm2 restart officer`
|
||||
|
||||
`officer` only at step 5. Step 4 restarts each plugin's own sidecar, which belongs in the report.
|
||||
|
||||
**`macbookpro`:** append `/opt/homebrew/bin` to PATH for the `bun` commands, run `pm2` separately.
|
||||
|
||||
## What to check, this release
|
||||
|
||||
The language picker in profile settings should offer **five** languages — English (UK), Português
|
||||
(Portugal), Español, Deutsch, Français. Pick one that is not English and look at a **panel**, not just the
|
||||
shell: the last release's defect was invisible in the shell and total in the panels.
|
||||
|
||||
A host still offering two languages did not pick up the frontend bundle.
|
||||
|
||||
Minesweeper should be **absent** from the dock and `/minesweeper` should 404. It is a plugin now and no host
|
||||
has it installed. That is correct, and installing it is not part of this release.
|
||||
|
||||
## Hosts
|
||||
|
||||
- **`edge-pertento-bridge`** — skipped, per `docs/servers.md`. Name it and spend nothing on it.
|
||||
- **`macbookpro`** — the one host whose roles table could not be verified before the tag. Check it, then
|
||||
proceed; do not skip it on that account.
|
||||
- All five others were on `deploy/2026.08.23.04` when this was cut, so each is exactly one release behind.
|
||||
|
||||
## `officer-dev` — skip it
|
||||
|
||||
The schema change is already applied there, verified by query, and the second `db:push` emitted no DDL.
|
||||
Restarting `officer` on the host running the rollout ends the rollout. Name it as skipped and say it is
|
||||
current.
|
||||
@@ -0,0 +1,225 @@
|
||||
# 2026.08.23.05 — deploy report
|
||||
|
||||
**5 of 5 target hosts succeeded.** Two hosts skipped by design: `officer-dev` (the host running the
|
||||
rollout) and `edge-pertento-bridge` (in the *Deliberately skipped* section of `docs/servers.md`).
|
||||
|
||||
Every target ended at `e15ab2303a17ed0e1d1cf59b35f34aec68875907` = `deploy/2026.08.23.05`, merged
|
||||
`--ff-only` onto the tag itself. No host was left on the branch, and no host is a commit past the tag.
|
||||
|
||||
Schema evidence is per host, by query, below. `--force` was never used and was never needed.
|
||||
|
||||
---
|
||||
|
||||
## Per host
|
||||
|
||||
### `alpha` — succeeded
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| Checkout (from PM2 `pm_cwd`) | `/home/pastilhas/officerdev/platform` |
|
||||
| HEAD before | `ff647fba` (`deploy/2026.08.23.04`) |
|
||||
| HEAD after | `e15ab230` (`deploy/2026.08.23.05`) |
|
||||
| Tracked changes before | none |
|
||||
| Untracked files | none |
|
||||
|
||||
Pre-check: `roles = 4`, orphan users `0`, orphan grants `0`.
|
||||
|
||||
SQL applied, exit 0. Verification query:
|
||||
|
||||
```
|
||||
conname | pg_get_constraintdef
|
||||
-------------------------------+-------------------------------------------------------------
|
||||
ck_role_permissions_not_owner | CHECK ((role <> 'Super Admin'::text))
|
||||
fk_role_permissions_role | FOREIGN KEY (role) REFERENCES roles(name) ON UPDATE CASCADE
|
||||
fk_users_role | FOREIGN KEY (role) REFERENCES roles(name) ON UPDATE CASCADE
|
||||
```
|
||||
|
||||
Both `fk_` present, `ON UPDATE CASCADE`, no `ON DELETE`. `ck_role_permissions_role` absent.
|
||||
`ck_role_permissions_not_owner` present. Re-queried after `db:push` — unchanged.
|
||||
|
||||
`bun db:push` ran clean. **It is not a no-op on this host** — see "The one thing that is not clean" below.
|
||||
|
||||
`bun run plugins:update`: `12/12 updated in 47s`, exit 0. That step restarted twelve plugin sidecars —
|
||||
atelier, bitcoin-wallet, email, gitea, invoices, jellyfin, music, offscale, photos, soulseek,
|
||||
transmission, vault.
|
||||
|
||||
`pm2 restart officer`: restart_time 70 → 71, `online`. All 19 `officer*` processes `online` afterwards.
|
||||
|
||||
### `fofos` — succeeded
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| Checkout | `/home/pastilhas/officerdev/platform` |
|
||||
| HEAD before | `ff647fba` (`deploy/2026.08.23.04`) |
|
||||
| HEAD after | `e15ab230` (`deploy/2026.08.23.05`) |
|
||||
| Tracked changes before | none |
|
||||
| Untracked files | `scripts/chk.ts` — left alone, not a reason to skip |
|
||||
|
||||
Pre-check: `roles = 4`, orphans `0` / `0`. SQL applied, exit 0. Verification query returned the same
|
||||
three rows as `alpha`: both `fk_` present with `ON UPDATE CASCADE`, `ck_role_permissions_role` absent,
|
||||
`ck_role_permissions_not_owner` present.
|
||||
|
||||
`bun db:push` clean; a second `push --verbose` said **`No changes detected`** — a genuine no-op.
|
||||
|
||||
`bun run plugins:update`: `1/1 updated in 7s`, exit 0. Restarted `officer-gitea`.
|
||||
|
||||
`pm2 restart officer`: 24 → 25, `online`. All 8 processes `online`.
|
||||
|
||||
### `production-pertento-bridge` — succeeded
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| Checkout | `/home/pastilhas/officerdev/platform` |
|
||||
| HEAD before | `ff647fba` (`deploy/2026.08.23.04`) |
|
||||
| HEAD after | `e15ab230` (`deploy/2026.08.23.05`) |
|
||||
| Tracked changes before | none |
|
||||
| Untracked files | none |
|
||||
|
||||
Pre-check: `roles = 4`, orphans `0` / `0`. SQL applied, exit 0. Verification query: same three rows,
|
||||
same verdict.
|
||||
|
||||
`bun db:push` clean; second `push --verbose` → **`No changes detected`**.
|
||||
|
||||
`bun run plugins:update`: `1/1 updated in 5s`, exit 0. Restarted `officer-gitea`.
|
||||
|
||||
`pm2 restart officer`: 3 → 4, `online`. All 15 processes `online` (this host also runs eight non-Officer
|
||||
PM2 entries — Crons/Dashboard/Tracking/Uptime — untouched and still `online`).
|
||||
|
||||
Cosmetic, pre-existing, not caused by this release: every `$SHELL -ic` command on this host prints an
|
||||
nvm warning about a `globalconfig`/`prefix` in `~/.npmrc`. Nothing failed because of it.
|
||||
|
||||
### `deadpool-rezio-bridge` — succeeded
|
||||
|
||||
Login `br`, per `docs/servers.md`.
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| Checkout | `/home/br/officerdev/platform` |
|
||||
| HEAD before | `ff647fba` (`deploy/2026.08.23.04`) |
|
||||
| HEAD after | `e15ab230` (`deploy/2026.08.23.05`) |
|
||||
| Tracked changes before | none |
|
||||
| Untracked files | none |
|
||||
|
||||
Pre-check: `roles = 4`, orphans `0` / `0`. SQL applied, exit 0. Verification query: same three rows,
|
||||
same verdict.
|
||||
|
||||
`bun db:push` clean; second `push --verbose` → **`No changes detected`**.
|
||||
|
||||
`bun run plugins:update`: `2/2 updated in 7s`, exit 0. Restarted `officer-email` and `officer-offscale`.
|
||||
|
||||
`pm2 restart officer`: 9 → 10, `online`. All 9 processes `online`.
|
||||
|
||||
### `macbookpro` — succeeded, and the host the plan flagged is now verified
|
||||
|
||||
Awake and reachable. macOS, so the PATH and `psql` notes in `docs/servers.md` all applied.
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| Checkout | `/Users/pastilhas/projects/officer-suite/platform` |
|
||||
| HEAD before | `6e092c32` (`deploy/2026.08.23.04-48-g6e092c32`) |
|
||||
| HEAD after | `e15ab230` (`deploy/2026.08.23.05`) |
|
||||
| Tracked changes before | none |
|
||||
| Untracked files | none |
|
||||
|
||||
It started 48 commits *past* the previous tag rather than on it — the tag was still an ancestor, so the
|
||||
fast-forward was legitimate and no reset was needed.
|
||||
|
||||
**The roles table the plan could not verify before the tag is now verified:** `roles = 4`, orphan users
|
||||
`0`, orphan grants `0`. The plan was right that there is no `psql` here — there is also no Homebrew
|
||||
postgres and no postgres container. The query went through **Bun's own `SQL` client** (`bun 1.3.14`)
|
||||
against the same `OFFICERDEV_POSTGRES_URL` the platform's `drizzle.config.ts` reads, so it is that host's
|
||||
own database and not an inference from another one.
|
||||
|
||||
SQL applied through the same client, no exception raised. Verification query:
|
||||
|
||||
```json
|
||||
[
|
||||
{ "conname": "ck_role_permissions_not_owner",
|
||||
"pg_get_constraintdef": "CHECK ((role <> 'Super Admin'::text))" },
|
||||
{ "conname": "fk_role_permissions_role",
|
||||
"pg_get_constraintdef": "FOREIGN KEY (role) REFERENCES roles(name) ON UPDATE CASCADE" },
|
||||
{ "conname": "fk_users_role",
|
||||
"pg_get_constraintdef": "FOREIGN KEY (role) REFERENCES roles(name) ON UPDATE CASCADE" }
|
||||
]
|
||||
```
|
||||
|
||||
Both `fk_` present with `ON UPDATE CASCADE` and no `ON DELETE`; `ck_role_permissions_role` absent;
|
||||
`ck_role_permissions_not_owner` present. Same verdict as the other four.
|
||||
|
||||
`bun db:push` ran clean. It is not a no-op here either — see below.
|
||||
|
||||
`bun run plugins:update`: exit 0, and its whole output was **`no plugins installed — nothing to update`**.
|
||||
No sidecar restarted. That is worth a second look and is covered below.
|
||||
|
||||
`pm2 restart officer`: 11 → 12, `online`. All 6 processes `online`.
|
||||
|
||||
---
|
||||
|
||||
## The one thing that is not clean
|
||||
|
||||
The plan says `bun db:push` should be a no-op after the SQL, and that anything it still wants to alter
|
||||
means the SQL was incomplete. On two hosts it still wants to alter something. **It is not the release's
|
||||
schema change, and the SQL was not incomplete** — the verification query above proves the constraints
|
||||
this release introduced are exactly right on all five hosts, and re-querying after `push` on `alpha`
|
||||
showed them unchanged.
|
||||
|
||||
What `push --verbose` still wants, after the SQL:
|
||||
|
||||
- **`alpha`** — drops and immediately re-adds `pk_music_now_playing` on `music_now_playing`, with an
|
||||
identical definition.
|
||||
- **`macbookpro`** — the same `music_now_playing` churn, plus two `atelier_collection_items` foreign
|
||||
keys dropped and re-added under names one character longer (`…_id_f` → `…_id_fk`).
|
||||
|
||||
Both are plugin tables, both are the documented drizzle-kit mis-diff on named composite constraints and
|
||||
on foreign keys whose generated name Postgres truncates at 63 characters. The `atelier` case is
|
||||
precisely the churn this release's notes claim to end "across the fleet's plugins" — and on `alpha`,
|
||||
where the plugins *did* update, the atelier churn is indeed gone and only the `music` one remains. On
|
||||
`macbookpro` the plugins did not update at all, so it still carries both.
|
||||
|
||||
`fofos`, `production-pertento-bridge` and `deadpool-rezio-bridge` all reported `No changes detected` —
|
||||
a true no-op, which is the outcome the plan expected.
|
||||
|
||||
I judged this not to be a stop condition and continued. The reasoning, so it can be overruled: the plan's
|
||||
"stop and report" is aimed at the roles/FK change being half-applied, that change is provably whole on
|
||||
every host, and the residue is a pre-existing self-cancelling churn on plugin tables that this release
|
||||
does not touch. If that call was wrong, the thing to re-examine is `music_now_playing`'s composite
|
||||
primary key — it is the one still churning on the host where plugins are current.
|
||||
|
||||
---
|
||||
|
||||
## What I did not do, and could not verify
|
||||
|
||||
- **`officer-dev` — skipped entirely.** Not pulled, nothing restarted. It is at `6cd99ddb`, one commit
|
||||
*past* the tag; that commit landed on `master` after the tag was cut and is a docs commit. Its schema
|
||||
change was already applied and verified before this rollout, per the plan. It is the only host not at
|
||||
the tag exactly, and restarting it is the owner's deliberate last step.
|
||||
- **`edge-pertento-bridge` — skipped, not a failure.** Listed in *Deliberately skipped* since
|
||||
2026-08-23. Nothing was spent on it: no SSH attempt, no retry.
|
||||
- **The five-language picker was NOT checked in a browser.** The plan's "what to check" asks for the
|
||||
profile settings picker to offer five languages and for a *panel* to be inspected in a non-English
|
||||
locale. That is a UI check and I did not perform it. What I can say is narrower: every host serves the
|
||||
tag's bundle and every host restarted `officer` afterwards, so the fix is present in the code each
|
||||
host is running. Whether it renders is unconfirmed.
|
||||
- **Minesweeper's absence was not checked.** `/minesweeper` was not requested on any host, and the dock
|
||||
was not inspected.
|
||||
- **`macbookpro` reports no plugins installed** while `gen-plugin-schemas` finds three schema files on
|
||||
disk (`atelier`, `kiosk`, `music`). So that host has plugin *schemas* without registered plugins. This
|
||||
predates the release and nothing in the plan covers it, so I left it alone — but it explains why the
|
||||
atelier constraint churn cleared on `alpha` and not here, and it means `macbookpro`'s plugins are not
|
||||
being updated by the deploy at all.
|
||||
- **`plugins:update` restarted sidecars on four hosts before the plan's own restart step**, as
|
||||
`docs/servers.md` warns. Counted per host above rather than left as "restarted: officer".
|
||||
|
||||
## One note for `docs/servers.md`
|
||||
|
||||
The database URL key is **not the same on every host.** `alpha` has only `POSTGRES_URL`; `fofos`,
|
||||
`production-pertento-bridge`, `deadpool-rezio-bridge` and `macbookpro` have only
|
||||
`OFFICERDEV_POSTGRES_URL`. `drizzle.config.ts` reads `OFFICERDEV_POSTGRES_URL ?? POSTGRES_URL`, so both
|
||||
work for the app — but a rollout that queries the database directly must follow the same precedence or
|
||||
it verifies the wrong database and says "schema ✓" about it. This cost one failed step on `fofos` before
|
||||
being caught, and I re-checked `alpha` afterwards to confirm it holds only the one key and that its
|
||||
verification was therefore sound.
|
||||
|
||||
`macbookpro` also has no `timeout(1)`. A remote command wrapping anything in `timeout` fails there with
|
||||
`command not found` — and it fails *silently* in the sense that what you see is the next command's error,
|
||||
not the missing binary's.
|
||||
@@ -0,0 +1,146 @@
|
||||
# 2026.08.23.06
|
||||
|
||||
Swedish, a plugin's capabilities read where they live instead of being copied, dev tools as a property of a
|
||||
role, a commander as a directory rather than a session, and uploads that land atomically.
|
||||
|
||||
**There are TWO schema changes and `bun db:push` can apply NEITHER of them.** Both stop and ask a question
|
||||
no terminal is there to answer, and then exit 0. Read "The schema changes" before running anything.
|
||||
|
||||
**There is also a one-off cleanup on three hosts**, and skipping it means the agent reads one affordance
|
||||
twice. See "The orphan copies".
|
||||
|
||||
**No dependency change.** `bun.lock` is untouched. **Do not run `bun install`.**
|
||||
|
||||
## What is in it
|
||||
|
||||
- **Swedish.** `sv-SE`, complete across all three catalogues — six shipped locales now.
|
||||
- **Parity checks itself.** The three parity tests listed their locales by hand, so adding a sixth added a
|
||||
file none of them looked at; they read the directory now. They also check both directions — the old check
|
||||
only asked whether every English key was present, so a rename reported half of itself.
|
||||
- **A plugin's capabilities are LOADED, not copied.** Affordances, notifications and skills are read from
|
||||
each installed and enabled plugin's own directory. `capabilities/` is meant to be forked and was
|
||||
accumulating files nobody authored. A copy also went stale on update and survived a disable.
|
||||
- **Roles phase 3.** `rolePermitsDevTools()` was `role === 'Developer'` gating a Postgres role and a
|
||||
rootless Docker daemon. Two boolean columns carry it now, so a role created at runtime can hold dev tools.
|
||||
- **Installing a plugin grants it to every role.** Deny-by-default meant every install needed a second
|
||||
decision on another screen, which mostly got forgotten. The owner revokes instead. Rows, not a rule
|
||||
change — absence still denies.
|
||||
- **A commander is a directory, not a session.** Its own `$DATA_PATH/<email>/commanders/<id>/` with a
|
||||
`CLAUDE.md` written at creation. `commander-block.ts` is gone.
|
||||
- **Uploads land atomically** — written aside and renamed, so a watcher never sees a half-written file.
|
||||
Same for SendOff.
|
||||
- **Whisper priming prompts for 23 more languages.** Inert until an STT server is configured; none is.
|
||||
|
||||
## The schema changes
|
||||
|
||||
Two tables. **Run this SQL first**, then `bun db:push` to confirm it took.
|
||||
|
||||
**Why not `db:push`:** `roles` drops a column while adding two, and `command_hq_sessions` does the same
|
||||
shape — drizzle-kit cannot tell a drop-plus-add from a rename, so it asks. With no terminal it bails, the
|
||||
wrapper still exits 0, and the `postdb:push` seed then fails against an unchanged schema. Verified on
|
||||
`officer-dev` twice. See `src/databases/CLAUDE.md`.
|
||||
|
||||
Both blocks are idempotent and safe to run twice.
|
||||
|
||||
```sql
|
||||
-- ── roles: dev tools become a property of the role ──────────────────────────────────────────────
|
||||
DO $$
|
||||
DECLARE used int;
|
||||
BEGIN
|
||||
SELECT count(*) INTO used FROM roles WHERE provisioning_script IS NOT NULL;
|
||||
IF used > 0 THEN
|
||||
RAISE EXCEPTION 'STOP: % role(s) have a provisioning_script. Do not continue; report this.', used;
|
||||
END IF;
|
||||
EXCEPTION WHEN undefined_column THEN
|
||||
NULL; -- already dropped by a previous run
|
||||
END $$;
|
||||
|
||||
ALTER TABLE roles ADD COLUMN IF NOT EXISTS grants_postgres boolean NOT NULL DEFAULT false;
|
||||
ALTER TABLE roles ADD COLUMN IF NOT EXISTS grants_docker boolean NOT NULL DEFAULT false;
|
||||
ALTER TABLE roles DROP COLUMN IF EXISTS provisioning_script;
|
||||
|
||||
-- ── command_hq_sessions: the row owns its identity, the transcript id becomes a pointer ──────────
|
||||
-- Every existing commander is DELETED rather than migrated. This is deliberate and the owner's call:
|
||||
-- a commander's identity, directory and CLAUDE.md are all new, so migrating means writing a path that
|
||||
-- runs once and never again. Re-arm afterwards; it also exercises the flow this release exists to fix.
|
||||
DELETE FROM command_hq_sessions;
|
||||
|
||||
ALTER TABLE command_hq_sessions ALTER COLUMN session_id DROP NOT NULL;
|
||||
ALTER TABLE command_hq_sessions ADD COLUMN IF NOT EXISTS dir_name text NOT NULL DEFAULT '';
|
||||
ALTER TABLE command_hq_sessions ALTER COLUMN dir_name DROP DEFAULT;
|
||||
ALTER TABLE command_hq_sessions ADD COLUMN IF NOT EXISTS reference_paths text[] NOT NULL DEFAULT '{}';
|
||||
```
|
||||
|
||||
**Tell the owner what you deleted** before running it, and put it in the report — a commander is somebody's
|
||||
standing job, and "we removed yours" is not a footnote. On `officer-dev` it was one, `Platform Deployer`,
|
||||
already deleted there.
|
||||
|
||||
Then verify **by query**, not by an exit code:
|
||||
|
||||
```sql
|
||||
SELECT name, is_system, grants_postgres, grants_docker FROM roles ORDER BY name;
|
||||
|
||||
SELECT column_name, is_nullable FROM information_schema.columns
|
||||
WHERE table_name = 'command_hq_sessions' ORDER BY column_name;
|
||||
```
|
||||
|
||||
**`Developer` must have both flags true and the other three both false.** If Developer comes back false the
|
||||
seed did not run — fix it before restarting, or Developer accounts lose their Postgres role and Docker
|
||||
daemon at their next provisioning. `provisioning_script` must be absent.
|
||||
|
||||
`command_hq_sessions` must show `dir_name` and `reference_paths`, with `session_id` nullable.
|
||||
|
||||
## The orphan copies
|
||||
|
||||
**`alpha`, `fofos` and `production-pertento-bridge` each have
|
||||
`capabilities/affordances/officerdev/gitea/`** — published by the mechanism this release removes. The gitea
|
||||
plugin still ships that affordance in its own directory, and affordances are deliberately NOT
|
||||
de-duplicated, so after this release both load and the agent reads the same guidance twice.
|
||||
|
||||
```bash
|
||||
cd <the capabilities directory beside the checkout>
|
||||
git status --porcelain # expect only ?? extensions/ afterwards
|
||||
rm -rf affordances/officerdev
|
||||
```
|
||||
|
||||
Safe because it is a copy: the source is `plugins/gitea/affordances/`, which is what now gets read.
|
||||
`deadpool-rezio-bridge` has none; check anyway.
|
||||
|
||||
**`extensions/` stays.** The platform's own bootstrap writes it every boot. This release gitignores it, and
|
||||
the ignore arrives with the capabilities pull in step 4.
|
||||
|
||||
## Every host
|
||||
|
||||
1. `git fetch origin --tags` then `git merge --ff-only deploy/2026.08.23.06`
|
||||
2. **The SQL above**, both blocks, then the two verification queries. Stop on the `RAISE EXCEPTION`.
|
||||
3. `bun db:push` — expect **no changes and no prompt**. If it asks anything, the SQL did not take.
|
||||
4. `bun run plugins:update` — this pulls the capabilities repository too, which is how the new
|
||||
`.gitignore` arrives.
|
||||
5. **The orphan removal**, on the three hosts that have one.
|
||||
6. `pm2 restart officer`
|
||||
|
||||
`officer` only at step 6. Step 4 restarts each plugin's own sidecar, which belongs in the report.
|
||||
|
||||
**`macbookpro`:** append `/opt/homebrew/bin` to PATH for the `bun` commands, run `pm2` separately. It has
|
||||
`psql` now and a `.zshenv` that puts it on a non-interactive PATH, so the SQL can be run there directly.
|
||||
|
||||
## What to check, this release
|
||||
|
||||
**Open a chat and ask the agent what skills it has.** The preamble is built from a new code path —
|
||||
`capability-sources.ts` resolving roots per session — and the failure mode is an agent that quietly knows
|
||||
less rather than an error. It should name the commander, deploy and platform skills, plus anything a plugin
|
||||
ships on that host. On a host with the gitea plugin, its affordance should appear **once**.
|
||||
|
||||
**The language picker should offer six.** Switch to Swedish and look at a *panel*, not just the shell.
|
||||
|
||||
**Creating a commander should produce a directory.** `$DATA_PATH/<email>/commanders/<n>/` holding
|
||||
`CLAUDE.md` and `state/`. The setup pane asks for a *reference path* now, not a working directory.
|
||||
|
||||
No direct SQL beyond the block above is needed, and none is authorised.
|
||||
|
||||
## Hosts
|
||||
|
||||
- **`edge-pertento-bridge`** — skipped. Down, per `docs/servers.md`.
|
||||
- **`officer-dev`** — skip it. Both schema changes are already applied and verified by query there, its
|
||||
capabilities repository is clean, and restarting `officer` on the host running the rollout ends the
|
||||
rollout. Name it as skipped and say it is current.
|
||||
@@ -0,0 +1,131 @@
|
||||
# 2026.08.23.06 — deploy report
|
||||
|
||||
**5 of 5 target hosts succeeded.** Two hosts skipped by design: `officer-dev` (the host running the
|
||||
rollout) and `edge-pertento-bridge` (listed under *Deliberately skipped* in `docs/servers.md`).
|
||||
|
||||
Every host went `e15ab230` → `2646842c` by `git merge --ff-only deploy/2026.08.23.06`. No host was
|
||||
ahead, none had diverged, and no tracked file was dirty anywhere. `--force` was never used and no SQL
|
||||
beyond the plan's block was run.
|
||||
|
||||
## Per host
|
||||
|
||||
### `alpha` — ✅
|
||||
|
||||
- HEAD `e15ab230` → `2646842c`, remote SSH, tree clean, no untracked files.
|
||||
- **SQL:** both blocks applied. `RAISE EXCEPTION` did not fire — no role held a `provisioning_script`.
|
||||
`DELETE FROM command_hq_sessions` removed **0 rows**; the table was already empty.
|
||||
- **Verified by query** on `officer_dev`: `Developer` `grants_postgres=t grants_docker=t`; `Admin`,
|
||||
`Member`, `Super Admin` all `f/f`; `provisioning_script` absent (0 columns); `dir_name` and
|
||||
`reference_paths` present `NOT NULL`, `session_id` nullable.
|
||||
- `bun db:push` exit 0, no prompt. Seed reported `0 seeded, 4 already present` — the Developer flags were
|
||||
written by the seed's re-assert path, confirmed by re-querying after the push, not by the message.
|
||||
- `bun run plugins:update` — **13/13 in 54s**, exit 0, `capabilities: pulled`. Every plugin's sidecar
|
||||
restarted as part of this step: atelier, bitcoin-wallet, email, gitea, invoices, jellyfin, minesweeper,
|
||||
music, offscale, photos, soulseek, transmission, vault.
|
||||
- **Orphan removed:** `capabilities/affordances/officerdev/` existed and was deleted. Capabilities tree
|
||||
clean afterwards — no leftover `?? extensions/`, so the new `.gitignore` had already arrived.
|
||||
- `pm2 restart officer` — restart_time 72 → 73. **All 20 processes `online`.**
|
||||
|
||||
### `fofos` — ✅
|
||||
|
||||
- HEAD `e15ab230` → `2646842c`, remote HTTPS, no tracked changes.
|
||||
- **Untracked file left in place:** `scripts/chk.ts`. Noted, not a reason to skip, not touched.
|
||||
- **Database is `officer`, not `officer_dev`, and the URL is in `OFFICERDEV_POSTGRES_URL`** rather than
|
||||
`POSTGRES_URL`. Matched the code's own precedence (`OFFICERDEV_POSTGRES_URL ?? POSTGRES_URL`,
|
||||
`officer_db/src/db.ts:9`) so the migration went to the database the server actually opens.
|
||||
- **SQL:** both blocks applied, exception did not fire, `DELETE` removed **0 rows**.
|
||||
- **Verified by query:** `Developer` `t/t`, other three `f/f`, `provisioning_script` absent, `dir_name`
|
||||
and `reference_paths` `NOT NULL`, `session_id` nullable.
|
||||
- `bun db:push` exit 0, no prompt, no plugin schemas on disk.
|
||||
- `bun run plugins:update` — **1/1 in 7s**, exit 0, `capabilities: pulled`. `officer-gitea` sidecar
|
||||
restarted by this step.
|
||||
- **Orphan removed:** `capabilities/affordances/officerdev/` deleted; tree clean afterwards.
|
||||
- `pm2 restart officer` — 25 → 26. **All 8 processes `online`.**
|
||||
|
||||
### `macbookpro` — ✅ (awake, with two deviations worth recording)
|
||||
|
||||
- HEAD `e15ab230` → `2646842c`, remote SSH, tree clean, no untracked files.
|
||||
- **SQL:** both blocks applied. `psql` is at `/opt/homebrew/opt/libpq/bin/psql` and on the
|
||||
non-interactive PATH, as the plan said. `DELETE` removed **0 rows**.
|
||||
- **Verified by query:** `Developer` `t/t`, other three `f/f`, `provisioning_script` absent, `dir_name`
|
||||
and `reference_paths` `NOT NULL`, `session_id` nullable.
|
||||
- `bun db:push` exit 0, no prompt, run with `/opt/homebrew/bin` **appended** to PATH.
|
||||
- `bun run plugins:update` — exit 0, but **`no plugins installed — nothing to update`**, so the step
|
||||
exited before its capabilities pull. See *What I could not do* below.
|
||||
- **No orphan to remove.** `~/projects/officer-suite/capabilities` is **not a git checkout** — it holds
|
||||
only empty `agents/ extensions/ processes/ skills/ tasks/ tools/` and has no `affordances/` at all.
|
||||
- `pm2 restart officer` (run as its own command, PATH untouched) — 12 → 13. **All 6 processes `online`.**
|
||||
|
||||
### `production-pertento-bridge` — ✅
|
||||
|
||||
- HEAD `e15ab230` → `2646842c`, remote HTTPS, tree clean, no untracked files.
|
||||
- **SQL:** both blocks applied, exception did not fire, `DELETE` removed **0 rows**.
|
||||
- **Verified by query** on `officer_dev`: `Developer` `t/t`, other three `f/f`, `provisioning_script`
|
||||
absent, `dir_name` and `reference_paths` `NOT NULL`, `session_id` nullable.
|
||||
- `bun db:push` exit 0, no prompt. Its shell prints an nvm/`.npmrc` `globalconfig` warning before every
|
||||
command — cosmetic, pre-existing, unrelated to this release.
|
||||
- `bun run plugins:update` — **1/1 in 5s**, exit 0, `capabilities: pulled`. `officer-gitea` restarted.
|
||||
- **Orphan removed:** `capabilities/affordances/officerdev/` deleted; tree clean afterwards.
|
||||
- `pm2 restart officer` — 4 → 5. **All 15 processes `online`** (nine of them are this host's non-Officer
|
||||
Pertento jobs — Crons, Dashboard, Tracking, Uptime — none of which were touched).
|
||||
|
||||
### `deadpool-rezio-bridge` — ✅ (login `br`)
|
||||
|
||||
- HEAD `e15ab230` → `2646842c`, remote HTTPS, tree clean, no untracked files.
|
||||
- Database is `officer` via `OFFICERDEV_POSTGRES_URL`, same shape as `fofos`.
|
||||
- **SQL:** both blocks applied, exception did not fire, `DELETE` removed **0 rows**.
|
||||
- **Verified by query:** `Developer` `t/t`, other three `f/f`, `provisioning_script` absent, `dir_name`
|
||||
and `reference_paths` `NOT NULL`, `session_id` nullable.
|
||||
- `bun db:push` exit 0, no prompt, 2 plugin schemas (email, offscale).
|
||||
- `bun run plugins:update` — **2/2 in 8s**, exit 0, `capabilities: pulled`. The **email plugin advanced
|
||||
`c60e3d6` → `c1c3674`** — the only plugin anywhere in this rollout that was not already current.
|
||||
`officer-email` and `officer-offscale` sidecars restarted by this step.
|
||||
- **No orphan**, as the plan predicted; checked anyway and `affordances/officerdev` does not exist.
|
||||
- `pm2 restart officer` — 10 → 11. **All 9 processes `online`.**
|
||||
|
||||
## Skipped
|
||||
|
||||
- **`officer-dev`** — the host this rollout ran from. Nothing pulled, nothing restarted. Confirmed
|
||||
current rather than assumed: HEAD is `2646842c`, the tag's commit, and a direct query shows `Developer`
|
||||
`t/t` with the other three `f/f`, `provisioning_script` absent, `dir_name`/`reference_paths` present and
|
||||
`session_id` nullable. Its own `officer` process is untouched and still runs the pre-restart build of
|
||||
this commit — **the owner restarts it deliberately, as the last step.**
|
||||
- **`edge-pertento-bridge`** — in *Deliberately skipped* since 2026-08-23. Not contacted, no retry budget
|
||||
spent.
|
||||
|
||||
## Commanders deleted
|
||||
|
||||
**None, on any host.** `command_hq_sessions` held **0 rows everywhere** — every `DELETE` reported
|
||||
`DELETE 0`. Each host was counted by query *before* the delete ran, so this is measured rather than
|
||||
inferred. Nobody's standing job was removed by this release.
|
||||
|
||||
`officer-dev`'s one commander, `Platform Deployer`, had already been deleted there before the rollout
|
||||
began, per the plan.
|
||||
|
||||
## What I could not do
|
||||
|
||||
- **`macbookpro` did not get the capabilities pull.** `bun run plugins:update` exits early with `no
|
||||
plugins installed` and never reaches the capabilities repository, which the plan named as how the new
|
||||
`.gitignore` arrives. It does not matter on that host — its capabilities directory is not a git
|
||||
checkout, so there is no repository for a `.gitignore` to land in and no `affordances/` to be
|
||||
duplicated. I did not invent a `git pull` the plan had not authorised. **Nothing is outstanding on that
|
||||
host; the note is about the step's shape, not a missing change.**
|
||||
- **The release's own "what to check" list is unrun.** Asking an agent to name its skills, switching a
|
||||
panel to Swedish, and creating a commander to confirm it produces a directory are interactive checks in
|
||||
a browser. This deploy agent cannot make them, so **no host has been confirmed working by use** — only
|
||||
by schema query and process state.
|
||||
- **`officer-dev` needs `pm2 restart officer`** to run the code it already has checked out.
|
||||
|
||||
## Two things worth carrying forward
|
||||
|
||||
**`fofos` and `deadpool-rezio-bridge` name their database differently from the other three** — the URL
|
||||
lives in `OFFICERDEV_POSTGRES_URL` and the database is `officer`, not `officer_dev`. Any future plan step
|
||||
that reaches for `POSTGRES_URL` alone will connect to a socket that is not there, which is what it did
|
||||
here on the first attempt. The code's precedence is `OFFICERDEV_POSTGRES_URL ?? POSTGRES_URL`; follow it
|
||||
rather than either name.
|
||||
|
||||
**The seed's own output does not prove the Developer flags were written.** It prints `0 seeded, 4 already
|
||||
present` on every host, because it counts inserts and the roles already existed — while the flags are
|
||||
re-asserted by a separate update path. On all five hosts the flags read `f/f` immediately after the SQL
|
||||
and `t/t` only after `db:push`. Nothing was wrong; the message is simply not the evidence, and the query
|
||||
after the push is.
|
||||
@@ -0,0 +1,43 @@
|
||||
# 2026.08.24.01
|
||||
|
||||
One frontend file: the transcribe button is hidden in the file viewer's audio player.
|
||||
|
||||
**No schema change.** Nothing under `src/databases/**/schema.ts` moved. `bun db:push` is still a step
|
||||
because it is cheap and it proves the database agrees with the code, but it must report no changes.
|
||||
|
||||
**No dependency change.** `bun.lock` is untouched. **Do not run `bun install`.**
|
||||
|
||||
## What is in it
|
||||
|
||||
- **The transcribe button is hidden.** It calls `POST /file-browser/transcribe`, which refuses on every
|
||||
server here because no STT endpoint is configured anywhere — a control that can only fail, sitting beside
|
||||
OCR and extract-audio, which work. Hidden rather than deleted: speech-to-text is moving to a plugin and
|
||||
the wiring stays correct for when it lands.
|
||||
|
||||
## Every host
|
||||
|
||||
1. `git fetch origin --tags` then `git merge --ff-only deploy/2026.08.24.01`
|
||||
2. `bun db:push` — expect **no changes**. If it wants to alter something, stop and report it: this release
|
||||
touched no schema, so a diff means that host and this tree disagree about something else.
|
||||
3. `bun run plugins:update`
|
||||
4. `pm2 restart officer`
|
||||
|
||||
`officer` only at step 4. Step 3 restarts each plugin's own sidecar, which belongs in the report.
|
||||
|
||||
**`macbookpro`:** append `/opt/homebrew/bin` to PATH for the `bun` commands, run `pm2` separately.
|
||||
|
||||
## What to check, this release
|
||||
|
||||
Open a `.m4a` in the file browser. The audio player's header should show a download and a close icon and
|
||||
**no transcribe icon**. An image should still offer OCR and a video should still offer extract audio — if
|
||||
either of those disappeared, too much was hidden.
|
||||
|
||||
A stale bundle is the likely failure. Hard refresh before deciding it did not work.
|
||||
|
||||
No direct SQL is needed and none is authorised.
|
||||
|
||||
## Hosts
|
||||
|
||||
- **`edge-pertento-bridge`** — skipped, per `docs/servers.md`.
|
||||
- **`officer-dev`** — skip it. Restarting `officer` on the host running the rollout ends the rollout. Name
|
||||
it as skipped and say whether it is current.
|
||||
@@ -3,6 +3,17 @@
|
||||
Spec conversation 2026-08-18, owner + agent, back and forth. This is the write-up asked for partway through
|
||||
it, kept current as answers landed.
|
||||
|
||||
**Superseded in part, 2026-08-24, by `docs/commander-directory.md`.** A commander's identity moved from
|
||||
Claude's transcript id to its own `command_hq_sessions` row, and it gained its own directory
|
||||
(`$DATA_PATH/<email>/commanders/<dirName>/`, `CLAUDE.md` + `state/`) created BEFORE any turn spawns. That
|
||||
makes several sections below describe the PREVIOUS mechanism rather than the current one — notably
|
||||
"How a commander is created" (creation is no longer session-first), "Working directory" (a commander no
|
||||
longer shares one directory per user, nor chooses an arbitrary one — it has its own), and the `POST
|
||||
/commanders` / `POST /commanders/new` rows in "API reference" (both now require `name` and `referencePath`
|
||||
and no longer accept an arbitrary `cwd`). This file was not rewritten to match — read
|
||||
`docs/commander-directory.md` first for anything those sections describe, and treat the code
|
||||
(`src/servers/api/command-hq/`) as the source of truth where the two disagree.
|
||||
|
||||
**Status:** data model, the full REST API including both creation paths (web and `POST
|
||||
/commanders/new`), built and mounted 2026-08-18 (`bunx tsgo` clean, suite at its 7 pre-existing baseline
|
||||
failures — none of them this feature's). `bun db:push` and a restart of `officer` have both since run
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
# A commander is a directory, not a session
|
||||
|
||||
**Status: phase 1 built 2026-08-24** — schema, directory creation, `CLAUDE.md` at creation, the inverted
|
||||
`POST /commanders` → start-turn → point-at-transcript order, `resolveChatCwd` resolving server-side, and the
|
||||
frontend composer split (`NewCommanderSetup` → `ActiveNewCommander`) in `src/servers/api/command-hq/` and
|
||||
`src/workspaces/officerdev/src/apps/CommandHQ/CommandHqDetail.tsx`. `bunx tsgo`/`./node_modules/.bin/tsgo`
|
||||
clean, suite green. **`bun db:push` has not been run** — the owner reviews and runs the database work
|
||||
separately; see the schema section below for what changed and why nothing here is a migration. Phase 2
|
||||
(a commander becomes a git repository) is still not built.
|
||||
|
||||
## What is wrong now
|
||||
|
||||
A commander's identity is borrowed. `command_hq_sessions.session_id` is Claude's **transcript id**, minted
|
||||
by the CLI as the first turn begins — verified: the id on the live row has a transcript file under
|
||||
`~/.claude/projects/`. Everything about a commander hangs off that one borrowed value, and three problems
|
||||
fall out of it.
|
||||
|
||||
**An ordering problem.** `POST /command-hq/commanders` takes a `sessionId` the caller already holds and
|
||||
validates the transcript exists, so a chat session is created first and filed as a commander second. But
|
||||
the working directory is fixed when the session spawns — before the id exists. There is no point in the
|
||||
current flow where a commander can be started *in a directory named after itself*.
|
||||
|
||||
**A first-turn interlock that is a side effect.** `commanderBlock` compiles the paths a commander must
|
||||
record itself at into every turn, and on the first turn they cannot be resolved, so the block is empty.
|
||||
The commander skill turns that into a rule: *"if you have not been given paths, you are on your first
|
||||
turn."* It works, and it is a signal derived from a failure rather than something anybody said. That
|
||||
block's own comment explains the awkwardness — it rides `effectivePrompt` rather than the session
|
||||
preamble, because the preamble is built once and the first turn has no id to build it from.
|
||||
|
||||
**Losing the conversation loses the commander.** State lives in the transcript and in a second tree,
|
||||
`commander-state/<transcript id>/`. A session that dies takes its commander with it.
|
||||
|
||||
## The design
|
||||
|
||||
**The directory IS the commander. The session is how you talk to it.**
|
||||
|
||||
```
|
||||
$DATA_PATH/<email>/commanders/<commander id>/
|
||||
CLAUDE.md written at creation, extended by the commander as it learns the job
|
||||
spawned session ids of the agents its loop started
|
||||
<whatever it writes> scripts, procedures, its record of what it has handled
|
||||
```
|
||||
|
||||
The id is one the **platform** owns, generated when the commander is created. Nothing waits on the CLI.
|
||||
|
||||
The flow becomes linear:
|
||||
|
||||
1. The user gives a name and a reference path, and clicks Start.
|
||||
2. The platform creates the row and the directory.
|
||||
3. It writes `CLAUDE.md` into the directory: the name, the reference path, and the standing guidance
|
||||
`commanderBlock` currently injects.
|
||||
4. It spawns the session with `cwd` set to that directory.
|
||||
5. The transcript id arrives later and is recorded **on the row**, as a pointer to the current
|
||||
conversation.
|
||||
|
||||
**The API does not change.** Same endpoints, same shape from the client's side. What changes is the server
|
||||
being able to do things in an order that works.
|
||||
|
||||
## What this buys
|
||||
|
||||
**`CLAUDE.md` needs no mechanism.** Claude Code reads one from the working directory at session start. So
|
||||
the living document is free: the commander edits its own file, and the next turn has it. No injection, no
|
||||
per-turn cost, no block riding `effectivePrompt`.
|
||||
|
||||
**A commander survives its conversation.** Everything it knows is on disk. A session that dies can be
|
||||
replaced by a fresh one in the same directory, and the commander carries on with its scripts, its
|
||||
procedures and its record of what it has already handled. Today that is a total loss.
|
||||
|
||||
**One directory instead of two.** `commander-state/<id>/` collapses in. Deleting a commander becomes
|
||||
deleting a directory, which is also the only way to be sure nothing is left behind.
|
||||
|
||||
**The first turn can be stated rather than inferred.** With the file written before the session exists,
|
||||
the platform can simply say which turn it is on. The interlock stops being a trick.
|
||||
|
||||
## The reference path
|
||||
|
||||
**What was the working directory becomes a reference** — what the commander operates ON, not where it
|
||||
lives. One is collected in the start dialog, because a person can name one thing and asking for a list up
|
||||
front gets a worse answer than asking later.
|
||||
|
||||
**More can be added in conversation.** A voice-memos commander watches one folder; a life manager might
|
||||
watch memos, scans and a mailbox. So this is a list on the row, seeded with one, and the commander writes
|
||||
additions into its own `CLAUDE.md` as they are agreed.
|
||||
|
||||
## The two tiers, and why a commander should get cheaper
|
||||
|
||||
This is the point of the whole design, and it generalises past any particular commander.
|
||||
|
||||
**The script gathers. The agent judges.**
|
||||
|
||||
A commander has two kinds of work and they have opposite economics. A script runs per event — every email,
|
||||
every voice memo — and costs nothing, so there is no ceiling on volume. An agent runs per batch, on
|
||||
material a script already assembled, and costs a turn every time it wakes. So the rule for building any
|
||||
commander is: push as much as possible down into the script, and let the agent see only what actually
|
||||
needs judgement.
|
||||
|
||||
Detecting a one-time code in an email is a script. Reading fifty messages and deciding which of them the
|
||||
owner would want to know about is an agent. A watcher that transcribes a recording is a script; deciding
|
||||
what a week of transcripts means is an agent.
|
||||
|
||||
**And the tiers are not fixed, which is the part that matters.**
|
||||
|
||||
Because the commander owns its own scripts — they are files in its directory, and it can edit them — every
|
||||
correction moves work downward. The owner says "this one was missed, it had eight digits rather than six",
|
||||
and a decision that required judgement becomes a rule that does not. The agent tier gets thinner; the free
|
||||
tier gets better.
|
||||
|
||||
**A commander that runs for six months should cost less per event than it did in week one, not more.**
|
||||
|
||||
That is the opposite of how automation usually ages, and it is a direct consequence of two decisions made
|
||||
here rather than a hope: the agent owns its scripts, and the corrections arrive through dialogue rather
|
||||
than through a ticket to somebody else. Nothing improves a system it cannot edit.
|
||||
|
||||
Two things this requires, and both are cheap if done from the start:
|
||||
|
||||
- **The script's verdict must be reproducible.** Not logged — RE-RUN. The input is still in the mailbox or
|
||||
the folder, and the script is deterministic, so diagnosing a miss is running it again and watching. A
|
||||
log would be a second copy of something already on disk.
|
||||
- **A change is checked against what came before.** The corpus already exists — the last few hundred
|
||||
messages, the transcripts so far. Re-run the old script and the new one over it and compare; anything
|
||||
whose verdict changed is either the bug just fixed or one just introduced. Without that, the fix for
|
||||
eight-digit codes quietly starts flagging every order confirmation, unattended, at three in the morning.
|
||||
|
||||
`capabilities/skills/commander/SKILL.md` should teach this. Somebody building the fourth commander needs
|
||||
the rule more than they need the worked example.
|
||||
|
||||
## What the directory holds: definition versus state
|
||||
|
||||
Two kinds of thing live in there and they must not mix, because one of them travels and the other must
|
||||
never.
|
||||
|
||||
```
|
||||
$DATA_PATH/<email>/commanders/<commander id>/
|
||||
CLAUDE.md DEFINITION — the living document, extended as the job is agreed
|
||||
parameters.* DEFINITION — the things a fork has to change. Paths, cadences, thresholds
|
||||
procedures/ DEFINITION — what a spawned agent reads at fire time
|
||||
scripts/ DEFINITION — the loops
|
||||
state/ INSTANCE — what it has handled, what it spawned, its loop's pid
|
||||
```
|
||||
|
||||
**Separate from the first commit, not sorted out later.** A shareable bundle is "everything except
|
||||
`state/`" only if that boundary is structural; mixed together, extracting one means picking files apart by
|
||||
hand and getting it wrong once. Same reasoning as plugin capabilities being read in place rather than
|
||||
copied — a directory boundary beats a convention, because a convention is what a tired person violates.
|
||||
|
||||
The failure it prevents is specific: a recipient's commander starting up believing it has already handled
|
||||
your recordings, and silently doing nothing.
|
||||
|
||||
**Parameters are separate from procedure, or forking means editing code.** With `~/Documents/VoiceMemos`
|
||||
baked into a script, a fork is a debugging session. Declared in one place, a fork is a conversation — the
|
||||
new owner is asked where their memos land and the commander rewrites its own file. That difference is the
|
||||
whole point of the thing being an agent rather than a template.
|
||||
|
||||
## Phase 2 — a commander becomes a git repository
|
||||
|
||||
**Not now.** Recorded because the phase 1 layout has to leave room for it, and it costs nothing today to
|
||||
leave that room.
|
||||
|
||||
**There is no button.** The owner asks the commander, in conversation, to make itself shareable, and it
|
||||
does — `git init`, a `.gitignore` that excludes `state/`, a first commit. That is the same shape as
|
||||
everything else here: the commander maintains its own directory, and the platform does not reach in.
|
||||
|
||||
**What it needs to know goes in the commander SKILL, not an affordance.** The layout, what is tracked,
|
||||
what is ignored and how credentials are handled is reference material — read on the one day somebody asks
|
||||
for it. An affordance's body is charged to every turn of every commander forever, and this would earn that
|
||||
cost approximately never. `capabilities/skills/commander/SKILL.md` gains a section.
|
||||
|
||||
**A commander never holds a service's connection details, and that is a boundary rather than a
|
||||
precaution.** The Whisper URL is not the commander's business: it knows the platform can transcribe and
|
||||
asks the platform, which holds the connection. So there is usually nothing sensitive in a commander to
|
||||
leak, and the sharing story is simple for a reason rather than by luck.
|
||||
|
||||
Where a commander genuinely does need a secret of its own, the same rule applies for the obvious reason: a
|
||||
credential is definition-shaped — it looks like configuration — so excluding `state/` does not protect it,
|
||||
and a shared repository is exactly the artefact where one gets published and stays published. Name what
|
||||
must be configured; let the platform hold the value.
|
||||
|
||||
**Whether the platform is actually configured is not this system's problem.** A commander depending on
|
||||
transcription can be armed on a server where nobody set up a Whisper endpoint, and it would produce
|
||||
nothing. That is a system administration mistake, and the answer is already in the commander skill rather
|
||||
than in new machinery: **do the first run by hand, then arm.** A first run against something unconfigured
|
||||
fails in front of the person doing it. The same rule catches the shared case — somebody forking this onto
|
||||
their own machine finds out on their first run instead of at 3am.
|
||||
|
||||
The forking story then reads: clone, open a conversation, answer where your memos land and what you want
|
||||
done with them, and the commander rewrites its own parameters and arms.
|
||||
|
||||
## The consequence for the skill
|
||||
|
||||
`capabilities/skills/commander/SKILL.md` says turn one is orientation — *"read where you actually are
|
||||
before you ask anything"* — and that assumes `cwd` is the thing worth exploring. Under this design the
|
||||
commander's own directory is nearly empty on turn one and the interesting thing is somewhere else.
|
||||
|
||||
That guidance has to change with the code, or every new commander will orient on an empty folder and ask
|
||||
a worse question than it does today.
|
||||
|
||||
## The schema change, and why there is no migration
|
||||
|
||||
`session_id` stops being the commander's identity and becomes a nullable pointer to its current
|
||||
conversation. The row gains its own id, its directory, and its reference paths.
|
||||
|
||||
**Nothing is migrated.** The one commander on this machine is deleted and re-armed from scratch on the new
|
||||
system — the owner's call, made on 2026-08-24, and the right one: it is unarmed and has never fired, so
|
||||
migrating it would mean writing and testing a path that runs exactly once and then never again. Re-arming
|
||||
also exercises the flow this design exists to fix, which a migration would route around.
|
||||
|
||||
That decision has a shelf life. It is free while one commander exists and unarmed; it stops being free the
|
||||
moment somebody is relying on one. Build this before that happens, or write the migration.
|
||||
|
||||
## What this does not decide
|
||||
|
||||
**Whether a commander's loops are scripted or agentic.** Both, in practice — a watcher that transcribes
|
||||
does not need judgement and should not spend a turn per file, while a job that reads transcripts and
|
||||
decides what to do with each of them is agentic by nature. The directory holds the procedure either way.
|
||||
|
||||
**How a commander learns what the platform can do.** It reads the generated platform skill and, for a
|
||||
plugin, that plugin's own skill. That is a separate gap: a plugin must ship a skill documenting its routes
|
||||
or a commander can see it is installed and still not know how to call it.
|
||||
@@ -0,0 +1,137 @@
|
||||
# Filesystem quotas — the prerequisite for per-user and per-team storage limits
|
||||
|
||||
**Status: planned, not done. Nothing in the platform reads or writes a quota yet.**
|
||||
|
||||
This is the host-side half. It makes limits *enforceable*; it does not create any. The platform side —
|
||||
the owner declaring defaults and maxima, and provisioning applying them — is `docs/roles-and-teams.md`
|
||||
→ "Quotas", and it is not built either. Do this first, because a policy nothing enforces is worse than
|
||||
no policy: it reads as a limit and is not one.
|
||||
|
||||
## Why the kernel and not the platform
|
||||
|
||||
Members hold `terminal` at `write` and have their own Linux accounts. A quota the platform tracks by
|
||||
counting bytes through the file browser is decorative — a member opens a terminal, or connects over SSH
|
||||
with their own key, and writes past it without the platform seeing the write.
|
||||
|
||||
Only the filesystem can refuse. So this is a provisioning feature, not an accounting one.
|
||||
|
||||
## Where it matters
|
||||
|
||||
**`edge-pertento`.** It is the first real multi-user deployment and the only host that will hold accounts
|
||||
that are not the owner's. It already has one account, which is the argument for doing this soon rather
|
||||
than carefully later: moving a home directory is easy once and tedious per person.
|
||||
|
||||
`officer-dev` is a development machine and will not be multi-user. `alpha` is the owner's own. Neither
|
||||
needs this, and neither can do it without the same downtime, so there is no reason to.
|
||||
|
||||
## What to check BEFORE booting into rescue
|
||||
|
||||
Run these on the live machine. They decide whether the procedure below is the right one.
|
||||
|
||||
```bash
|
||||
findmnt -no FSTYPE,SOURCE,TARGET / # expect ext4, and note the real device
|
||||
findmnt -no OPTIONS / | tr ',' '\n' | grep -i quota # expect nothing — that is why we are here
|
||||
sudo tune2fs -l "$(findmnt -no SOURCE /)" | grep -iE 'features|Inode size'
|
||||
lsblk -o NAME,SIZE,FSTYPE,MOUNTPOINT
|
||||
df -h /home
|
||||
```
|
||||
|
||||
Three things decide the plan:
|
||||
|
||||
1. **Is `/home` its own filesystem, or part of `/`?** On a stock Hetzner image it is part of `/`, so the
|
||||
root filesystem is what gains the feature.
|
||||
2. **Inode size.** Only matters if you use project quotas — they need 256 bytes and cannot be enabled on a
|
||||
128-byte filesystem without recreating it. The plan below avoids project quotas for exactly this reason.
|
||||
3. **Is the device under LVM?** `alpha` is; `officer-dev` is not. It changes only the device path.
|
||||
|
||||
## The plan: user quotas and group quotas, not project quotas
|
||||
|
||||
Two mount options cover both cases and neither needs a feature the filesystem might lack:
|
||||
|
||||
- **`usrquota`** — a limit per Linux user. This is the per-account home directory limit.
|
||||
- **`grpquota`** — a limit per Linux group. **This is how a team's shared folder gets a limit**, because a
|
||||
team is already a Linux group (see `docs/roles-and-teams.md` — the group is what keeps one team out of
|
||||
another's folder, not the permission grant).
|
||||
|
||||
Project quotas (`prjquota`) would let a *directory* carry a limit independent of ownership. That is more
|
||||
flexible and it is not needed: every case here is "this user" or "this team", both of which are already
|
||||
identities the kernel knows. Skipping it also skips the inode-size problem entirely.
|
||||
|
||||
**For the group quota to work, the team folder must be `setgid`** (`chmod g+s`), so files created inside it
|
||||
inherit the team's group and are charged to it. Without that, a member's files are charged to the member's
|
||||
own primary group and the team limit counts nothing. This is the single easiest thing to get wrong here.
|
||||
|
||||
## Procedure
|
||||
|
||||
Downtime is roughly ten minutes, most of it reboots. The owner has said downtime is acceptable.
|
||||
|
||||
**1. Install the tools, on the live machine.**
|
||||
|
||||
```bash
|
||||
sudo apt-get update && sudo apt-get install -y quota
|
||||
```
|
||||
|
||||
**2. Take note of the root device**, from the pre-flight above — `/dev/sda1` on a stock image,
|
||||
`/dev/mapper/<vg>-<lv>` under LVM. Every command below uses it.
|
||||
|
||||
**3. Boot into Hetzner rescue** from the Cloud console, and do not mount the filesystem.
|
||||
|
||||
**4. Check it, then add the feature.** `tune2fs` refuses on a filesystem that is not clean, and the check
|
||||
is worth running anyway before changing metadata:
|
||||
|
||||
```bash
|
||||
e2fsck -f /dev/sda1
|
||||
tune2fs -O quota /dev/sda1
|
||||
tune2fs -l /dev/sda1 | grep -i features # 'quota' must now appear
|
||||
```
|
||||
|
||||
`quota` here is the ext4 *journaled quota* feature. It is what lets quotas come up with the filesystem
|
||||
rather than needing a `quotacheck` scan at every boot, which on 320 GB is not quick.
|
||||
|
||||
**5. Add the mount options.** Back on the normal system — or by mounting the root filesystem from rescue
|
||||
and editing it there — the `/` line in `/etc/fstab` gains `usrquota,grpquota`:
|
||||
|
||||
```
|
||||
UUID=<unchanged> / ext4 defaults,usrquota,grpquota 0 1
|
||||
```
|
||||
|
||||
**6. Reboot into the normal system and confirm.**
|
||||
|
||||
```bash
|
||||
findmnt -no OPTIONS / | tr ',' '\n' | grep quota # usrquota, grpquota
|
||||
sudo quotaon -p / # both should report "is on"
|
||||
sudo repquota -s / # a table, not an error
|
||||
```
|
||||
|
||||
## Verify it actually refuses
|
||||
|
||||
Enabling quotas and setting none is the failure that looks like success. Prove enforcement once, on a
|
||||
throwaway account, before trusting it:
|
||||
|
||||
```bash
|
||||
sudo useradd -m quotaprobe
|
||||
sudo setquota -u quotaprobe 10240 10240 0 0 / # 10 MB soft and hard
|
||||
sudo -u quotaprobe dd if=/dev/zero of=/home/quotaprobe/fill bs=1M count=50
|
||||
# expect: "Disk quota exceeded" and a file of about 10 MB, NOT 50
|
||||
sudo userdel -r quotaprobe
|
||||
```
|
||||
|
||||
If that writes 50 MB, quotas are not on, whatever the mount options say.
|
||||
|
||||
## What this does not do
|
||||
|
||||
- **It sets no limits.** Every existing account is unlimited until something assigns a quota. That is the
|
||||
correct intermediate state: the machine gains the ability, nobody's behaviour changes.
|
||||
- **It does not touch the platform.** `provision-os.ts` does not call `setquota` yet, and there is nowhere
|
||||
for the owner to declare a default. That is the other half.
|
||||
- **It does not apply to the team folders**, because there are no team folders yet. When they arrive they
|
||||
must be `setgid` or their group quota counts nothing.
|
||||
|
||||
## When the platform half is built
|
||||
|
||||
`POST /users/:id/provision-linux` already exists as a retry path and is the natural home for this: it
|
||||
becomes "re-apply current policy to this account", covering both a new account and a changed limit.
|
||||
|
||||
**On a host where quotas are not enabled, the platform must say so** — "policy recorded, not enforceable on
|
||||
this host" — rather than storing numbers that do nothing. Same rule as the plugin dependency check: refuse
|
||||
or report, never silently no-op. `quotaon -p` is the check, and it is cheap enough to run per request.
|
||||
@@ -271,6 +271,20 @@ Getting this wrong is worse than missing a string, because it corrupts data or b
|
||||
|
||||
- **Agent and chat output.** Whatever Claude or opencode wrote. It is content.
|
||||
- **User content** — file names, paths, terminal output, code, commit messages, note bodies.
|
||||
- **Not everything a panel renders is content, and "panel plus file names" is the wrong test.** The
|
||||
guidance during phase 3 was to expect roughly half of FileBrowser's hits to be content and stay English.
|
||||
`useFileBrowserApp.ts`, the largest file in the phase at 47 hits, turned out **100% convertible**.
|
||||
|
||||
The reason is worth keeping. A panel that LISTS things renders content — a file name in a row is the
|
||||
user's, and translating it would be absurd. A panel REPORTING about those things owns the sentence:
|
||||
`Renamed to "{{name}}"` is ours, and the file name sits inside it as a parameter, which is exactly where
|
||||
content belongs. So the test is not "does this panel touch file names" but "is the string a frame around
|
||||
content, or the content itself".
|
||||
|
||||
It also matters for plurals. Six hand-rolled ones in that file became real entries because Portuguese
|
||||
reorders them — `1 item eliminado` against `Deleted 1 item` — which a frame with a count spliced into it
|
||||
cannot express at all.
|
||||
|
||||
- **A label quoted from ANOTHER product's interface.** "Click **Enable**", "set **App name**", "choose
|
||||
**Internal**" in a setup guide. These read like prose and are identifiers: their whole job is to match
|
||||
what the reader will actually see on Google's screen, and Google localises by the reader's _Google_
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
# A plugin's capabilities are LOADED, never copied
|
||||
|
||||
**Status: designed, not built.** What exists today is the copying this replaces.
|
||||
|
||||
## The problem, as it is on disk right now
|
||||
|
||||
`capabilities/` is a git repository. The intent is that anyone can fork it, keep their own tasks, skills
|
||||
and affordances in it, and pull core's on top. That only works if the directory contains exactly two
|
||||
things: what core ships, and what the owner wrote.
|
||||
|
||||
It does not. Four publishers copy a plugin's files into it at install time — assets, affordances,
|
||||
notification catalogues and skills — so a fork accumulates files it did not author. On this machine today
|
||||
`skills/email-db/` and `extensions/` sit untracked in that repository, published there by plugins. Commit
|
||||
them and the fork carries another plugin's work; leave them and `git status` is permanently noisy. There
|
||||
is no third answer, because nothing on disk says which files came from where.
|
||||
|
||||
Copying is also wrong in a second way that has nothing to do with git. **A copy goes stale.**
|
||||
`plugins:update` pulls new plugin code and the copied capabilities keep describing the old version until
|
||||
something republishes them. And a copy survives `plugins:disable`, so a disabled plugin's skill stays in
|
||||
the agent's index — the plugin is off and the agent still believes it is there.
|
||||
|
||||
## The design
|
||||
|
||||
**A plugin keeps its capabilities in its own directory, and the platform reads them where they are.**
|
||||
|
||||
```
|
||||
capabilities/ the owner's fork: core's, plus their own. Nothing else, ever.
|
||||
tasks/ skills/ affordances/ notifications/
|
||||
|
||||
plugins/<name>/ the plugin's own repository, cloned on install
|
||||
tasks/ skills/ affordances/ notifications/
|
||||
```
|
||||
|
||||
Nothing is copied. Nothing needs a namespace to keep the two apart, because they are already apart —
|
||||
which is the whole reason this beats scoping the paths. A namespace makes provenance *readable*; separate
|
||||
directories make it *structural*.
|
||||
|
||||
Four consequences, and each of them is a bug that stops existing:
|
||||
|
||||
- **A fork stays clean.** `capabilities/` contains only what its owner is responsible for, so pulling from
|
||||
upstream merges without touching anything a plugin put there.
|
||||
- **Capabilities update with the plugin.** `plugins:update` is a `git pull` in the plugin's directory, and
|
||||
the capabilities are in that directory. There is nothing left to go stale.
|
||||
- **Disable actually disables.** The load list is built from plugins that are installed AND enabled, so a
|
||||
disabled plugin contributes nothing. Today its copies remain.
|
||||
- **Uninstall needs no cleanup step.** The directory goes and its capabilities go with it. No list of
|
||||
filenames to be careful with, and no way to delete the owner's own work by getting that list wrong.
|
||||
|
||||
## What has to change
|
||||
|
||||
**The loaders take a list of roots.** `loadSkills(root)` and `loadAffordances(root)` already take one with
|
||||
a default, so this is a small change rather than a rewrite. Notifications the same.
|
||||
|
||||
**The list is resolved per session, not at module load.** `affordanceBlock()` and `skillsBlock()` are
|
||||
called once per session in `sidecar/claude/claude-manager.ts` — deliberately, so a capability written an
|
||||
hour ago is offered on the next session. That is also why they can afford to ask the database which
|
||||
plugins are installed: once per session, not once per turn.
|
||||
|
||||
**Tasks are the awkward one, because that store is read-write.** `api/tasks/task-files.ts` creates, edits
|
||||
and deletes as well as reads. Reading must span every source; writing must only ever touch the owner's
|
||||
directory. A plugin's task is read-only by nature and the UI has to know, or it will offer an Edit that
|
||||
silently writes into a directory the next `plugins:update` overwrites.
|
||||
|
||||
A task's directory name is also stored data — `taskDirName` in `pipeline_jobs` — so lookup by name has to
|
||||
search every source rather than assuming one root.
|
||||
|
||||
**The four publishers are deleted**, along with their install and uninstall wiring:
|
||||
`affordances/publish.ts`, `notifications/publish.ts`, `skills/publish.ts`, `api/tasks/publish.ts`. The
|
||||
last was written on 2026-08-23 and is superseded before it ever shipped.
|
||||
|
||||
`app-store/assets` is a different case and stays. Those are images served over HTTP from a
|
||||
platform-managed path, not capabilities read from disk, and the copy is what makes them reachable.
|
||||
|
||||
## The migration
|
||||
|
||||
One-off, and it is deletion rather than movement. The copies already in `capabilities/` have nothing to
|
||||
remove them once the publishers are gone — `skills/email-db/` and `extensions/` on this machine, and
|
||||
whatever the equivalent is per host. They are copies of files that still exist in each plugin's own
|
||||
directory, so deleting them loses nothing.
|
||||
|
||||
Check each host rather than assuming this one is representative.
|
||||
|
||||
## What this does not decide
|
||||
|
||||
**Where the owner's own capabilities live inside their fork.** Flat at the top of each type directory is
|
||||
what happens today and needs no change. Scoping them is a separate question and this design does not
|
||||
force it either way.
|
||||
|
||||
**Whether a plugin's capabilities should be namespaced among themselves.** Two plugins can still ship a
|
||||
task with the same directory name, and the loader has to break that tie — first-come, as the items store
|
||||
has always done, or by qualifying with the plugin name at read time. Worth settling when a second plugin
|
||||
ships a task; not before.
|
||||
+87
-15
@@ -131,7 +131,7 @@ provisioning script that adds a user to a privileged group is granting more than
|
||||
|
||||
Additive only. Nothing reads it.
|
||||
|
||||
### Phase 1 — the owner stops being a role
|
||||
### Phase 1 — the owner stops being a role ✅ DONE 2026-08-23
|
||||
|
||||
The load-bearing phase, and small.
|
||||
|
||||
@@ -140,7 +140,16 @@ The load-bearing phase, and small.
|
||||
call site keeps meaning OWNER**, so no route changes who it admits. Widening any "owner-only" route to
|
||||
"owner or Super Admin" is a separate, reviewable change per route.
|
||||
3. Drop the second-Super-Admin refusals in `create-user.ts` and `updateUserRoleHandler`.
|
||||
4. Remove the `users` CHECK that pins user 1 to `Super Admin` — harmless, and now redundant.
|
||||
4. ~~Remove the `users` CHECK that pins user 1 to `Super Admin`~~ — **KEPT, overruling this plan.** It is
|
||||
redundant for the question it was written for: `isOwner` is an id comparison and never reads that column.
|
||||
But it still forbids user 1 being set to `Member` while remaining the owner, which is a state with no
|
||||
benefit and a confusing display. A constraint that costs nothing and forbids a nonsense state is worth
|
||||
keeping after its original reason has gone.
|
||||
|
||||
**One asymmetry introduced deliberately.** An existing account can now be promoted to `Super Admin`;
|
||||
creating one directly as `Super Admin` is still refused by `create-user.ts`. Promotion happens to an account
|
||||
somebody has looked at, from a screen showing who they are. Creation is a form filled in once, and the role
|
||||
that provisions sudo should not be a dropdown default reached by accident. Create, then promote.
|
||||
|
||||
Reviewable on its own, and nothing downstream can start until ownership is an identity.
|
||||
|
||||
@@ -170,14 +179,21 @@ D8's warning in front of whoever writes the first script.
|
||||
code — `permissions/authorize.test.ts` is already pure and mockless, which is what makes that possible.
|
||||
4. Teams UI, and the account screen showing which teams a user is in and as what.
|
||||
|
||||
### Phase 5 — the thing that actually unblocks all of it
|
||||
### Phase 5 — account creation — **DONE, and it was done before this phase was written**
|
||||
|
||||
**There is still no way to create a second account.** `createUser` has one call site, `auth/bootstrap.ts`,
|
||||
gated on an empty table; the existing accounts were inserted by hand. Roles and teams are a permission model
|
||||
for accounts that cannot yet exist.
|
||||
`POST /api/users` is owner-gated and live, with a form under Settings → User Management: same password
|
||||
validator as bootstrap, a role checked against the `roles` table, `Super Admin` refused outright, an
|
||||
optional inbound SSH key, and Linux provisioning with `POST /users/:id/provision-linux` to retry it.
|
||||
Alongside it: role change, password reset, delete.
|
||||
|
||||
Sequenced last here because it is orthogonal, and flagged because a reader will otherwise finish phase 4 and
|
||||
find nothing to test it with. **It may be worth doing first.**
|
||||
It shipped in `69a31051`. This section spent days telling readers it did not exist and sequencing work
|
||||
around building it — and on 2026-08-23 it produced a concrete cost, when a recommendation to "do phase 5
|
||||
first" was made from this text and withdrawn only because someone opened `api/users/` to write the spec.
|
||||
|
||||
The general lesson, since it is the second time this exact shape has bitten: `bun run docs:check` compares
|
||||
numbers, so counts stay honest while prose rots quietly. **A phase marked as not built is worth verifying
|
||||
against the tree before it is planned around**, not because the plan was careless but because a plan is
|
||||
written once and the code moves every day.
|
||||
|
||||
---
|
||||
|
||||
@@ -198,13 +214,69 @@ find nothing to test it with. **It may be worth doing first.**
|
||||
- `bun run docs:check` — the permission count claim in `CLAUDE.md` moves whenever the registry does.
|
||||
- Any schema phase: verify by query per host, never by `db:push`'s exit code.
|
||||
|
||||
## Teams — decided 2026-08-23
|
||||
|
||||
The four open questions were answered by the owner. Recorded here so phase 4 is implementation rather
|
||||
than another round of design.
|
||||
|
||||
**A team owns a shared folder.** Not only an authorization grouping — storage is the part that was
|
||||
actually wanted. Team *dashboards* are a probable later addition and were explicitly deferred, because
|
||||
they need a permission shape nothing here has: people who can edit a dashboard's STRUCTURE versus people
|
||||
who only see what it renders. Every grant today is read-or-write over a whole capability, so that is a new
|
||||
model rather than a new level. Build the folder; leave room for the dashboards.
|
||||
|
||||
**Any Admin may create a team**, not the owner alone. Teams are self-service for people already trusted to
|
||||
administer the platform. This is what makes the quota section below necessary rather than optional: an
|
||||
Admin creating a team is an Admin allocating disk.
|
||||
|
||||
**A team may grant a capability the member's own role lacks** — the union, per D6 — with one rule on top:
|
||||
**whoever creates the team can only attach capabilities they hold themselves.** Escalation is capped at the
|
||||
creator's own reach rather than the whole registry. `execution` and `admin` stay ungrantable regardless,
|
||||
and `confined` still resolves to nothing for an account with no Linux user.
|
||||
|
||||
**Deleting a role is refused while anyone holds it, in a team or globally.** The same rule D4 already
|
||||
applies to global holders, extended rather than special-cased; the refusal should name the teams.
|
||||
|
||||
### Two things phase 4 must not get wrong
|
||||
|
||||
**The shared folder is enforced by the kernel, not by the grant.** Every role already holds `files` at
|
||||
`write` — they are the seeded `confined` defaults — so the capability is not what keeps one team out of
|
||||
another's folder. A Linux group per team is. The grant says whether someone can use the files surface at
|
||||
all; group membership says which folders they reach. Both are required and they are not substitutes.
|
||||
|
||||
**The grant cache is keyed on ROLE today**, deliberately: "at most one entry per role, and a new account
|
||||
needs no warm-up" (`permissions/authorize.ts`). Teams end that — effective permissions become role plus
|
||||
memberships, which is per-user. That property is easy to lose without noticing and awkward to recover, so
|
||||
it should be a decision in the implementation rather than a side effect.
|
||||
|
||||
## Quotas — the model, and the prerequisite nobody can skip
|
||||
|
||||
The owner declares platform-level defaults and maxima: per user, and per team. Admins allocate inside
|
||||
them. A change re-applies at the Linux level through `POST /users/:id/provision-linux`, which already
|
||||
exists as a retry path and becomes "re-apply current policy to this account".
|
||||
|
||||
**Enforcement is the kernel's job, not the platform's.** Members hold `terminal` at `write` and have their
|
||||
own Linux accounts, so a quota the platform tracks by counting bytes through the file browser is
|
||||
decorative — a member opens a terminal or SSHes in and writes past it. Only filesystem quotas bind.
|
||||
|
||||
**Which means a filesystem that can carry them.** Checked on 2026-08-23: neither `officer-dev` nor `alpha`
|
||||
can. Both are ext4 with homes on the root filesystem, no `usrquota`/`grpquota`/`prjquota` mount options,
|
||||
and neither has the ext4 `quota` or `project` feature. Enabling `project` needs `tune2fs -O project` on an
|
||||
UNMOUNTED filesystem, and `/` cannot be unmounted on a running server — so it is a rescue boot, not a
|
||||
config change.
|
||||
|
||||
The cheap moment is when a host is built, by giving the Linux homes their own filesystem with quota
|
||||
features from the start. Retrofitting a live host is expensive and gets more so with every account on it.
|
||||
|
||||
**Where this actually matters is `edge-pertento`** — the first real multi-user deployment, and the only
|
||||
one that will have accounts that are not the owner's. `officer-dev` is a development machine and will not
|
||||
be multi-user; alpha is the owner's own. Check edge-pertento's disk before it gains more accounts.
|
||||
|
||||
**On a host that cannot enforce, the platform says so** — "policy recorded, not enforceable here" — rather
|
||||
than storing numbers that do nothing. A feature that silently does nothing is worse than one that admits
|
||||
it cannot.
|
||||
|
||||
## Open questions
|
||||
|
||||
1. **Does a team have its own capabilities, or only its own levels?** Whether `team_permissions` can grant a
|
||||
capability the account's global role does not have at all. Union says yes; that may be more than wanted.
|
||||
2. **Who may create a team?** Owner only, or any Admin. Bears on whether a team is an administrative object
|
||||
or a self-service one.
|
||||
3. **Does a team own anything?** A team folder, a team dashboard. The answer decides whether teams are
|
||||
purely an authorization grouping or become a storage boundary — and the second is a much larger feature.
|
||||
4. **What happens to a user's team memberships when a role is deleted?** D4 refuses to delete a role anyone
|
||||
None outstanding for phase 4. The four that were here are answered above.
|
||||
holds globally; the same question exists per team and is not answered here.
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "officer",
|
||||
"version": "2026.08.23.04",
|
||||
"version": "2026.08.24.01",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"workspaces": [
|
||||
|
||||
@@ -34,8 +34,15 @@ const AREAS = [
|
||||
* 'officer-web (shell)' landed 2026-08-23 (phase 2). It holds no never-translate hits of its own; what it
|
||||
* does hold is ~30 `<Untranslated reason="foreign-ui-label">` labels quoted from Google's console, which
|
||||
* `stripUntranslated` skips for the same reason.
|
||||
*
|
||||
* 'officerdev (panels)' landed 2026-08-23 (phase 3). It holds nine `new Error()` messages for whoever is
|
||||
* reading a terminal, not a user — same never-translate shape as 'shared components'. Two more strings
|
||||
* are marked `i18n-exempt` rather than never-translate: `useTaskRunner.ts`'s "Running:"/"Error:" framing
|
||||
* is interleaved with raw stdout/stderr inside one monospace `<pre>` feed, so translating the frame alone
|
||||
* would read as broken mid-buffer — the same reasoning as the never-translate list, reached by a
|
||||
* different mechanism because it's not a thrown Error.
|
||||
*/
|
||||
const ZEROED: string[] = ['shared components', 'officer-web (shell)'];
|
||||
const ZEROED: string[] = ['shared components', 'officer-web (shell)', 'officerdev (panels)'];
|
||||
|
||||
const SKIP = /node_modules|\.gen\.|[/\\]locales[/\\]|[/\\]vendor[/\\]|\.test\.|\.d\.ts$/;
|
||||
|
||||
@@ -52,7 +59,10 @@ const Q = String.raw`(?:"([^"\n]{2,120})"|'([^'\n]{2,120})')`;
|
||||
*/
|
||||
const PATTERNS: { kind: string; rx: RegExp; neverTranslate?: true }[] = [
|
||||
// >Some visible words<
|
||||
{ kind: 'jsx text', rx: />\s*([A-Z][A-Za-z0-9][^<>{}\n]{2,100}?)\s*</g },
|
||||
//
|
||||
// The lookbehind rejects `=>`, so an arrow return type does not read as JSX text: `(id: string) =>
|
||||
// Promise<unknown>` was reported as the string "Promise".
|
||||
{ kind: 'jsx text', rx: /(?<!=)>\s*([A-Z][A-Za-z0-9][^<>{}\n]{2,100}?)\s*</g },
|
||||
// placeholder="..." and friends
|
||||
{
|
||||
kind: 'attributes',
|
||||
@@ -71,9 +81,20 @@ const PATTERNS: { kind: string; rx: RegExp; neverTranslate?: true }[] = [
|
||||
),
|
||||
},
|
||||
// Thrown Errors are for whoever is reading a terminal at 4am. They stay in one language.
|
||||
{ kind: 'thrown errors', rx: new RegExp(String.raw`new Error\(\s*` + Q, 'g'), neverTranslate: true },
|
||||
//
|
||||
// Matches a template literal as well as a quoted one: `new Error(\`Whisper returned ${res.status}\`)`
|
||||
// was falling through to `template str` and so counted as convertible, which is the opposite of true.
|
||||
{
|
||||
kind: 'thrown errors',
|
||||
rx: new RegExp(String.raw`new Error\(\s*(?:` + Q + String.raw`|\`([^\`]{2,120})\`)`, 'g'),
|
||||
neverTranslate: true,
|
||||
},
|
||||
// `Failed to create ${kind}` — needs interpolation, so it costs more to convert.
|
||||
{ kind: 'template str', rx: /`([A-Z][^`\n]{4,100}\$\{[^`]*)`/g },
|
||||
//
|
||||
// The lookbehind hands a thrown Error to the category above instead of counting it here as well: a
|
||||
// string matched by two patterns is counted twice, and it stayed convertible, so the area could never
|
||||
// reach zero however it was treated.
|
||||
{ kind: 'template str', rx: /(?<!new Error\()`([A-Z][^`\n]{4,100}\$\{[^`]*)`/g },
|
||||
];
|
||||
|
||||
/** Filters out ids, class names, paths, mime types and SCREAMING_CONSTANTS. */
|
||||
@@ -86,6 +107,10 @@ function isHuman(value: string | undefined): value is string {
|
||||
if (/^(https?:|\/|#|data:|application\/|image\/)/.test(s)) return false;
|
||||
// Allows . and - so credential-format examples like `GOCSPX-...` fall under the same rule.
|
||||
if (/^[A-Z0-9_.-]+$/.test(s)) return false;
|
||||
// A namespaced storage or channel key: an ALL-CAPS prefix, then a separator, then an interpolation —
|
||||
// `FULLSCREEN_PANEL:${workspace.key}`. The SCREAMING_CONSTANT rule above misses it because the
|
||||
// interpolated half is lowercase.
|
||||
if (/^[A-Z0-9_]{3,}[.:/-]/.test(s)) return false;
|
||||
// `Bearer ${token}` is an Authorization header value, not a sentence. Caught by the template-string
|
||||
// pattern because it starts with a capital and interpolates; excluded here because an auth scheme
|
||||
// followed by a credential is never something a person reads.
|
||||
@@ -126,6 +151,21 @@ const stripComments = (source: string): string =>
|
||||
const stripCodeElements = (source: string): string =>
|
||||
source.replace(/<(code|kbd|pre|samp)\b[^>]*>[\s\S]*?<\/\1>/g, '<$1/>');
|
||||
|
||||
/**
|
||||
* An explicit, per-line opt-out: `// i18n-exempt: <reason>` exempts the NEXT line.
|
||||
*
|
||||
* For the cases no syntactic rule can reach — chiefly a prompt written for a model rather than a
|
||||
* reader. `<Untranslated>` covers JSX; this covers the rest, and the two together mean the scanner does
|
||||
* not need another heuristic every time a new shape of never-translate string turns up.
|
||||
*
|
||||
* A reason is mandatory: the regex will not match a bare `// i18n-exempt`. That is the whole safeguard —
|
||||
* somebody has to write down why, and `grep -rn "i18n-exempt"` lists every one for review.
|
||||
*/
|
||||
const stripExemptLines = (source: string): string => {
|
||||
const lines = source.split('\n');
|
||||
return lines.map((line, i) => (/\/\/\s*i18n-exempt:\s*\S/.test(lines[i - 1] ?? '') ? '' : line)).join('\n');
|
||||
};
|
||||
|
||||
/**
|
||||
* `<Untranslated reason="…">` marks text that must stay in one language — today, a label quoted from
|
||||
* another product's UI, which docs/localization.md classifies as an identifier.
|
||||
@@ -169,7 +209,8 @@ function scan(root: string): { files: number; hits: Hit[] } {
|
||||
files++;
|
||||
let source: string;
|
||||
try {
|
||||
source = stripUntranslated(stripCodeElements(stripComments(readFileSync(file, 'utf8'))));
|
||||
// Exempt lines go FIRST: stripComments would remove the marker they depend on.
|
||||
source = stripUntranslated(stripCodeElements(stripComments(stripExemptLines(readFileSync(file, 'utf8')))));
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
+31
-17
@@ -7,31 +7,45 @@
|
||||
*
|
||||
* ── Why these four are `isSystem` ──
|
||||
*
|
||||
* Each is named by name somewhere in the code: `rolePermitsDevTools()` tests for `'Developer'`,
|
||||
* `bootstrap.ts` writes `'Super Admin'`, `users.role` defaults to `'Member'`, and `'Admin'` is the one
|
||||
* `rolePermitsDevTools` deliberately excludes. Renaming or deleting one of those is not a data change, it
|
||||
* is a code change that has not happened — so the flag exists for the delete and rename paths to refuse on,
|
||||
* rather than for the failure to turn up later as a missing database role or an account that cannot log in.
|
||||
* Each is named by name somewhere in the code: this file matches `'Developer'`, `'Admin'` and `'Member'`
|
||||
* by name on every run, and `bootstrap.ts` writes `'Super Admin'`. Renaming or deleting one of those is not
|
||||
* a data change, it is a code change that has not happened — so the flag exists for the delete and rename
|
||||
* paths to refuse on, rather than for the failure to turn up later as a missing database role or an
|
||||
* account that cannot log in.
|
||||
*
|
||||
* ── `description` versus `grantsPostgres`/`grantsDocker` ──
|
||||
*
|
||||
* `seedRole` treats these two kinds of column differently, and it matters here specifically because this
|
||||
* is the one caller that seeds `isSystem: true` rows. `description` is written once, on insert, and never
|
||||
* touched again — an edit made on one server must not be reverted by the next deploy. `grantsPostgres` and
|
||||
* `grantsDocker` ARE re-asserted every run for a system role, because they are not the operator's data,
|
||||
* they are what "Developer" MEANS — the same standing as the name itself. `rolePermitsDevTools()` used to
|
||||
* be that declaration, hardcoded as `role === 'Developer'`; this table is where it lives now, and this
|
||||
* SEED array is the only place either flag is set for these four roles. See `seedRole`'s own comment in
|
||||
* `officer_db/src/roles/queries.ts` for the mechanics.
|
||||
*
|
||||
* ── What this deliberately does NOT do ──
|
||||
*
|
||||
* No foreign key from `users.role`. That change cannot go through `db:push`: push creates the table empty
|
||||
* and adds the constraint in the same run, so every existing account violates it — verified against a
|
||||
* scratch database on 2026-08-23, where push reported the violation, created the table, skipped the
|
||||
* constraint, and exited 0. The FK is a separate release, once every host holds these rows.
|
||||
*
|
||||
* So today `users.role` is still a plain text column and nothing enforces that its value is a real role.
|
||||
* That is the intended state of this phase: additive, unable to fail, and safe to run on five hosts
|
||||
* unattended.
|
||||
*
|
||||
* Never overwrites. A description edited on one server must not be reverted by the next deploy.
|
||||
* Never overwrite `description`, as above. And never touch a CUSTOM role's grants — those are set through
|
||||
* `PATCH /api/roles/:name/grants` (`setRoleGrants`), which itself refuses to touch a system role for the
|
||||
* mirror-image reason: writing there would look like it worked and then silently revert on the next run of
|
||||
* this script.
|
||||
*/
|
||||
import { seedRole, waitForDatabase } from '../src/databases/officer_db/src/index';
|
||||
|
||||
const SEED: { name: string; description: string }[] = [
|
||||
type SeedRoleEntry = { name: string; description: string; grantsPostgres?: boolean; grantsDocker?: boolean };
|
||||
|
||||
// Match today's behaviour exactly: only Developer passed `rolePermitsDevTools` before this table existed,
|
||||
// so Developer is the only role seeded with either flag true.
|
||||
const SEED: SeedRoleEntry[] = [
|
||||
{ name: 'Super Admin', description: 'Full authority over the platform, and an OS account with sudo' },
|
||||
{ name: 'Admin', description: 'Administers the platform. No operating-system privilege' },
|
||||
{ name: 'Developer', description: 'A member who is also trusted with developer tooling on the machine' },
|
||||
{
|
||||
name: 'Developer',
|
||||
description: 'A member who is also trusted with developer tooling on the machine',
|
||||
grantsPostgres: true,
|
||||
grantsDocker: true,
|
||||
},
|
||||
{ name: 'Member', description: 'The default. Reaches only what their role has been granted' },
|
||||
];
|
||||
|
||||
|
||||
@@ -106,7 +106,6 @@ export function App() {
|
||||
<Route path="/system-monitor/:scope" element={<Dashboard.SystemMonitorScreen />} />
|
||||
{/* TEMPORARY / EXPERIMENTAL — offline file transfer over animated QR codes */}
|
||||
<Route path="/qr-transfer" element={<Dashboard.QrTransferScreen />} />
|
||||
<Route path="/minesweeper" element={<Dashboard.MinesweeperScreen />} />
|
||||
<Route path="/sendoff" element={<Dashboard.SendOffScreen />} />
|
||||
<Route path="/activity" element={<Dashboard.ActivityScreen />} />
|
||||
<Route path="/activity/:id" element={<Dashboard.ActivityScreen />} />
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import type { LayoutNode } from 'officerdev';
|
||||
import { WorkspaceView } from 'officerdev';
|
||||
import { useDashboardState } from 'state/useDashboardState';
|
||||
import { defaultLayout } from './defaultLayout';
|
||||
|
||||
// /minesweeper — Minesweeper X running under wine on a virtual display, streamed over RFB.
|
||||
//
|
||||
// The pixels arrive through /api/minesweeper/ws, which is a byte relay onto x11vnc's own TCP socket on
|
||||
// loopback — the same shape as the terminal's relay onto the pty sidecar, and the reason this works at
|
||||
// all where `plugins/desktop` could not: a websocket provider is a platform concern, and a plugin
|
||||
// cannot claim one.
|
||||
//
|
||||
// Owner-only, and not because a game is dangerous: wine runs as the server owner, so the game's menus
|
||||
// are file dialogs with the owner's home behind them.
|
||||
|
||||
export const MinesweeperScreen = () => {
|
||||
const workspace = useDashboardState<LayoutNode>('screens/minesweeper', defaultLayout);
|
||||
|
||||
return <WorkspaceView workspace={workspace} locked appTypes={{ allowed: ['minesweeper'], fallback: 'minesweeper' }} />;
|
||||
};
|
||||
@@ -1,9 +0,0 @@
|
||||
import type { LayoutNode } from 'officerdev';
|
||||
|
||||
// One panel. The board is 152x214 and there is nothing to put beside it — a split here would be a
|
||||
// second panel of empty space.
|
||||
export const defaultLayout: LayoutNode = {
|
||||
type: 'panel',
|
||||
id: 'minesweeper',
|
||||
appType: 'minesweeper',
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
export * from './MinesweeperScreen';
|
||||
+1
-1
@@ -8,7 +8,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@/components/ui/collapsible';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useTranslation } from '@/lib/i18n';
|
||||
import { Untranslated } from '@/lib/Untranslated';
|
||||
import { Untranslated } from '@/components/Untranslated';
|
||||
|
||||
type GoogleOAuthSettings = {
|
||||
clientId: string;
|
||||
|
||||
@@ -22,5 +22,4 @@ export * from './Dashboards';
|
||||
export * from './Terminal';
|
||||
export * from './Jobs';
|
||||
export * from './QrTransfer';
|
||||
export * from './Minesweeper';
|
||||
export * from './SendOff';
|
||||
|
||||
@@ -1,30 +1,11 @@
|
||||
import { useEffect } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import type { TranslationMap } from 'i18n';
|
||||
|
||||
import { I18nProvider, localeDirection } from 'i18n';
|
||||
import { panelCatalogs } from 'officerdev';
|
||||
import { uiCatalogs } from '@/components/locales';
|
||||
import { setNameRevision } from 'workspaces/react';
|
||||
import { useSettings } from 'state/useSettings';
|
||||
import { FALLBACK_LOCALE, SHIPPED_LOCALES } from './locales';
|
||||
import appEnGB from '../locales/en-GB.json';
|
||||
import appPtPT from '../locales/pt-PT.json';
|
||||
|
||||
const appCatalogs: TranslationMap = { 'en-GB': appEnGB, 'pt-PT': appPtPT };
|
||||
|
||||
/**
|
||||
* Deep-merges the three per-workspace catalogs (D6) into one map per shipped locale — `app.*` from
|
||||
* officer-web, `panel.*` from officerdev's panel apps, `ui.*` from shared components.
|
||||
*/
|
||||
const translations: TranslationMap = Object.fromEntries(
|
||||
SHIPPED_LOCALES.map(({ code }) => [
|
||||
code,
|
||||
{
|
||||
app: appCatalogs[code] ?? {},
|
||||
panel: panelCatalogs[code] ?? {},
|
||||
ui: uiCatalogs[code] ?? {},
|
||||
},
|
||||
]),
|
||||
);
|
||||
import { catalogues as translations } from './catalogues';
|
||||
|
||||
type I18nBridgeProps = {
|
||||
children: ReactNode;
|
||||
@@ -40,6 +21,17 @@ export const I18nBridge = ({ children }: I18nBridgeProps) => {
|
||||
document.documentElement.dir = localeDirection(locale);
|
||||
}, [locale]);
|
||||
|
||||
/*
|
||||
Panel names are thunks resolved during the framework's own render (AppRegistryEntry.name), and the
|
||||
framework is not subscribed to this provider — it has no i18n dependency at all, deliberately. So
|
||||
tell it that the words changed. It treats the value as opaque; that it happens to be a locale is
|
||||
this file's business, not the framework's.
|
||||
|
||||
Set during render rather than in an effect: an effect would leave the first paint after a locale
|
||||
switch showing the previous language's titles.
|
||||
*/
|
||||
setNameRevision(locale);
|
||||
|
||||
return (
|
||||
<I18nProvider translations={translations} locale={locale} fallbackLocale={FALLBACK_LOCALE}>
|
||||
{children}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { catalogues } from './catalogues';
|
||||
import { FALLBACK_LOCALE, SHIPPED_LOCALES } from './locales';
|
||||
|
||||
/**
|
||||
* A locale is only shipped if it is actually LOADED.
|
||||
*
|
||||
* This exists because es-ES, de-DE and fr-FR shipped dead. Every one of their 1,129 keys was translated and
|
||||
* in parity, all three were in `SHIPPED_LOCALES` so the picker offered them, and choosing one rendered the
|
||||
* entire UI in raw keys — because none of the three catalogue files had been given the `import`.
|
||||
*
|
||||
* The parity tests could not have caught it and never will: they compare JSON files to each other, and an
|
||||
* unimported file is as parity-correct as an imported one. The gap was between "the translation exists" and
|
||||
* "the app can reach it", and nothing was asking that question.
|
||||
*
|
||||
* Adding a locale touches four files. This is the one that knows whether all four happened.
|
||||
*/
|
||||
describe('every shipped locale is reachable', () => {
|
||||
const NAMESPACES = ['app', 'panel', 'ui'] as const;
|
||||
|
||||
test('SHIPPED_LOCALES is not empty and contains the fallback', () => {
|
||||
expect(SHIPPED_LOCALES.length).toBeGreaterThan(0);
|
||||
expect(SHIPPED_LOCALES.map((l) => l.code)).toContain(FALLBACK_LOCALE);
|
||||
});
|
||||
|
||||
for (const { code } of SHIPPED_LOCALES) {
|
||||
describe(code, () => {
|
||||
test('has a catalogue at all', () => {
|
||||
expect(catalogues[code]).toBeDefined();
|
||||
});
|
||||
|
||||
for (const ns of NAMESPACES) {
|
||||
// Non-empty, not merely present. The failure being pinned produced `{}` for all three, which is
|
||||
// exactly what `?? {}` yields for a locale nobody imported — present, and useless.
|
||||
test(`${ns}.* is populated`, () => {
|
||||
const catalogue = (catalogues[code] as Record<string, object> | undefined)?.[ns];
|
||||
expect(catalogue).toBeDefined();
|
||||
expect(Object.keys(catalogue ?? {}).length).toBeGreaterThan(0);
|
||||
});
|
||||
}
|
||||
|
||||
// Parity against the fallback is tested per-workspace already; this is the cheaper cross-check that
|
||||
// the MERGED catalogue is the same size, which catches a namespace wired to the wrong locale's file.
|
||||
test('is the same size as the fallback', () => {
|
||||
const count = (o: unknown): number =>
|
||||
typeof o === 'object' && o !== null ? Object.values(o).reduce<number>((n, v) => n + count(v), 0) : 1;
|
||||
expect(count(catalogues[code])).toBe(count(catalogues[FALLBACK_LOCALE]));
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { TranslationMap } from 'i18n';
|
||||
import { panelCatalogs } from 'officerdev';
|
||||
import { uiCatalogs } from '@/components/locales';
|
||||
import { SHIPPED_LOCALES } from './locales';
|
||||
import appDeDE from '../locales/de-DE.json';
|
||||
import appEnGB from '../locales/en-GB.json';
|
||||
import appEsES from '../locales/es-ES.json';
|
||||
import appFrFR from '../locales/fr-FR.json';
|
||||
import appPtPT from '../locales/pt-PT.json';
|
||||
import appSvSE from '../locales/sv-SE.json';
|
||||
|
||||
/**
|
||||
* The three per-workspace catalogues, merged into one map per shipped locale (D6): `app.*` from
|
||||
* officer-web, `panel.*` from officerdev's panel apps, `ui.*` from shared components.
|
||||
*
|
||||
* ── Why this is a separate file from `I18nBridge` ──
|
||||
*
|
||||
* So it can be tested without mounting React, and it is tested because of a real failure. es-ES, de-DE and
|
||||
* fr-FR shipped translated, in perfect key parity, listed in the picker — and completely dead. All three
|
||||
* `import` lists still named only en-GB and pt-PT, so `catalogues[code]` was three empty objects and the
|
||||
* whole UI rendered untranslated in three of five languages.
|
||||
*
|
||||
* Nothing caught it. The parity tests compare the JSON FILES against each other and every file was correct;
|
||||
* a locale that is never imported is invisible to them by construction. `SHIPPED_LOCALES` is what the picker
|
||||
* reads, and it had been updated. Adding a locale was four edits in four files with no single place that
|
||||
* knew all four had happened.
|
||||
*
|
||||
* `catalogues.test.ts` is now that place: it asserts every shipped locale actually resolves to a populated
|
||||
* catalogue in all three namespaces.
|
||||
*/
|
||||
const appCatalogs: TranslationMap = {
|
||||
'en-GB': appEnGB,
|
||||
'pt-PT': appPtPT,
|
||||
'es-ES': appEsES,
|
||||
'de-DE': appDeDE,
|
||||
'fr-FR': appFrFR,
|
||||
'sv-SE': appSvSE,
|
||||
};
|
||||
|
||||
export const catalogues: TranslationMap = Object.fromEntries(
|
||||
SHIPPED_LOCALES.map(({ code }) => [
|
||||
code,
|
||||
{
|
||||
app: appCatalogs[code] ?? {},
|
||||
panel: panelCatalogs[code] ?? {},
|
||||
ui: uiCatalogs[code] ?? {},
|
||||
},
|
||||
]),
|
||||
);
|
||||
@@ -19,7 +19,7 @@ const autonym = (code: string): string => {
|
||||
return name.charAt(0).toUpperCase() + name.slice(1);
|
||||
};
|
||||
|
||||
export const SHIPPED_LOCALES: ShippedLocale[] = ['en-GB', 'pt-PT'].map((code) => ({
|
||||
export const SHIPPED_LOCALES: ShippedLocale[] = ['en-GB', 'pt-PT', 'fr-FR', 'de-DE', 'es-ES', 'sv-SE'].map((code) => ({
|
||||
code,
|
||||
label: autonym(code),
|
||||
}));
|
||||
|
||||
@@ -0,0 +1,704 @@
|
||||
{
|
||||
"common": {
|
||||
"save": "Speichern",
|
||||
"saving": "Wird gespeichert...",
|
||||
"cancel": "Abbrechen"
|
||||
},
|
||||
"settings": {
|
||||
"profile": {
|
||||
"userData": {
|
||||
"email": "E-Mail",
|
||||
"username": "Benutzername",
|
||||
"name": "Name",
|
||||
"namePlaceholder": "Ihr Name",
|
||||
"updated": "Profil aktualisiert",
|
||||
"updateFailed": "Profil konnte nicht aktualisiert werden",
|
||||
"selectImage": "Bitte wählen Sie eine Bilddatei aus",
|
||||
"imageTooLarge": "Das Bild muss kleiner als 384 KB sein"
|
||||
},
|
||||
"apiKeys": {
|
||||
"intro": "Ein API-Schlüssel meldet Sie an, ohne Passwort und ohne Ablauf. Ein Schlüssel funktioniert überall — ein Schlüssel ist nicht an eine App gebunden. Jedem Gerät einen eigenen zu geben, dient nur dazu, einen abzuschalten, ohne die anderen anzutasten. Senden Sie ihn als",
|
||||
"loading": "Schlüssel werden geladen…",
|
||||
"mintedTitle": "Schlüssel für “{{name}}” — wird nur einmal angezeigt",
|
||||
"savedIt": "Ich habe ihn gespeichert",
|
||||
"copyKey": "Schlüssel kopieren",
|
||||
"copied": "Schlüssel kopiert",
|
||||
"copyFailed": "Kopieren nicht möglich — bitte manuell auswählen und kopieren",
|
||||
"newKey": "Neuer Schlüssel",
|
||||
"namePlaceholder": "iPhone, Musik-App, Laptop-CLI…",
|
||||
"create": "Erstellen",
|
||||
"createFailed": "Der Schlüssel konnte nicht erstellt werden",
|
||||
"noneYet": "Noch keine Schlüssel.",
|
||||
"noneInUse": "Keine Schlüssel in Verwendung.",
|
||||
"revoked": "widerrufen",
|
||||
"expired": "abgelaufen",
|
||||
"lastUsed": "zuletzt verwendet am {{date}}",
|
||||
"neverUsed": "nie verwendet",
|
||||
"forget": "Diesen Eintrag entfernen",
|
||||
"forgetFailed": "Entfernen nicht möglich",
|
||||
"revoke": "Widerrufen",
|
||||
"revokeDone": "Widerrufen — alles, was diesen Schlüssel verwendet hat, wurde abgemeldet",
|
||||
"revokeFailed": "Widerrufen nicht möglich",
|
||||
"hideDead": "Widerrufene und abgelaufene ausblenden",
|
||||
"showDead": {
|
||||
"one": "{{count}} widerrufenen oder abgelaufenen Schlüssel anzeigen",
|
||||
"other": "{{count}} widerrufene oder abgelaufene Schlüssel anzeigen"
|
||||
}
|
||||
},
|
||||
"voice": {
|
||||
"label": "Stimme",
|
||||
"refresh": "Stimmen aktualisieren",
|
||||
"serverDefault": "Server-Standard",
|
||||
"hint": "Wählen Sie eine Stimme für die Sprachausgabe (Text-to-Speech). Belassen Sie es beim Server-Standard, um die vom Administrator konfigurierte Stimme zu verwenden.",
|
||||
"stop": "Stopp",
|
||||
"listen": "Anhören"
|
||||
},
|
||||
"dock": {
|
||||
"visible": "Sichtbar",
|
||||
"hidden": "Ausgeblendet",
|
||||
"dragToShow": "Elemente hierher ziehen, um sie im Dock anzuzeigen",
|
||||
"allVisible": "Alle Elemente sichtbar",
|
||||
"reset": "Auf Standard zurücksetzen"
|
||||
},
|
||||
"languages": {
|
||||
"spokenLabel": "Sprachen, die Sie sprechen",
|
||||
"addLanguage": "Sprache hinzufügen...",
|
||||
"defaultLabel": "Standardsprache",
|
||||
"defaultHint": "Wird für die Lokalisierung der Oberfläche verwendet.",
|
||||
"translateToLabel": "Übersetzen nach",
|
||||
"translateToHint": "Zielsprache beim Übersetzen von Inhalten, die Sie nicht sprechen."
|
||||
},
|
||||
"changePassword": {
|
||||
"currentPasswordLabel": "Aktuelles Passwort",
|
||||
"currentPasswordPlaceholder": "Aktuelles Passwort",
|
||||
"newPasswordLabel": "Neues Passwort",
|
||||
"newPasswordPlaceholder": "Neues Passwort",
|
||||
"confirmPasswordLabel": "Neues Passwort bestätigen",
|
||||
"confirmPasswordPlaceholder": "Neues Passwort bestätigen",
|
||||
"changeButton": "Passwort ändern",
|
||||
"changingButton": "Wird geändert...",
|
||||
"passwordChanged": "Passwort geändert",
|
||||
"passwordChangeFailed": "Passwort konnte nicht geändert werden"
|
||||
},
|
||||
"models": {
|
||||
"defaultModel": "Standardmodell",
|
||||
"sameAsChat": "Wie Chat-Standard",
|
||||
"taskDefaultsSaved": "Aufgaben-Standardeinstellungen gespeichert",
|
||||
"saveFailed": "Einstellungen konnten nicht gespeichert werden",
|
||||
"defaultChat": "Standard-Chat-Modell",
|
||||
"defaultChatHint": "Wird beim Starten eines neuen Chats vom Startbildschirm verwendet",
|
||||
"defaultProject": "Standard-Projektmodell",
|
||||
"defaultProjectHint": "Wird beim Starten eines neuen Chats innerhalb eines Projekt-Dashboards verwendet",
|
||||
"defaultTask": "Standard-Aufgabenmodell",
|
||||
"defaultTaskHint": "Wird beim Ausführen von Aufgaben aus dem Datei-Browser verwendet",
|
||||
"aiDefaultsSaved": "KI-Modell-Standardeinstellungen gespeichert"
|
||||
}
|
||||
},
|
||||
"sections": {
|
||||
"profile": {
|
||||
"label": "Profil",
|
||||
"hint": "Avatar, Name, E-Mail",
|
||||
"description": "Avatar, Name und E-Mail"
|
||||
},
|
||||
"changePassword": {
|
||||
"label": "Passwort ändern",
|
||||
"description": "Aktualisieren Sie Ihr Passwort"
|
||||
},
|
||||
"apiKeys": {
|
||||
"label": "API-Schlüssel",
|
||||
"hint": "Schlüssel, die sich als Sie anmelden",
|
||||
"description": "Schlüssel, die sich als Sie anmelden, einzeln widerrufbar"
|
||||
},
|
||||
"languages": {
|
||||
"label": "Sprachen",
|
||||
"hint": "gesprochen und Übersetzung",
|
||||
"description": "Gesprochen, Standard und Übersetzung"
|
||||
},
|
||||
"voice": {
|
||||
"label": "Stimme",
|
||||
"hint": "Text-zu-Sprache",
|
||||
"description": "Einstellung für die Sprachausgabe (Text-to-Speech)"
|
||||
},
|
||||
"dock": {
|
||||
"label": "Dock",
|
||||
"hint": "auswählen und anordnen",
|
||||
"description": "Dock-Elemente auswählen und anordnen"
|
||||
},
|
||||
"myModels": {
|
||||
"label": "Meine Modelle",
|
||||
"description": "Modelle für sich selbst anzeigen oder ausblenden"
|
||||
},
|
||||
"channelModels": {
|
||||
"label": "Kanalmodelle",
|
||||
"description": "Modelle, die für Konten ohne Eigentümerrechte erreichbar sind"
|
||||
},
|
||||
"defaultModels": {
|
||||
"label": "Standardmodelle",
|
||||
"description": "Standardmodelle für Chat, Projekte und Aufgaben"
|
||||
},
|
||||
"providers": {
|
||||
"label": "Anbieter",
|
||||
"hint": "Harnesses",
|
||||
"description": "Entfernte und lokale KI-Anbieter"
|
||||
},
|
||||
"tts": {
|
||||
"label": "Text-zu-Sprache",
|
||||
"description": "TTS-Anbieter und Stimme"
|
||||
},
|
||||
"stt": {
|
||||
"label": "Sprache-zu-Text",
|
||||
"description": "Whisper-Server-URL"
|
||||
},
|
||||
"ocr": {
|
||||
"label": "OCR",
|
||||
"description": "Vision-Modell für Textextraktion"
|
||||
},
|
||||
"maintenance": {
|
||||
"label": "Neu erstellen & neu starten",
|
||||
"hint": "Wartung",
|
||||
"description": "Änderungen laden, ohne ein Terminal zu benötigen"
|
||||
},
|
||||
"smtp": {
|
||||
"label": "SMTP",
|
||||
"hint": "ausgehende E-Mail",
|
||||
"description": "Anbieter für E-Mail-Zustellung"
|
||||
},
|
||||
"googleOauth": {
|
||||
"label": "Google OAuth",
|
||||
"description": "Client-ID und Secret für Google-APIs"
|
||||
},
|
||||
"apify": {
|
||||
"label": "Apify",
|
||||
"description": "API-Token für Web-Scraping-Actors"
|
||||
},
|
||||
"davSync": {
|
||||
"label": "Kalender- und Kontaktabgleich",
|
||||
"hint": "App-Passwörter",
|
||||
"description": "Geräteweise Passwörter für CalDAV-/CardDAV-Clients"
|
||||
},
|
||||
"accounts": {
|
||||
"label": "Konten",
|
||||
"hint": "Benutzer",
|
||||
"description": "Wer Zugriff hat, und worauf"
|
||||
},
|
||||
"permissions": {
|
||||
"label": "Berechtigungen",
|
||||
"description": "Was jede Rolle erreichen kann"
|
||||
}
|
||||
},
|
||||
"searchPlaceholder": "Einstellungen durchsuchen…",
|
||||
"selectSection": "Wählen Sie einen Bereich aus",
|
||||
"groups": {
|
||||
"models": "Modelle",
|
||||
"defaults": "Standardwerte",
|
||||
"providers": "Anbieter",
|
||||
"ai": "KI",
|
||||
"maintenance": "Wartung",
|
||||
"email": "E-Mail"
|
||||
},
|
||||
"nav": {
|
||||
"profile": "Profil",
|
||||
"ai": "KI",
|
||||
"system": "System",
|
||||
"integrations": "Integrationen",
|
||||
"users": "Benutzer"
|
||||
},
|
||||
"ai": {
|
||||
"noModels": "Keine Modelle verfügbar. Konfigurieren Sie API-Schlüssel unter Anbieter.",
|
||||
"dragToShow": "Modelle hierher ziehen, um sie anzuzeigen",
|
||||
"allVisible": "Alle Modelle sichtbar",
|
||||
"dragToEnable": "Modelle hierher ziehen, um sie zu aktivieren",
|
||||
"allEnabled": "Alle Modelle aktiviert",
|
||||
"visible": "Sichtbar",
|
||||
"hidden": "Ausgeblendet",
|
||||
"enabled": "Aktiviert",
|
||||
"disabled": "Deaktiviert",
|
||||
"refresh": "Modelle von Anbietern aktualisieren",
|
||||
"refreshed": "Modelle aktualisiert",
|
||||
"refreshFailed": "Modelle konnten nicht aktualisiert werden"
|
||||
},
|
||||
"integrations": {
|
||||
"enterprise": "Unternehmen",
|
||||
"personal": "Persönlich"
|
||||
},
|
||||
"server": {
|
||||
"loading": "Wird geladen…",
|
||||
"provider": "Anbieter",
|
||||
"model": "Modell",
|
||||
"voice": "Stimme",
|
||||
"ocr": {
|
||||
"serverUrl": "Server-URL des Vision-Modells",
|
||||
"refreshModels": "Modelle aktualisieren",
|
||||
"selectModel": "Wählen Sie ein Modell aus",
|
||||
"saved": "OCR-Einstellungen gespeichert",
|
||||
"saveFailed": "OCR-Einstellungen konnten nicht gespeichert werden",
|
||||
"reachable": "Vision-Modell-Server ist erreichbar"
|
||||
},
|
||||
"stt": {
|
||||
"serverUrl": "Whisper-Server-URL",
|
||||
"saved": "STT-Einstellungen gespeichert",
|
||||
"saveFailed": "STT-Einstellungen konnten nicht gespeichert werden",
|
||||
"reachable": "Whisper-Server ist erreichbar"
|
||||
},
|
||||
"tts": {
|
||||
"openaiCompatible": "OpenAI-kompatibel (Kokoro, OpenAI usw.)",
|
||||
"elevenLabs": "ElevenLabs",
|
||||
"refreshVoices": "Stimmen aktualisieren",
|
||||
"selectVoice": "Wählen Sie eine Stimme aus",
|
||||
"saved": "TTS-Einstellungen gespeichert",
|
||||
"saveFailed": "TTS-Einstellungen konnten nicht gespeichert werden",
|
||||
"playing": "Testaudio wird abgespielt",
|
||||
"testFailed": "Test fehlgeschlagen — TTS-Server nicht erreichbar"
|
||||
},
|
||||
"smtp": {
|
||||
"mailhog": "MailHog (lokaler Test)",
|
||||
"resend": "Resend",
|
||||
"custom": "Benutzerdefiniertes SMTP",
|
||||
"apiKey": "API-Schlüssel",
|
||||
"host": "Host",
|
||||
"port": "Port",
|
||||
"username": "Benutzername",
|
||||
"password": "Passwort",
|
||||
"tls": "TLS / SSL",
|
||||
"fromName": "Absendername",
|
||||
"fromEmail": "Absender-E-Mail",
|
||||
"sendTest": "Test-E-Mail senden",
|
||||
"userExample": "benutzer@beispiel.de",
|
||||
"recipientExample": "empfänger@beispiel.de",
|
||||
"saved": "SMTP-Einstellungen gespeichert",
|
||||
"saveFailed": "SMTP-Einstellungen konnten nicht gespeichert werden",
|
||||
"connected": "Verbindung erfolgreich",
|
||||
"testSent": "Test-E-Mail an {{email}} gesendet"
|
||||
},
|
||||
"maintenance": {
|
||||
"updateTitle": "Plattform aktualisieren",
|
||||
"updateHint": "Ändert nichts, was gerade läuft. Was das Update benötigt, ist hier aufgeführt, und die Entscheidung liegt bei Ihnen.",
|
||||
"rebuild": "Frontend neu erstellen",
|
||||
"restart": "Server neu starten",
|
||||
"upToDate": "Bereits aktuell",
|
||||
"rebuilt": "Frontend neu erstellt — laden Sie diese Seite neu, um es zu sehen",
|
||||
"restarting": "Wird neu gestartet — die Seite verbindet sich von selbst wieder",
|
||||
"pulled": {
|
||||
"one": "{{count}} Commit abgerufen",
|
||||
"other": "{{count}} Commits abgerufen"
|
||||
}
|
||||
},
|
||||
"harnesses": {
|
||||
"piMono": "Pi Mono",
|
||||
"checking": "Wird geprüft…",
|
||||
"localProviders": "Lokale Anbieter",
|
||||
"remoteProviders": "Entfernte Anbieter",
|
||||
"connect": "Verbinden",
|
||||
"authenticate": "Authentifizieren",
|
||||
"connectLocal": "Lokalen Anbieter verbinden",
|
||||
"connectProvider": "Anbieter verbinden",
|
||||
"none": "Keine Anbieter gefunden.",
|
||||
"updatePiMono": "Pi Mono aktualisieren",
|
||||
"removeProvider": "Anbieter entfernen",
|
||||
"namePlaceholder": "Name (z. B. Mein Ollama)",
|
||||
"username": "Benutzername",
|
||||
"password": "Passwort",
|
||||
"apiKey": "API-Schlüssel",
|
||||
"editKey": "Schlüssel bearbeiten",
|
||||
"disconnect": "Anbieter trennen",
|
||||
"saveKey": "Schlüssel speichern",
|
||||
"enterKey": "API-Schlüssel eingeben",
|
||||
"search": "Anbieter durchsuchen…",
|
||||
"connectFailed": "Verbindung fehlgeschlagen",
|
||||
"authFailed": "Authentifizierung fehlgeschlagen",
|
||||
"saveFailed": "Anbieter konnte nicht gespeichert werden",
|
||||
"connectedTo": "Mit {{name}} verbunden"
|
||||
}
|
||||
},
|
||||
"users": {
|
||||
"loadingPermissions": "Berechtigungen werden geladen…",
|
||||
"permissionsFailed": "Berechtigungen konnten nicht geladen werden.",
|
||||
"deniedUnlessGranted": "Alles ist verweigert, sofern es hier nicht gewährt wird.",
|
||||
"read": "Lesen",
|
||||
"full": "Vollständig",
|
||||
"noAccess": "Kein Zugriff",
|
||||
"role": "Rolle",
|
||||
"permissionsSaved": "Gespeichert, was {{role}}s erreichen können",
|
||||
"copyBeforeClosing": "Kopieren Sie, was Sie brauchen, bevor Sie schließen — nichts davon kann erneut angezeigt werden.",
|
||||
"password": "Passwort",
|
||||
"linuxAccount": "Linux-Konto",
|
||||
"sshKey": "Ihr öffentlicher SSH-Schlüssel",
|
||||
"addToGitea": "Fügen Sie dies zu ihrem Gitea-Konto hinzu",
|
||||
"linuxIncomplete": "Das Konto funktioniert, aber die Linux-Seite wurde nicht fertiggestellt",
|
||||
"done": "Fertig",
|
||||
"addAccount": "Konto hinzufügen",
|
||||
"newAccount": "Neues Konto",
|
||||
"email": "E-Mail",
|
||||
"name": "Name",
|
||||
"username": "Benutzername",
|
||||
"usernameHint": "Buchstaben, Zahlen, Punkte, Bindestriche und Unterstriche.",
|
||||
"generatePassword": "Ein Passwort generieren",
|
||||
"copyPassword": "Passwort kopieren",
|
||||
"passwordHint": "Mindestens 12 Zeichen, mit Groß- und Kleinbuchstaben, einer Zahl und einem Symbol.",
|
||||
"sshPlaceholder": "ssh-ed25519 AAAAC3Nza… ana@laptop",
|
||||
"passwordCopied": "Passwort kopiert",
|
||||
"loadingAccounts": "Konten werden geladen…",
|
||||
"accountsFailed": "Konten konnten nicht geladen werden.",
|
||||
"removeAccount": "Konto entfernen",
|
||||
"generateNewPassword": "Ein neues Passwort generieren",
|
||||
"serverOwner": "Servereigentümer",
|
||||
"copySshTitle": "Ihren öffentlichen SSH-Schlüssel kopieren (fügen Sie ihn zu ihrem Gitea-Konto hinzu)",
|
||||
"resetPasswordTitle": "Ein neues Passwort generieren — wird nur einmal angezeigt und meldet sie überall ab",
|
||||
"copyNewPassword": "Das neue Passwort kopieren",
|
||||
"publicKeyCopied": "Öffentlicher Schlüssel kopiert",
|
||||
"copyFailed": "Kopieren nicht möglich — bitte manuell auswählen und kopieren",
|
||||
"repairFor": "Linux-Konto von {{email}} reparieren",
|
||||
"linuxAccountFor": "Linux-Konto: {{osUser}} — klicken, um es zu reparieren oder den SSH-Schlüssel zu ersetzen",
|
||||
"copySshFor": "Öffentlichen SSH-Schlüssel von {{email}} kopieren",
|
||||
"resetPasswordFor": "Passwort von {{email}} zurücksetzen",
|
||||
"removeFor": "{{email}} entfernen",
|
||||
"provisionPrompt": "Linux-Konto für {{email}}.\n\nFügen Sie einen öffentlichen SSH-Schlüssel ein, um SSH-Zugriff zu erlauben, oder lassen Sie das Feld leer, um den aktuellen zu behalten.",
|
||||
"createLinux": "Ein Linux-Konto erstellen",
|
||||
"noLinux": "Kein Linux-Konto — klicken, um eines zu erstellen",
|
||||
"removeTitle": "{{email}} entfernen?",
|
||||
"removeBody": "Dies löscht das Konto und alles, was dazugehört — Passkeys, Dashboards, gespeicherte Layouts und alles, was ein installiertes Plugin für dieses Konto speichert. Dies kann nicht rückgängig gemacht werden.",
|
||||
"resetTitle": "Passwort für {{email}} zurücksetzen?",
|
||||
"resetBody": "Ein neues Passwort wird generiert und Ihnen einmalig angezeigt — es wird nirgendwo gespeichert und kann danach nicht mehr abgerufen werden. Das bisherige Passwort funktioniert sofort nicht mehr, und die Person wird überall abgemeldet.",
|
||||
"resetLinuxNote": "Das Linux-Konto ({{osUser}}) ist davon nicht betroffen: Es hat kein Passwort, und die SSH-Schlüssel bleiben unverändert."
|
||||
},
|
||||
"dav": {
|
||||
"newDevice": "Neues Gerät",
|
||||
"create": "Erstellen",
|
||||
"none": "Noch keine Geräte.",
|
||||
"placeholder": "Pixel 9, iPad, Thunderbird…",
|
||||
"revoke": "Widerrufen",
|
||||
"delete": "Löschen",
|
||||
"copyFailed": "Kopieren nicht möglich — bitte manuell auswählen und kopieren",
|
||||
"createFailed": "Das App-Passwort konnte nicht erstellt werden",
|
||||
"revoked": "Widerrufen — dieses Gerät synchronisiert nicht mehr",
|
||||
"revokeFailed": "Widerrufen nicht möglich",
|
||||
"deleteFailed": "Löschen nicht möglich",
|
||||
"copyField": "{{field}} kopieren",
|
||||
"copied": "{{what}} kopiert"
|
||||
},
|
||||
"google": {
|
||||
"guideToggle": "Schritt-für-Schritt-Anleitung",
|
||||
"step1": {
|
||||
"title": "Ein Google-Cloud-Projekt erstellen",
|
||||
"body": "Öffnen Sie die Seite zur Projekterstellung, geben Sie ihm einen Namen (zum Beispiel “Officer”) und klicken Sie auf:",
|
||||
"link": "New Project"
|
||||
},
|
||||
"step2": {
|
||||
"title": "Die APIs aktivieren",
|
||||
"body": "Öffnen Sie die API-Bibliothek, suchen Sie nach jeder dieser APIs und klicken Sie auf:",
|
||||
"link": "API Library"
|
||||
},
|
||||
"step3": {
|
||||
"title": "Den OAuth-Zustimmungsbildschirm konfigurieren",
|
||||
"link": "OAuth Branding",
|
||||
"appName": "Legen Sie den Namen Ihrer Organisation oder “Officer” fest als:",
|
||||
"supportEmail": "Legen Sie die E-Mail-Adresse Ihres Administrators fest als:",
|
||||
"devContact": "Fügen Sie die E-Mail-Adresse Ihres Administrators hinzu unter:",
|
||||
"save": "Klicken Sie dann auf:"
|
||||
},
|
||||
"step4": {
|
||||
"title": "Die Zielgruppe festlegen",
|
||||
"link": "OAuth Audience",
|
||||
"internal": "Wenn Ihr Team Google Workspace verwendet, wählen Sie dies — keine Überprüfung nötig:",
|
||||
"external": "Andernfalls wählen Sie dies und fügen Sie die E-Mail-Adressen Ihres Teams unter Test users hinzu (erforderlich, solange die App nicht verifiziert ist; Grenze von 100 Testnutzern):"
|
||||
},
|
||||
"step5": {
|
||||
"title": "Bereiche hinzufügen",
|
||||
"link": "Data Access",
|
||||
"body": "Öffnen Sie den Datenzugriff, suchen Sie die unten stehenden Bereiche, fügen Sie sie hinzu und klicken Sie dann auf:",
|
||||
"note": "Google stuft den Kalenderbereich als sensibel und den Gmail-Bereich als eingeschränkt ein. Das ist unproblematisch für Internal-Apps (Google Workspace) und für External-Apps im Testmodus; die Veröffentlichung in Produktion mit eingeschränkten Bereichen erfordert eine Google-Verifizierung."
|
||||
},
|
||||
"step6": {
|
||||
"title": "OAuth-Anmeldedaten erstellen",
|
||||
"link": "Clients",
|
||||
"body": "Öffnen Sie die Clients-Seite und klicken Sie auf:",
|
||||
"appType": "Anwendungstyp:",
|
||||
"nameAnything": "Name: beliebig (zum Beispiel “Officer”)",
|
||||
"redirectUris": "Autorisierte Weiterleitungs-URIs — hinzufügen:",
|
||||
"thenClick": "Klicken Sie dann auf:"
|
||||
},
|
||||
"step7": {
|
||||
"title": "Die Anmeldedaten kopieren",
|
||||
"body": "Ein Dialogfeld zeigt Ihre Client-ID und Ihr Client-Secret. Kopieren Sie beide und fügen Sie sie in die untenstehenden Felder ein."
|
||||
},
|
||||
"scopeGmail": "E-Mails lesen",
|
||||
"scopeCalendar": "Kalenderereignisse lesen",
|
||||
"verifying": "Anmeldedaten werden überprüft…",
|
||||
"valid": "Anmeldedaten gültig",
|
||||
"invalid": "Ungültige Anmeldedaten",
|
||||
"verifyFailed": "Überprüfungsanfrage fehlgeschlagen",
|
||||
"clientId": "Client-ID",
|
||||
"clientSecret": "Client-Secret",
|
||||
"redirectUri": "Weiterleitungs-URI",
|
||||
"redirectUriHint": "Fügen Sie diese URI zu den autorisierten Weiterleitungs-URIs Ihres Google-OAuth-Clients hinzu",
|
||||
"saved": "Google-OAuth-Konfiguration gespeichert",
|
||||
"saveFailed": "Google-OAuth-Konfiguration konnte nicht gespeichert werden"
|
||||
},
|
||||
"googleAccount": {
|
||||
"title": "Google-Konto",
|
||||
"hint": "Verbinden Sie Ihr Google-Konto für Kalender, Kontakte und andere Google-Dienste.",
|
||||
"connected": "Verbunden",
|
||||
"disconnect": "Trennen",
|
||||
"connect": "Google-Konto verbinden",
|
||||
"connectedToast": "Google-Konto verbunden",
|
||||
"connectFailed": "Google-Konto konnte nicht verbunden werden",
|
||||
"disconnectedToast": "Google-Konto getrennt",
|
||||
"disconnectFailed": "Google-Konto konnte nicht getrennt werden"
|
||||
},
|
||||
"apify": {
|
||||
"token": "API-Token",
|
||||
"saved": "Apify-API-Token gespeichert",
|
||||
"saveFailed": "Apify-API-Token konnte nicht gespeichert werden"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
"userMenu": {
|
||||
"profile": "Profil",
|
||||
"systemSettings": "Systemeinstellungen",
|
||||
"signOut": "Abmelden",
|
||||
"ai": "KI",
|
||||
"integrations": "Integrationen",
|
||||
"userManagement": "Benutzerverwaltung"
|
||||
},
|
||||
"tabName": "Tab-Name",
|
||||
"tabNameHint": "Klicken, um diesen Tab umzubenennen — leeren, um zum Seitennamen zurückzukehren",
|
||||
"logoAlt": "Officer",
|
||||
"navigation": "Navigation",
|
||||
"jobsRunning": "Job läuft",
|
||||
"jobsNothingRunning": "Nichts läuft",
|
||||
"jobsQueued": "Jobs in Warteschlange"
|
||||
},
|
||||
"nav": {
|
||||
"home": "Start",
|
||||
"files": "Dateien",
|
||||
"chat": "Chat",
|
||||
"commandHq": "Command HQ",
|
||||
"editor": "Editor",
|
||||
"jobs": "Jobs",
|
||||
"terminal": "Terminal",
|
||||
"browser": "Browser",
|
||||
"monitor": "Monitor",
|
||||
"activity": "Aktivität",
|
||||
"dashboards": "Dashboards",
|
||||
"sendOff": "SendOff",
|
||||
"calendar": "Kalender",
|
||||
"contacts": "Kontakte",
|
||||
"appStore": "App Store",
|
||||
"marketplace": "Marketplace",
|
||||
"codeEditor": "Code-Editor"
|
||||
},
|
||||
"theme": {
|
||||
"pillTitle": "Design: {{name}} · Strg+Umschalt+←/→ durchblättern · Strg+Umschalt+Leertaste durchsuchen · Strg+Umschalt+F {{starAction}}",
|
||||
"pillLabel": "Design: {{name}}",
|
||||
"star": "markieren",
|
||||
"unstar": "Markierung aufheben",
|
||||
"cycleHint": "Strg+Umschalt+←/→ durchblättern · Strg+Umschalt+Leertaste durchsuchen",
|
||||
"everyShortcutHint": "Strg+? für alle Tastenkombinationen",
|
||||
"favouritesOnly": "Nur Favoriten ({{count}})",
|
||||
"favourites": "Favoriten ({{count}})",
|
||||
"noFavourites": "Noch keine Favoriten — markieren Sie einen mit der Schaltfläche in seiner Zeile.",
|
||||
"starNamed": "{{name}} markieren",
|
||||
"unstarNamed": "Markierung von {{name}} aufheben",
|
||||
"addToFavourites": "Zu Favoriten hinzufügen",
|
||||
"removeFromFavourites": "Aus Favoriten entfernen",
|
||||
"addToFavouritesKey": "Zu Favoriten hinzufügen (f)",
|
||||
"removeFromFavouritesKey": "Aus Favoriten entfernen (f)",
|
||||
"active": "aktiv",
|
||||
"browseHint": "j/k oder ↑/↓ bewegen · l oder → Vorschau · f markieren · h oder ← zurücksetzen · Eingabetaste behalten · Esc abbrechen"
|
||||
},
|
||||
"spotlight": {
|
||||
"placeholder": "Gehe zu…",
|
||||
"noResults": "Keine Treffer für “{{query}}”."
|
||||
},
|
||||
"shortcutsModal": {
|
||||
"title": "Tastenkombinationen",
|
||||
"hint": "Strg+? · Esc zum Schließen"
|
||||
},
|
||||
"screenError": {
|
||||
"title": "Dieser Bildschirm konnte nicht angezeigt werden",
|
||||
"description": "Nichts ist verloren gegangen — das ist ein Anzeigefehler, kein Datenfehler. Agenten-Transkripte, Jobs und Dateien sind unberührt.",
|
||||
"unknownError": "Unbekannter Fehler",
|
||||
"tryAgain": "Erneut versuchen",
|
||||
"reload": "Neu laden",
|
||||
"goHome": "Zum Start",
|
||||
"resetHint": "Wenn es an derselben Stelle erneut fehlschlägt, ist meist ein gespeichertes Panel-Layout die Ursache. Zurücksetzen bringt die Panels aller Bildschirme auf ihre Standardwerte zurück.",
|
||||
"dashboardsSafe": "Ihre Dashboards sind davon nicht betroffen.",
|
||||
"resetting": "Wird zurückgesetzt…",
|
||||
"resetConfirm": "Ja — Layout aller Bildschirme zurücksetzen",
|
||||
"reset": "Bildschirm-Layouts zurücksetzen"
|
||||
},
|
||||
"rescan": {
|
||||
"label": "Elemente erneut scannen",
|
||||
"title": "officer-items erneut scannen (Änderungen übernehmen, ohne neu zu starten)",
|
||||
"failed": "Elemente konnten nicht erneut gescannt werden",
|
||||
"done": "Elemente erneut gescannt — {{summary}}",
|
||||
"counts": {
|
||||
"tasks": {
|
||||
"one": "{{count}} Aufgabe",
|
||||
"other": "{{count}} Aufgaben"
|
||||
},
|
||||
"agents": {
|
||||
"one": "{{count}} Agent",
|
||||
"other": "{{count}} Agenten"
|
||||
},
|
||||
"tools": {
|
||||
"one": "{{count}} Werkzeug",
|
||||
"other": "{{count}} Werkzeuge"
|
||||
},
|
||||
"skills": {
|
||||
"one": "{{count}} Skill",
|
||||
"other": "{{count}} Skills"
|
||||
},
|
||||
"processes": {
|
||||
"one": "{{count}} Prozess",
|
||||
"other": "{{count}} Prozesse"
|
||||
},
|
||||
"affordances": {
|
||||
"one": "{{count}} Affordance",
|
||||
"other": "{{count}} Affordances"
|
||||
}
|
||||
},
|
||||
"newChatsOnly": "Gilt für neue Chats — ein bereits geöffneter behält, was er beim Start hatte.",
|
||||
"noDescription": {
|
||||
"one": "{{count}} Skill hat keine Beschreibung, daher wird sie keinem Agenten angeboten.",
|
||||
"other": "{{count}} Skills haben keine Beschreibung, daher werden sie keinem Agenten angeboten."
|
||||
}
|
||||
},
|
||||
"bugReport": {
|
||||
"label": "Fehler melden",
|
||||
"title": "Fehler melden",
|
||||
"describePlaceholder": "Beschreiben Sie, was schiefgelaufen ist...",
|
||||
"screenshot": "Screenshot",
|
||||
"noScreenshot": "Kein Screenshot aufgenommen",
|
||||
"collectedNote": "Konsolenprotokolle, Browserinformationen und aktuelle API-Fehler werden automatisch erfasst.",
|
||||
"submitted": "Fehlerbericht gesendet",
|
||||
"failed": "Fehlerbericht konnte nicht gesendet werden",
|
||||
"submit": "Senden"
|
||||
},
|
||||
"auth": {
|
||||
"signInTitle": "Willkommen zurück",
|
||||
"signInSubtitle": "Melden Sie sich bei Ihrem Konto an",
|
||||
"emailOrUsername": "E-Mail oder Benutzername",
|
||||
"password": "Passwort",
|
||||
"forgotPassword": "Passwort vergessen?",
|
||||
"emailPlaceholder": "name@beispiel.de",
|
||||
"passwordPlaceholder": "Ihr Passwort",
|
||||
"forgotTitle": "Passwort vergessen",
|
||||
"forgotSubtitle": "Geben Sie Ihre E-Mail-Adresse ein, um einen Link zum Zurücksetzen zu erhalten",
|
||||
"email": "E-Mail",
|
||||
"backToLogin": "Zurück zur Anmeldung",
|
||||
"checkEmail": "Prüfen Sie Ihre E-Mails",
|
||||
"checkEmailHint": "Klicken Sie darauf, um Ihr Passwort zurückzusetzen.",
|
||||
"resetOk": "Passwort erfolgreich zurückgesetzt. Bitte melden Sie sich an.",
|
||||
"verifying": "Wird überprüft…",
|
||||
"invalidLink": "Ungültiger Link",
|
||||
"invalidLinkHint": "Dieser Link zum Zurücksetzen ist ungültig oder abgelaufen.",
|
||||
"resetTitle": "Passwort zurücksetzen",
|
||||
"resetSubtitle": "Geben Sie Ihr neues Passwort ein",
|
||||
"confirmPassword": "Passwort bestätigen",
|
||||
"minChars": "Mindestens 12 Zeichen",
|
||||
"confirmPlaceholder": "Bestätigen Sie Ihr Passwort",
|
||||
"bootstrapTitle": "Willkommen, Administrator",
|
||||
"bootstrapSubtitle": "Erstellen Sie Ihr Administratorkonto",
|
||||
"name": "Name",
|
||||
"username": "Benutzername",
|
||||
"namePlaceholder": "Ihr Name",
|
||||
"adminEmailPlaceholder": "admin@beispiel.de",
|
||||
"bootstrapOk": "Administratorkonto erstellt. Bitte melden Sie sich an.",
|
||||
"debug": {
|
||||
"controls": "Steuerung:",
|
||||
"camera": "Kamera:",
|
||||
"model": "Modell:"
|
||||
}
|
||||
},
|
||||
"shortcuts": {
|
||||
"navigation": "Navigation",
|
||||
"spotlight": "Spotlight — zu jedem Bildschirm, Plugin oder jeder Einstellung per Namen springen",
|
||||
"thisList": "Diese Liste",
|
||||
"themes": "Designs",
|
||||
"themesNote": "Der Browser wendet die Änderung an, während Sie sich bewegen, sodass Sie sehen, wie sich die App verändert, statt Namen zu lesen.",
|
||||
"openBrowser": "Den Design-Browser öffnen",
|
||||
"prevNextTheme": "Vorheriges / nächstes Design, ohne etwas zu öffnen",
|
||||
"starCurrent": "Das aktuelle Design von überall markieren oder die Markierung aufheben",
|
||||
"inBrowser": "Im Design-Browser",
|
||||
"moveList": "Durch die Liste bewegen",
|
||||
"applyKeepBrowsing": "Das fokussierte Design anwenden und weiter durchsuchen",
|
||||
"restoreStart": "Das Design wiederherstellen, mit dem Sie begonnen haben",
|
||||
"starFocused": "Das fokussierte Design markieren oder die Markierung aufheben",
|
||||
"keepLooking": "Behalten, was Sie sich gerade ansehen",
|
||||
"cancelRestore": "Abbrechen und wiederherstellen",
|
||||
"inSpotlight": "In Spotlight",
|
||||
"moveResults": "Durch die Ergebnisse bewegen",
|
||||
"goThere": "Dorthin gehen",
|
||||
"close": "Schließen",
|
||||
"chat": "Chat",
|
||||
"chatNote": "Der Verlaufsabruf reagiert nur bei LEEREM Eingabefeld auf die Pfeiltaste, und jede andere Taste beendet ihn — so wird ein Entwurf nie überschrieben.",
|
||||
"prevNextPrompt": "Vorheriger / nächster gesendeter Prompt, wie in einer Shell",
|
||||
"send": "Senden · bei leerem Eingabefeld mit etwas in der Warteschlange wird das jetzt gesendet",
|
||||
"newline": "Zeilenumbruch",
|
||||
"stopTurn": "Den gerade laufenden Turn stoppen"
|
||||
},
|
||||
"jobs": {
|
||||
"runningQueued": "In Ausführung und in Warteschlange",
|
||||
"loading": "Wird geladen…",
|
||||
"nothingRunning": "Nichts läuft",
|
||||
"history": "Verlauf",
|
||||
"clearAll": "Alles löschen",
|
||||
"selectJob": "Wählen Sie einen Job aus",
|
||||
"notFound": "Job nicht gefunden.",
|
||||
"backToJobs": "Zurück zu den Jobs",
|
||||
"stop": "Stopp",
|
||||
"testNotification": "Eine Testbenachrichtigung an alle konfigurierten Kanäle senden",
|
||||
"search": "Suchen…",
|
||||
"deleteFinished": "Alle abgeschlossenen Jobs löschen",
|
||||
"noChannels": "Keine Kanäle konfiguriert",
|
||||
"notifyFailed": "Benachrichtigung konnte nicht gesendet werden",
|
||||
"stopRequested": "Stopp angefordert",
|
||||
"stopFailed": "Stopp fehlgeschlagen",
|
||||
"titles": "Titel",
|
||||
"download": "Herunterladen",
|
||||
"input": "Eingabe",
|
||||
"output": "Ausgabe",
|
||||
"steps": "Schritte",
|
||||
"selectStep": "Wählen Sie einen Schritt oder eine Unteraufgabe, um die Ausgabe anzuzeigen",
|
||||
"noOutput": "Noch keine Ausgabe",
|
||||
"tokens": "Tokens:",
|
||||
"cost": "Kosten:",
|
||||
"status": {
|
||||
"running": "Läuft",
|
||||
"queued": "In Warteschlange",
|
||||
"completed": "Abgeschlossen",
|
||||
"failed": "Fehlgeschlagen",
|
||||
"stopped": "Gestoppt",
|
||||
"interrupted": "Unterbrochen"
|
||||
},
|
||||
"failedWithCode": "Fehlgeschlagen (Exit {{code}})"
|
||||
},
|
||||
"activity": {
|
||||
"title": "Aktivität",
|
||||
"backgroundTasks": "Hintergrundaufgaben",
|
||||
"detached": "Abgekoppelt",
|
||||
"backToList": "Zurück zur Liste",
|
||||
"selectTask": "Wählen Sie eine Aufgabe aus, um ihre Live-Ausgabe zu verfolgen",
|
||||
"noneRunning": "keine läuft"
|
||||
},
|
||||
"passkeyGate": {
|
||||
"setUp": "Richten Sie Ihren Passkey ein"
|
||||
},
|
||||
"chatHistory": {
|
||||
"loadFailed": "Diese Unterhaltung konnte nicht geladen werden: {{reason}}"
|
||||
},
|
||||
"capability": {
|
||||
"frontmatter": "Frontmatter",
|
||||
"noMatches": "Keine Treffer",
|
||||
"emptyFile": "Leere Datei",
|
||||
"createFailed": "{{kind}} konnte nicht erstellt werden",
|
||||
"deleteFailed": "{{kind}} konnte nicht gelöscht werden",
|
||||
"searchPlaceholder": "{{kind}} durchsuchen…",
|
||||
"selectOne": "{{kind}} auswählen",
|
||||
"selectToView": "{{kind}} auswählen, um den Inhalt anzuzeigen",
|
||||
"delete": "Löschen",
|
||||
"createPrompt": "Hilf mir, den Inhalt für diese neue {{kind}}-Datei zu erstellen",
|
||||
"improvePrompt": "Hilf mir, diese {{kind}}-Datei zu verstehen und zu verbessern"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,704 @@
|
||||
{
|
||||
"common": {
|
||||
"save": "Guardar",
|
||||
"saving": "Guardando...",
|
||||
"cancel": "Cancelar"
|
||||
},
|
||||
"settings": {
|
||||
"profile": {
|
||||
"userData": {
|
||||
"email": "Correo electrónico",
|
||||
"username": "Nombre de usuario",
|
||||
"name": "Nombre",
|
||||
"namePlaceholder": "Tu nombre",
|
||||
"updated": "Perfil actualizado",
|
||||
"updateFailed": "No se pudo actualizar el perfil",
|
||||
"selectImage": "Selecciona un archivo de imagen",
|
||||
"imageTooLarge": "La imagen debe ser menor de 384 KB"
|
||||
},
|
||||
"apiKeys": {
|
||||
"intro": "Una clave de API inicia sesión como tú, sin contraseña y sin caducar. Una clave funciona en todas partes — una clave no está ligada a una aplicación. Dar a cada dispositivo la suya solo sirve para poder cortar una sin tocar las demás. Envíala como",
|
||||
"loading": "Cargando claves…",
|
||||
"mintedTitle": "Clave para “{{name}}” — se muestra una sola vez",
|
||||
"savedIt": "Ya la he guardado",
|
||||
"copyKey": "Copiar clave",
|
||||
"copied": "Clave copiada",
|
||||
"copyFailed": "No se pudo copiar — selecciona y copia manualmente",
|
||||
"newKey": "Nueva clave",
|
||||
"namePlaceholder": "iPhone, app de Música, CLI del portátil…",
|
||||
"create": "Crear",
|
||||
"createFailed": "No se pudo crear la clave",
|
||||
"noneYet": "Todavía no hay claves.",
|
||||
"noneInUse": "No hay claves en uso.",
|
||||
"revoked": "revocada",
|
||||
"expired": "caducada",
|
||||
"lastUsed": "usada por última vez el {{date}}",
|
||||
"neverUsed": "nunca usada",
|
||||
"forget": "Eliminar este registro",
|
||||
"forgetFailed": "No se pudo eliminar",
|
||||
"revoke": "Revocar",
|
||||
"revokeDone": "Revocada — todo lo que usaba esa clave se ha desconectado",
|
||||
"revokeFailed": "No se pudo revocar",
|
||||
"hideDead": "Ocultar revocadas y caducadas",
|
||||
"showDead": {
|
||||
"one": "Mostrar {{count}} clave revocada o caducada",
|
||||
"other": "Mostrar {{count}} claves revocadas o caducadas"
|
||||
}
|
||||
},
|
||||
"voice": {
|
||||
"label": "Voz",
|
||||
"refresh": "Actualizar voces",
|
||||
"serverDefault": "Predeterminada del servidor",
|
||||
"hint": "Elige una voz para la conversión de texto a voz. Déjala en la predeterminada del servidor para usar la voz configurada por el administrador.",
|
||||
"stop": "Detener",
|
||||
"listen": "Escuchar"
|
||||
},
|
||||
"dock": {
|
||||
"visible": "Visibles",
|
||||
"hidden": "Ocultos",
|
||||
"dragToShow": "Arrastra elementos aquí para mostrarlos en el dock",
|
||||
"allVisible": "Todos los elementos visibles",
|
||||
"reset": "Restaurar valores predeterminados"
|
||||
},
|
||||
"languages": {
|
||||
"spokenLabel": "Idiomas que hablas",
|
||||
"addLanguage": "Añadir un idioma...",
|
||||
"defaultLabel": "Idioma predeterminado",
|
||||
"defaultHint": "Se usa para la localización de la interfaz.",
|
||||
"translateToLabel": "Traducir a",
|
||||
"translateToHint": "Idioma de destino al traducir contenido que no hablas."
|
||||
},
|
||||
"changePassword": {
|
||||
"currentPasswordLabel": "Contraseña actual",
|
||||
"currentPasswordPlaceholder": "Contraseña actual",
|
||||
"newPasswordLabel": "Nueva contraseña",
|
||||
"newPasswordPlaceholder": "Nueva contraseña",
|
||||
"confirmPasswordLabel": "Confirmar nueva contraseña",
|
||||
"confirmPasswordPlaceholder": "Confirma la nueva contraseña",
|
||||
"changeButton": "Cambiar contraseña",
|
||||
"changingButton": "Cambiando...",
|
||||
"passwordChanged": "Contraseña cambiada",
|
||||
"passwordChangeFailed": "No se pudo cambiar la contraseña"
|
||||
},
|
||||
"models": {
|
||||
"defaultModel": "Modelo predeterminado",
|
||||
"sameAsChat": "Igual que el predeterminado del chat",
|
||||
"taskDefaultsSaved": "Valores predeterminados de tareas guardados",
|
||||
"saveFailed": "No se pudieron guardar los ajustes",
|
||||
"defaultChat": "Modelo predeterminado del chat",
|
||||
"defaultChatHint": "Se usa al iniciar un chat nuevo desde la pantalla de inicio",
|
||||
"defaultProject": "Modelo predeterminado del proyecto",
|
||||
"defaultProjectHint": "Se usa al iniciar un chat nuevo dentro de un dashboard de proyecto",
|
||||
"defaultTask": "Modelo predeterminado de tareas",
|
||||
"defaultTaskHint": "Se usa al ejecutar tareas desde el explorador de archivos",
|
||||
"aiDefaultsSaved": "Valores predeterminados de modelos de IA guardados"
|
||||
}
|
||||
},
|
||||
"sections": {
|
||||
"profile": {
|
||||
"label": "Perfil",
|
||||
"hint": "avatar, nombre, correo electrónico",
|
||||
"description": "Avatar, nombre y correo electrónico"
|
||||
},
|
||||
"changePassword": {
|
||||
"label": "Cambiar contraseña",
|
||||
"description": "Actualiza tu contraseña"
|
||||
},
|
||||
"apiKeys": {
|
||||
"label": "Claves de API",
|
||||
"hint": "claves que inician sesión como tú",
|
||||
"description": "Claves que inician sesión como tú, revocables una a una"
|
||||
},
|
||||
"languages": {
|
||||
"label": "Idiomas",
|
||||
"hint": "hablados y traducción",
|
||||
"description": "Hablados, predeterminado y traducción"
|
||||
},
|
||||
"voice": {
|
||||
"label": "Voz",
|
||||
"hint": "conversión de texto a voz",
|
||||
"description": "Preferencia de voz para la conversión de texto a voz"
|
||||
},
|
||||
"dock": {
|
||||
"label": "Dock",
|
||||
"hint": "elegir y reordenar",
|
||||
"description": "Elige y reordena los elementos del dock"
|
||||
},
|
||||
"myModels": {
|
||||
"label": "Mis modelos",
|
||||
"description": "Muestra u oculta modelos solo para ti"
|
||||
},
|
||||
"channelModels": {
|
||||
"label": "Modelos del canal",
|
||||
"description": "Modelos accesibles para cuentas que no son del propietario"
|
||||
},
|
||||
"defaultModels": {
|
||||
"label": "Modelos predeterminados",
|
||||
"description": "Modelos predeterminados para chat, proyectos y tareas"
|
||||
},
|
||||
"providers": {
|
||||
"label": "Proveedores",
|
||||
"hint": "harnesses",
|
||||
"description": "Proveedores de IA remotos y locales"
|
||||
},
|
||||
"tts": {
|
||||
"label": "Texto a voz",
|
||||
"description": "Proveedor de TTS y voz"
|
||||
},
|
||||
"stt": {
|
||||
"label": "Voz a texto",
|
||||
"description": "URL del servidor Whisper"
|
||||
},
|
||||
"ocr": {
|
||||
"label": "OCR",
|
||||
"description": "Modelo de visión para extracción de texto"
|
||||
},
|
||||
"maintenance": {
|
||||
"label": "Reconstruir y reiniciar",
|
||||
"hint": "mantenimiento",
|
||||
"description": "Carga los cambios sin usar un terminal"
|
||||
},
|
||||
"smtp": {
|
||||
"label": "SMTP",
|
||||
"hint": "correo saliente",
|
||||
"description": "Proveedor de entrega de correo electrónico"
|
||||
},
|
||||
"googleOauth": {
|
||||
"label": "Google OAuth",
|
||||
"description": "Client ID y secret para las APIs de Google"
|
||||
},
|
||||
"apify": {
|
||||
"label": "Apify",
|
||||
"description": "Token de API para actores de web scraping"
|
||||
},
|
||||
"davSync": {
|
||||
"label": "Sincronización de Calendario y Contactos",
|
||||
"hint": "contraseñas de aplicación",
|
||||
"description": "Contraseñas por dispositivo para clientes CalDAV/CardDAV"
|
||||
},
|
||||
"accounts": {
|
||||
"label": "Cuentas",
|
||||
"hint": "usuarios",
|
||||
"description": "Quién tiene acceso, y con qué función"
|
||||
},
|
||||
"permissions": {
|
||||
"label": "Permisos",
|
||||
"description": "Lo que cada función puede alcanzar"
|
||||
}
|
||||
},
|
||||
"searchPlaceholder": "Buscar ajustes…",
|
||||
"selectSection": "Selecciona una sección",
|
||||
"groups": {
|
||||
"models": "Modelos",
|
||||
"defaults": "Predeterminados",
|
||||
"providers": "Proveedores",
|
||||
"ai": "IA",
|
||||
"maintenance": "Mantenimiento",
|
||||
"email": "Correo electrónico"
|
||||
},
|
||||
"nav": {
|
||||
"profile": "Perfil",
|
||||
"ai": "IA",
|
||||
"system": "Sistema",
|
||||
"integrations": "Integraciones",
|
||||
"users": "Usuarios"
|
||||
},
|
||||
"ai": {
|
||||
"noModels": "No hay modelos disponibles. Configura las claves de API en Proveedores.",
|
||||
"dragToShow": "Arrastra modelos aquí para mostrarlos",
|
||||
"allVisible": "Todos los modelos visibles",
|
||||
"dragToEnable": "Arrastra modelos aquí para activarlos",
|
||||
"allEnabled": "Todos los modelos activados",
|
||||
"visible": "Visibles",
|
||||
"hidden": "Ocultos",
|
||||
"enabled": "Activados",
|
||||
"disabled": "Desactivados",
|
||||
"refresh": "Actualizar modelos desde los proveedores",
|
||||
"refreshed": "Modelos actualizados",
|
||||
"refreshFailed": "No se pudieron actualizar los modelos"
|
||||
},
|
||||
"integrations": {
|
||||
"enterprise": "Empresa",
|
||||
"personal": "Personal"
|
||||
},
|
||||
"server": {
|
||||
"loading": "Cargando…",
|
||||
"provider": "Proveedor",
|
||||
"model": "Modelo",
|
||||
"voice": "Voz",
|
||||
"ocr": {
|
||||
"serverUrl": "URL del servidor del modelo de visión",
|
||||
"refreshModels": "Actualizar modelos",
|
||||
"selectModel": "Selecciona un modelo",
|
||||
"saved": "Ajustes de OCR guardados",
|
||||
"saveFailed": "No se pudieron guardar los ajustes de OCR",
|
||||
"reachable": "El servidor del modelo de visión está accesible"
|
||||
},
|
||||
"stt": {
|
||||
"serverUrl": "URL del servidor Whisper",
|
||||
"saved": "Ajustes de STT guardados",
|
||||
"saveFailed": "No se pudieron guardar los ajustes de STT",
|
||||
"reachable": "El servidor Whisper está accesible"
|
||||
},
|
||||
"tts": {
|
||||
"openaiCompatible": "Compatible con OpenAI (Kokoro, OpenAI, etc.)",
|
||||
"elevenLabs": "ElevenLabs",
|
||||
"refreshVoices": "Actualizar voces",
|
||||
"selectVoice": "Selecciona una voz",
|
||||
"saved": "Ajustes de TTS guardados",
|
||||
"saveFailed": "No se pudieron guardar los ajustes de TTS",
|
||||
"playing": "Reproduciendo audio de prueba",
|
||||
"testFailed": "La prueba falló — no se pudo contactar con el servidor de TTS"
|
||||
},
|
||||
"smtp": {
|
||||
"mailhog": "MailHog (pruebas locales)",
|
||||
"resend": "Resend",
|
||||
"custom": "SMTP personalizado",
|
||||
"apiKey": "Clave de API",
|
||||
"host": "Host",
|
||||
"port": "Puerto",
|
||||
"username": "Nombre de usuario",
|
||||
"password": "Contraseña",
|
||||
"tls": "TLS / SSL",
|
||||
"fromName": "Nombre del remitente",
|
||||
"fromEmail": "Correo del remitente",
|
||||
"sendTest": "Enviar correo de prueba",
|
||||
"userExample": "usuario@ejemplo.com",
|
||||
"recipientExample": "destinatario@ejemplo.com",
|
||||
"saved": "Ajustes de SMTP guardados",
|
||||
"saveFailed": "No se pudieron guardar los ajustes de SMTP",
|
||||
"connected": "Conexión correcta",
|
||||
"testSent": "Correo de prueba enviado a {{email}}"
|
||||
},
|
||||
"maintenance": {
|
||||
"updateTitle": "Actualizar la plataforma",
|
||||
"updateHint": "No cambia nada que esté en ejecución. Lo que la actualización necesita se indica aquí, y la decisión es tuya.",
|
||||
"rebuild": "Reconstruir el frontend",
|
||||
"restart": "Reiniciar el servidor",
|
||||
"upToDate": "Ya está actualizado",
|
||||
"rebuilt": "Frontend reconstruido — actualiza esta página para verlo",
|
||||
"restarting": "Reiniciando — la página se reconectará sola",
|
||||
"pulled": {
|
||||
"one": "Se obtuvo {{count}} commit",
|
||||
"other": "Se obtuvieron {{count}} commits"
|
||||
}
|
||||
},
|
||||
"harnesses": {
|
||||
"piMono": "Pi Mono",
|
||||
"checking": "Comprobando…",
|
||||
"localProviders": "Proveedores locales",
|
||||
"remoteProviders": "Proveedores remotos",
|
||||
"connect": "Conectar",
|
||||
"authenticate": "Autenticar",
|
||||
"connectLocal": "Conectar proveedor local",
|
||||
"connectProvider": "Conectar proveedor",
|
||||
"none": "No se encontraron proveedores.",
|
||||
"updatePiMono": "Actualizar Pi Mono",
|
||||
"removeProvider": "Eliminar proveedor",
|
||||
"namePlaceholder": "Nombre (p. ej. Mi Ollama)",
|
||||
"username": "Nombre de usuario",
|
||||
"password": "Contraseña",
|
||||
"apiKey": "Clave de API",
|
||||
"editKey": "Editar clave",
|
||||
"disconnect": "Desconectar proveedor",
|
||||
"saveKey": "Guardar clave",
|
||||
"enterKey": "Introduce la clave de API",
|
||||
"search": "Buscar proveedores…",
|
||||
"connectFailed": "No se pudo conectar",
|
||||
"authFailed": "La autenticación falló",
|
||||
"saveFailed": "No se pudo guardar el proveedor",
|
||||
"connectedTo": "Conectado a {{name}}"
|
||||
}
|
||||
},
|
||||
"users": {
|
||||
"loadingPermissions": "Cargando permisos…",
|
||||
"permissionsFailed": "No se pudieron cargar los permisos.",
|
||||
"deniedUnlessGranted": "Todo está denegado a menos que se conceda aquí.",
|
||||
"read": "Lectura",
|
||||
"full": "Total",
|
||||
"noAccess": "Sin acceso",
|
||||
"role": "Función",
|
||||
"permissionsSaved": "Guardado lo que pueden alcanzar los {{role}}",
|
||||
"copyBeforeClosing": "Copia lo que necesites antes de cerrar — nada de esto se puede volver a mostrar.",
|
||||
"password": "Contraseña",
|
||||
"linuxAccount": "Cuenta Linux",
|
||||
"sshKey": "Su clave pública SSH",
|
||||
"addToGitea": "Añade esto a su cuenta de Gitea",
|
||||
"linuxIncomplete": "La cuenta funciona, pero su parte Linux no terminó de configurarse",
|
||||
"done": "Hecho",
|
||||
"addAccount": "Añadir cuenta",
|
||||
"newAccount": "Nueva cuenta",
|
||||
"email": "Correo electrónico",
|
||||
"name": "Nombre",
|
||||
"username": "Nombre de usuario",
|
||||
"usernameHint": "Letras, números, puntos, guiones y guiones bajos.",
|
||||
"generatePassword": "Generar una contraseña",
|
||||
"copyPassword": "Copiar la contraseña",
|
||||
"passwordHint": "Al menos 12 caracteres, con mayúsculas y minúsculas, un número y un símbolo.",
|
||||
"sshPlaceholder": "ssh-ed25519 AAAAC3Nza… ana@portatil",
|
||||
"passwordCopied": "Contraseña copiada",
|
||||
"loadingAccounts": "Cargando cuentas…",
|
||||
"accountsFailed": "No se pudieron cargar las cuentas.",
|
||||
"removeAccount": "Eliminar cuenta",
|
||||
"generateNewPassword": "Generar una nueva contraseña",
|
||||
"serverOwner": "Propietario del servidor",
|
||||
"copySshTitle": "Copiar su clave pública SSH (añádela a su cuenta de Gitea)",
|
||||
"resetPasswordTitle": "Generar una nueva contraseña — se muestra una sola vez, y cierra su sesión en todas partes",
|
||||
"copyNewPassword": "Copiar la nueva contraseña",
|
||||
"publicKeyCopied": "Clave pública copiada",
|
||||
"copyFailed": "No se pudo copiar — selecciónala y copia a mano",
|
||||
"repairFor": "Reparar la cuenta Linux de {{email}}",
|
||||
"linuxAccountFor": "Cuenta Linux: {{osUser}} — haz clic para reparar o sustituir su clave SSH",
|
||||
"copySshFor": "Copiar la clave pública SSH de {{email}}",
|
||||
"resetPasswordFor": "Restablecer la contraseña de {{email}}",
|
||||
"removeFor": "Eliminar {{email}}",
|
||||
"provisionPrompt": "Cuenta Linux para {{email}}.\n\nPega una clave pública SSH para permitirle conectarse por SSH, o déjalo vacío para mantener la actual.",
|
||||
"createLinux": "Crear una cuenta Linux",
|
||||
"noLinux": "Sin cuenta Linux — haz clic para crear una",
|
||||
"removeTitle": "¿Eliminar {{email}}?",
|
||||
"removeBody": "Esto elimina la cuenta y todo lo que le pertenece — passkeys, dashboards, diseños guardados y todo lo que cualquier plugin instalado guarde para ella. No se puede deshacer.",
|
||||
"resetTitle": "¿Restablecer la contraseña de {{email}}?",
|
||||
"resetBody": "Se genera una nueva contraseña y se te muestra una sola vez — no se guarda en ningún sitio y no se puede consultar después. Su contraseña actual deja de funcionar de inmediato, y su sesión se cierra en todas partes.",
|
||||
"resetLinuxNote": "Su cuenta Linux ({{osUser}}) no se ve afectada: no tiene contraseña, y las claves SSH no cambian."
|
||||
},
|
||||
"dav": {
|
||||
"newDevice": "Nuevo dispositivo",
|
||||
"create": "Crear",
|
||||
"none": "Todavía no hay dispositivos.",
|
||||
"placeholder": "Pixel 9, iPad, Thunderbird…",
|
||||
"revoke": "Revocar",
|
||||
"delete": "Eliminar",
|
||||
"copyFailed": "No se pudo copiar — selecciona y copia manualmente",
|
||||
"createFailed": "No se pudo crear la contraseña de aplicación",
|
||||
"revoked": "Revocada — ese dispositivo dejará de sincronizar",
|
||||
"revokeFailed": "No se pudo revocar",
|
||||
"deleteFailed": "No se pudo eliminar",
|
||||
"copyField": "Copiar {{field}}",
|
||||
"copied": "{{what}} copiado"
|
||||
},
|
||||
"google": {
|
||||
"guideToggle": "Guía de configuración paso a paso",
|
||||
"step1": {
|
||||
"title": "Crear un proyecto de Google Cloud",
|
||||
"body": "Abre la página de creación de proyectos, dale un nombre (por ejemplo “Officer”) y haz clic en:",
|
||||
"link": "New Project"
|
||||
},
|
||||
"step2": {
|
||||
"title": "Activar las APIs",
|
||||
"body": "Abre la biblioteca de APIs, busca cada una de estas y haz clic en:",
|
||||
"link": "API Library"
|
||||
},
|
||||
"step3": {
|
||||
"title": "Configurar la pantalla de consentimiento de OAuth",
|
||||
"link": "OAuth Branding",
|
||||
"appName": "Pon el nombre de tu organización, o “Officer”, como:",
|
||||
"supportEmail": "Pon el correo del administrador como:",
|
||||
"devContact": "Añade el correo del administrador en:",
|
||||
"save": "Después haz clic en:"
|
||||
},
|
||||
"step4": {
|
||||
"title": "Definir la audiencia",
|
||||
"link": "OAuth Audience",
|
||||
"internal": "Si tu equipo usa Google Workspace, elige — no hace falta verificación:",
|
||||
"external": "En caso contrario elige, y añade los correos de tu equipo en Test users (obligatorio mientras la aplicación no esté verificada; límite de 100 usuarios de prueba):"
|
||||
},
|
||||
"step5": {
|
||||
"title": "Añadir ámbitos",
|
||||
"link": "Data Access",
|
||||
"body": "Abre el acceso a datos, busca y añade los ámbitos de abajo, y luego haz clic en:",
|
||||
"note": "Google clasifica el ámbito del calendario como sensible y el de Gmail como restringido. Eso no es un problema para aplicaciones Internal (Google Workspace) ni para aplicaciones External en modo de prueba; publicar en producción con ámbitos restringidos requiere verificación de Google."
|
||||
},
|
||||
"step6": {
|
||||
"title": "Crear credenciales OAuth",
|
||||
"link": "Clients",
|
||||
"body": "Abre la página de clientes y haz clic en:",
|
||||
"appType": "Tipo de aplicación:",
|
||||
"nameAnything": "Nombre: el que quieras (por ejemplo “Officer”)",
|
||||
"redirectUris": "Authorized redirect URIs — añade:",
|
||||
"thenClick": "Después haz clic en:"
|
||||
},
|
||||
"step7": {
|
||||
"title": "Copiar las credenciales",
|
||||
"body": "Un cuadro de diálogo muestra tu client ID y tu client secret. Copia ambos y pégalos en los campos de abajo."
|
||||
},
|
||||
"scopeGmail": "Leer correos",
|
||||
"scopeCalendar": "Leer eventos del calendario",
|
||||
"verifying": "Verificando credenciales…",
|
||||
"valid": "Credenciales válidas",
|
||||
"invalid": "Credenciales no válidas",
|
||||
"verifyFailed": "La solicitud de verificación falló",
|
||||
"clientId": "Client ID",
|
||||
"clientSecret": "Client Secret",
|
||||
"redirectUri": "URI de redirección",
|
||||
"redirectUriHint": "Añade este URI a los URIs de redirección autorizados de tu cliente OAuth de Google",
|
||||
"saved": "Configuración de Google OAuth guardada",
|
||||
"saveFailed": "No se pudo guardar la configuración de Google OAuth"
|
||||
},
|
||||
"googleAccount": {
|
||||
"title": "Cuenta de Google",
|
||||
"hint": "Conecta tu cuenta de Google para Calendario, Contactos y otros servicios de Google.",
|
||||
"connected": "Conectada",
|
||||
"disconnect": "Desconectar",
|
||||
"connect": "Conectar cuenta de Google",
|
||||
"connectedToast": "Cuenta de Google conectada",
|
||||
"connectFailed": "No se pudo conectar la cuenta de Google",
|
||||
"disconnectedToast": "Cuenta de Google desconectada",
|
||||
"disconnectFailed": "No se pudo desconectar la cuenta de Google"
|
||||
},
|
||||
"apify": {
|
||||
"token": "Token de API",
|
||||
"saved": "Token de API de Apify guardado",
|
||||
"saveFailed": "No se pudo guardar el token de API de Apify"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
"userMenu": {
|
||||
"profile": "Perfil",
|
||||
"systemSettings": "Ajustes del sistema",
|
||||
"signOut": "Cerrar sesión",
|
||||
"ai": "IA",
|
||||
"integrations": "Integraciones",
|
||||
"userManagement": "Gestión de usuarios"
|
||||
},
|
||||
"tabName": "Nombre de la pestaña",
|
||||
"tabNameHint": "Haz clic para renombrar esta pestaña — bórralo para volver al nombre de la página",
|
||||
"logoAlt": "Officer",
|
||||
"navigation": "Navegación",
|
||||
"jobsRunning": "Trabajo en ejecución",
|
||||
"jobsNothingRunning": "Nada en ejecución",
|
||||
"jobsQueued": "Trabajos en cola"
|
||||
},
|
||||
"nav": {
|
||||
"home": "Inicio",
|
||||
"files": "Archivos",
|
||||
"chat": "Chat",
|
||||
"commandHq": "Command HQ",
|
||||
"editor": "Editor",
|
||||
"jobs": "Trabajos",
|
||||
"terminal": "Terminal",
|
||||
"browser": "Navegador",
|
||||
"monitor": "Monitor",
|
||||
"activity": "Actividad",
|
||||
"dashboards": "Dashboards",
|
||||
"sendOff": "SendOff",
|
||||
"calendar": "Calendario",
|
||||
"contacts": "Contactos",
|
||||
"appStore": "Tienda de aplicaciones",
|
||||
"marketplace": "Marketplace",
|
||||
"codeEditor": "Editor de código"
|
||||
},
|
||||
"theme": {
|
||||
"pillTitle": "Tema: {{name}} · ctrl+shift+←/→ recorrer · ctrl+shift+space explorar · ctrl+shift+f {{starAction}}",
|
||||
"pillLabel": "Tema: {{name}}",
|
||||
"star": "marcar",
|
||||
"unstar": "desmarcar",
|
||||
"cycleHint": "ctrl+shift+←/→ recorrer · ctrl+shift+space explorar",
|
||||
"everyShortcutHint": "ctrl+? para todos los atajos",
|
||||
"favouritesOnly": "Solo favoritos ({{count}})",
|
||||
"favourites": "Favoritos ({{count}})",
|
||||
"noFavourites": "Todavía no hay favoritos — marca uno con el botón de su fila.",
|
||||
"starNamed": "Marcar {{name}}",
|
||||
"unstarNamed": "Desmarcar {{name}}",
|
||||
"addToFavourites": "Añadir a favoritos",
|
||||
"removeFromFavourites": "Quitar de favoritos",
|
||||
"addToFavouritesKey": "Añadir a favoritos (f)",
|
||||
"removeFromFavouritesKey": "Quitar de favoritos (f)",
|
||||
"active": "activo",
|
||||
"browseHint": "j/k o ↑/↓ mover · l o → previsualizar · f marcar · h o ← reiniciar · enter mantener · esc cancelar"
|
||||
},
|
||||
"spotlight": {
|
||||
"placeholder": "Ir a…",
|
||||
"noResults": "Nada coincide con “{{query}}”."
|
||||
},
|
||||
"shortcutsModal": {
|
||||
"title": "Atajos de teclado",
|
||||
"hint": "Ctrl+? · Esc para cerrar"
|
||||
},
|
||||
"screenError": {
|
||||
"title": "Esta pantalla no se pudo mostrar",
|
||||
"description": "No se ha perdido nada — este es un fallo de visualización, no de datos. Las transcripciones del agente, los trabajos y los archivos están intactos.",
|
||||
"unknownError": "Error desconocido",
|
||||
"tryAgain": "Reintentar",
|
||||
"reload": "Recargar",
|
||||
"goHome": "Ir al inicio",
|
||||
"resetHint": "Si vuelve a fallar en el mismo sitio, la causa habitual es un diseño de panel guardado. Reiniciar devuelve los paneles de todas las pantallas a sus valores predeterminados.",
|
||||
"dashboardsSafe": "Tus dashboards no se ven afectados.",
|
||||
"resetting": "Reiniciando…",
|
||||
"resetConfirm": "Sí — reiniciar el diseño de todas las pantallas",
|
||||
"reset": "Reiniciar diseños de pantallas"
|
||||
},
|
||||
"rescan": {
|
||||
"label": "Reanalizar elementos",
|
||||
"title": "Reanalizar officer-items (aplicar cambios sin reiniciar)",
|
||||
"failed": "No se pudieron reanalizar los elementos",
|
||||
"done": "Elementos reanalizados — {{summary}}",
|
||||
"counts": {
|
||||
"tasks": {
|
||||
"one": "{{count}} tarea",
|
||||
"other": "{{count}} tareas"
|
||||
},
|
||||
"agents": {
|
||||
"one": "{{count}} agente",
|
||||
"other": "{{count}} agentes"
|
||||
},
|
||||
"tools": {
|
||||
"one": "{{count}} herramienta",
|
||||
"other": "{{count}} herramientas"
|
||||
},
|
||||
"skills": {
|
||||
"one": "{{count}} skill",
|
||||
"other": "{{count}} skills"
|
||||
},
|
||||
"processes": {
|
||||
"one": "{{count}} proceso",
|
||||
"other": "{{count}} procesos"
|
||||
},
|
||||
"affordances": {
|
||||
"one": "{{count}} affordance",
|
||||
"other": "{{count}} affordances"
|
||||
}
|
||||
},
|
||||
"newChatsOnly": "Se aplica a los chats nuevos — uno abierto mantiene lo que tenía al empezar.",
|
||||
"noDescription": {
|
||||
"one": "{{count}} skill no tiene descripción, así que no se ofrece a ningún agente.",
|
||||
"other": "{{count}} skills no tienen descripción, así que no se ofrecen a ningún agente."
|
||||
}
|
||||
},
|
||||
"bugReport": {
|
||||
"label": "Informar de un error",
|
||||
"title": "Informar de un error",
|
||||
"describePlaceholder": "Describe qué ha ido mal...",
|
||||
"screenshot": "Captura de pantalla",
|
||||
"noScreenshot": "No se obtuvo ninguna captura de pantalla",
|
||||
"collectedNote": "Los registros de la consola, la información del navegador y los errores recientes de la API se recogen automáticamente.",
|
||||
"submitted": "Informe de error enviado",
|
||||
"failed": "No se pudo enviar el informe de error",
|
||||
"submit": "Enviar"
|
||||
},
|
||||
"auth": {
|
||||
"signInTitle": "Bienvenido de nuevo",
|
||||
"signInSubtitle": "Inicia sesión en tu cuenta",
|
||||
"emailOrUsername": "Correo electrónico o nombre de usuario",
|
||||
"password": "Contraseña",
|
||||
"forgotPassword": "¿Olvidaste tu contraseña?",
|
||||
"emailPlaceholder": "tu@ejemplo.com",
|
||||
"passwordPlaceholder": "Tu contraseña",
|
||||
"forgotTitle": "Recuperar contraseña",
|
||||
"forgotSubtitle": "Introduce tu correo electrónico para recibir un enlace de restablecimiento",
|
||||
"email": "Correo electrónico",
|
||||
"backToLogin": "Volver al inicio de sesión",
|
||||
"checkEmail": "Revisa tu correo electrónico",
|
||||
"checkEmailHint": "Haz clic en él para restablecer tu contraseña.",
|
||||
"resetOk": "Contraseña restablecida correctamente. Inicia sesión.",
|
||||
"verifying": "Verificando…",
|
||||
"invalidLink": "Enlace no válido",
|
||||
"invalidLinkHint": "Este enlace de restablecimiento no es válido o ha caducado.",
|
||||
"resetTitle": "Restablecer contraseña",
|
||||
"resetSubtitle": "Introduce tu nueva contraseña",
|
||||
"confirmPassword": "Confirmar contraseña",
|
||||
"minChars": "Mínimo 12 caracteres",
|
||||
"confirmPlaceholder": "Confirma tu contraseña",
|
||||
"bootstrapTitle": "Bienvenido, administrador",
|
||||
"bootstrapSubtitle": "Crea tu cuenta de administrador",
|
||||
"name": "Nombre",
|
||||
"username": "Nombre de usuario",
|
||||
"namePlaceholder": "Tu nombre",
|
||||
"adminEmailPlaceholder": "admin@ejemplo.com",
|
||||
"bootstrapOk": "Cuenta de administrador creada. Inicia sesión.",
|
||||
"debug": {
|
||||
"controls": "Controles:",
|
||||
"camera": "Cámara:",
|
||||
"model": "Modelo:"
|
||||
}
|
||||
},
|
||||
"shortcuts": {
|
||||
"navigation": "Navegación",
|
||||
"spotlight": "Spotlight — ve a cualquier pantalla, plugin o ajuste por su nombre",
|
||||
"thisList": "Esta lista",
|
||||
"themes": "Temas",
|
||||
"themesNote": "El explorador aplica el tema a medida que te mueves, para que veas la aplicación cambiar en vez de leer nombres.",
|
||||
"openBrowser": "Abrir el explorador de temas",
|
||||
"prevNextTheme": "Tema anterior / siguiente, sin abrir nada",
|
||||
"starCurrent": "Marcar o desmarcar el tema actual, desde cualquier lugar",
|
||||
"inBrowser": "En el explorador de temas",
|
||||
"moveList": "Moverse por la lista",
|
||||
"applyKeepBrowsing": "Aplicar el tema enfocado y seguir explorando",
|
||||
"restoreStart": "Volver al tema con el que empezaste",
|
||||
"starFocused": "Marcar o desmarcar el tema enfocado",
|
||||
"keepLooking": "Mantener lo que estás viendo",
|
||||
"cancelRestore": "Cancelar y restaurar",
|
||||
"inSpotlight": "En Spotlight",
|
||||
"moveResults": "Moverse por los resultados",
|
||||
"goThere": "Ir allí",
|
||||
"close": "Cerrar",
|
||||
"chat": "Chat",
|
||||
"chatNote": "La recuperación solo usa la tecla de flecha en un compositor VACÍO, y cualquier otra tecla termina la recuperación — así que un borrador nunca se sobrescribe.",
|
||||
"prevNextPrompt": "Prompt anterior / siguiente que enviaste, al estilo de una shell",
|
||||
"send": "Enviar · en un compositor vacío con algo en cola, envía eso ahora",
|
||||
"newline": "Salto de línea",
|
||||
"stopTurn": "Detener el turno que se está generando"
|
||||
},
|
||||
"jobs": {
|
||||
"runningQueued": "En ejecución y en cola",
|
||||
"loading": "Cargando…",
|
||||
"nothingRunning": "Nada en ejecución",
|
||||
"history": "Historial",
|
||||
"clearAll": "Limpiar todo",
|
||||
"selectJob": "Selecciona un trabajo",
|
||||
"notFound": "Trabajo no encontrado.",
|
||||
"backToJobs": "Volver a trabajos",
|
||||
"stop": "Detener",
|
||||
"testNotification": "Enviar una notificación de prueba a todos los canales configurados",
|
||||
"search": "Buscar…",
|
||||
"deleteFinished": "Eliminar todos los trabajos terminados",
|
||||
"noChannels": "No hay canales configurados",
|
||||
"notifyFailed": "No se pudo enviar la notificación",
|
||||
"stopRequested": "Detención solicitada",
|
||||
"stopFailed": "No se pudo detener",
|
||||
"titles": "Títulos",
|
||||
"download": "Descargar",
|
||||
"input": "Entrada",
|
||||
"output": "Salida",
|
||||
"steps": "Pasos",
|
||||
"selectStep": "Selecciona un paso o subtarea para ver la salida",
|
||||
"noOutput": "Todavía no hay salida",
|
||||
"tokens": "Tokens:",
|
||||
"cost": "Coste:",
|
||||
"status": {
|
||||
"running": "En ejecución",
|
||||
"queued": "En cola",
|
||||
"completed": "Completado",
|
||||
"failed": "Fallido",
|
||||
"stopped": "Detenido",
|
||||
"interrupted": "Interrumpido"
|
||||
},
|
||||
"failedWithCode": "Fallido (salida {{code}})"
|
||||
},
|
||||
"activity": {
|
||||
"title": "Actividad",
|
||||
"backgroundTasks": "Tareas en segundo plano",
|
||||
"detached": "Separada",
|
||||
"backToList": "Volver a la lista",
|
||||
"selectTask": "Selecciona una tarea para seguir su salida en directo",
|
||||
"noneRunning": "ninguna en ejecución"
|
||||
},
|
||||
"passkeyGate": {
|
||||
"setUp": "Configura tu passkey"
|
||||
},
|
||||
"chatHistory": {
|
||||
"loadFailed": "No se pudo cargar esta conversación: {{reason}}"
|
||||
},
|
||||
"capability": {
|
||||
"frontmatter": "Frontmatter",
|
||||
"noMatches": "Sin coincidencias",
|
||||
"emptyFile": "Archivo vacío",
|
||||
"createFailed": "No se pudo crear el {{kind}}",
|
||||
"deleteFailed": "No se pudo eliminar el {{kind}}",
|
||||
"searchPlaceholder": "Buscar {{kind}}…",
|
||||
"selectOne": "Selecciona un {{kind}}",
|
||||
"selectToView": "Selecciona un {{kind}} para ver su contenido",
|
||||
"delete": "Eliminar",
|
||||
"createPrompt": "Ayúdame a crear el contenido de este nuevo archivo de {{kind}}",
|
||||
"improvePrompt": "Ayúdame a entender y mejorar este archivo de {{kind}}"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,704 @@
|
||||
{
|
||||
"common": {
|
||||
"save": "Enregistrer",
|
||||
"saving": "Enregistrement...",
|
||||
"cancel": "Annuler"
|
||||
},
|
||||
"settings": {
|
||||
"profile": {
|
||||
"userData": {
|
||||
"email": "E-mail",
|
||||
"username": "Nom d'utilisateur",
|
||||
"name": "Nom",
|
||||
"namePlaceholder": "Votre nom",
|
||||
"updated": "Profil mis à jour",
|
||||
"updateFailed": "Échec de la mise à jour du profil",
|
||||
"selectImage": "Veuillez sélectionner un fichier image",
|
||||
"imageTooLarge": "L'image doit faire moins de 384 Ko"
|
||||
},
|
||||
"apiKeys": {
|
||||
"intro": "Une clé API vous connecte comme vous-même, sans mot de passe et sans expirer. Une clé fonctionne partout — une clé n'est pas liée à une application. Donner sa propre clé à chaque appareil sert seulement à pouvoir en couper une sans toucher aux autres. Envoyez-la comme",
|
||||
"loading": "Chargement des clés…",
|
||||
"mintedTitle": "Clé pour “{{name}}” — affichée une seule fois",
|
||||
"savedIt": "Je l'ai enregistrée",
|
||||
"copyKey": "Copier la clé",
|
||||
"copied": "Clé copiée",
|
||||
"copyFailed": "Impossible de copier — sélectionnez et copiez manuellement",
|
||||
"newKey": "Nouvelle clé",
|
||||
"namePlaceholder": "iPhone, application Musique, CLI du portable…",
|
||||
"create": "Créer",
|
||||
"createFailed": "Impossible de créer la clé",
|
||||
"noneYet": "Aucune clé pour l'instant.",
|
||||
"noneInUse": "Aucune clé utilisée.",
|
||||
"revoked": "révoquée",
|
||||
"expired": "expirée",
|
||||
"lastUsed": "dernière utilisation {{date}}",
|
||||
"neverUsed": "jamais utilisée",
|
||||
"forget": "Supprimer cet enregistrement",
|
||||
"forgetFailed": "Impossible de le supprimer",
|
||||
"revoke": "Révoquer",
|
||||
"revokeDone": "Révoquée — tout ce qui utilisait cette clé est déconnecté",
|
||||
"revokeFailed": "Impossible de révoquer",
|
||||
"hideDead": "Masquer les révoquées et expirées",
|
||||
"showDead": {
|
||||
"one": "Afficher {{count}} clé révoquée ou expirée",
|
||||
"other": "Afficher {{count}} clés révoquées ou expirées"
|
||||
}
|
||||
},
|
||||
"voice": {
|
||||
"label": "Voix",
|
||||
"refresh": "Actualiser les voix",
|
||||
"serverDefault": "Valeur par défaut du serveur",
|
||||
"hint": "Choisissez une voix pour la synthèse vocale. Laissez la valeur par défaut du serveur pour utiliser la voix configurée par l'administrateur.",
|
||||
"stop": "Arrêter",
|
||||
"listen": "Écouter"
|
||||
},
|
||||
"dock": {
|
||||
"visible": "Visibles",
|
||||
"hidden": "Masqués",
|
||||
"dragToShow": "Faites glisser des éléments ici pour les afficher dans le Dock",
|
||||
"allVisible": "Tous les éléments sont visibles",
|
||||
"reset": "Rétablir les valeurs par défaut"
|
||||
},
|
||||
"languages": {
|
||||
"spokenLabel": "Langues que vous parlez",
|
||||
"addLanguage": "Ajouter une langue...",
|
||||
"defaultLabel": "Langue par défaut",
|
||||
"defaultHint": "Utilisée pour la localisation de l'interface.",
|
||||
"translateToLabel": "Traduire vers",
|
||||
"translateToHint": "Langue cible pour traduire le contenu que vous ne parlez pas."
|
||||
},
|
||||
"changePassword": {
|
||||
"currentPasswordLabel": "Mot de passe actuel",
|
||||
"currentPasswordPlaceholder": "Mot de passe actuel",
|
||||
"newPasswordLabel": "Nouveau mot de passe",
|
||||
"newPasswordPlaceholder": "Nouveau mot de passe",
|
||||
"confirmPasswordLabel": "Confirmer le nouveau mot de passe",
|
||||
"confirmPasswordPlaceholder": "Confirmez le nouveau mot de passe",
|
||||
"changeButton": "Changer le mot de passe",
|
||||
"changingButton": "Modification...",
|
||||
"passwordChanged": "Mot de passe modifié",
|
||||
"passwordChangeFailed": "Échec de la modification du mot de passe"
|
||||
},
|
||||
"models": {
|
||||
"defaultModel": "Modèle par défaut",
|
||||
"sameAsChat": "Identique à la valeur par défaut du Chat",
|
||||
"taskDefaultsSaved": "Valeurs par défaut des tâches enregistrées",
|
||||
"saveFailed": "Échec de l'enregistrement des paramètres",
|
||||
"defaultChat": "Modèle par défaut du Chat",
|
||||
"defaultChatHint": "Utilisé au démarrage d'un nouveau Chat depuis l'écran d'accueil",
|
||||
"defaultProject": "Modèle par défaut du projet",
|
||||
"defaultProjectHint": "Utilisé au démarrage d'un nouveau Chat dans un Dashboard de projet",
|
||||
"defaultTask": "Modèle par défaut des tâches",
|
||||
"defaultTaskHint": "Utilisé lors de l'exécution de tâches depuis l'explorateur de fichiers",
|
||||
"aiDefaultsSaved": "Valeurs par défaut des modèles IA enregistrées"
|
||||
}
|
||||
},
|
||||
"sections": {
|
||||
"profile": {
|
||||
"label": "Profil",
|
||||
"hint": "avatar, nom, e-mail",
|
||||
"description": "Avatar, nom et e-mail"
|
||||
},
|
||||
"changePassword": {
|
||||
"label": "Changer le mot de passe",
|
||||
"description": "Mettez à jour votre mot de passe"
|
||||
},
|
||||
"apiKeys": {
|
||||
"label": "Clés API",
|
||||
"hint": "clés qui vous connectent comme vous-même",
|
||||
"description": "Clés qui vous connectent comme vous-même, révocables une à une"
|
||||
},
|
||||
"languages": {
|
||||
"label": "Langues",
|
||||
"hint": "parlées et traduction",
|
||||
"description": "Parlées, par défaut et traduction"
|
||||
},
|
||||
"voice": {
|
||||
"label": "Voix",
|
||||
"hint": "synthèse vocale",
|
||||
"description": "Préférence de voix pour la synthèse vocale"
|
||||
},
|
||||
"dock": {
|
||||
"label": "Dock",
|
||||
"hint": "choisir et réorganiser",
|
||||
"description": "Choisir et réorganiser les éléments du Dock"
|
||||
},
|
||||
"myModels": {
|
||||
"label": "Mes modèles",
|
||||
"description": "Afficher ou masquer des modèles pour vous-même"
|
||||
},
|
||||
"channelModels": {
|
||||
"label": "Modèles du canal",
|
||||
"description": "Modèles accessibles aux comptes non propriétaires"
|
||||
},
|
||||
"defaultModels": {
|
||||
"label": "Modèles par défaut",
|
||||
"description": "Modèles par défaut pour le Chat, les projets et les tâches"
|
||||
},
|
||||
"providers": {
|
||||
"label": "Fournisseurs",
|
||||
"hint": "harnesses",
|
||||
"description": "Fournisseurs d'IA distants et locaux"
|
||||
},
|
||||
"tts": {
|
||||
"label": "Synthèse vocale",
|
||||
"description": "Fournisseur et voix pour la synthèse vocale"
|
||||
},
|
||||
"stt": {
|
||||
"label": "Reconnaissance vocale",
|
||||
"description": "URL du serveur Whisper"
|
||||
},
|
||||
"ocr": {
|
||||
"label": "OCR",
|
||||
"description": "Modèle de vision pour l'extraction de texte"
|
||||
},
|
||||
"maintenance": {
|
||||
"label": "Reconstruire et redémarrer",
|
||||
"hint": "maintenance",
|
||||
"description": "Charger les modifications sans terminal"
|
||||
},
|
||||
"smtp": {
|
||||
"label": "SMTP",
|
||||
"hint": "courrier sortant",
|
||||
"description": "Fournisseur de distribution d'e-mails"
|
||||
},
|
||||
"googleOauth": {
|
||||
"label": "Google OAuth",
|
||||
"description": "Client ID et secret pour les API Google"
|
||||
},
|
||||
"apify": {
|
||||
"label": "Apify",
|
||||
"description": "Jeton API pour les acteurs de web scraping"
|
||||
},
|
||||
"davSync": {
|
||||
"label": "Synchronisation Calendrier et Contacts",
|
||||
"hint": "mots de passe d'application",
|
||||
"description": "Mots de passe par appareil pour les clients CalDAV/CardDAV"
|
||||
},
|
||||
"accounts": {
|
||||
"label": "Comptes",
|
||||
"hint": "utilisateurs",
|
||||
"description": "Qui a accès, et à quoi"
|
||||
},
|
||||
"permissions": {
|
||||
"label": "Permissions",
|
||||
"description": "Ce que chaque rôle peut atteindre"
|
||||
}
|
||||
},
|
||||
"searchPlaceholder": "Rechercher dans les paramètres…",
|
||||
"selectSection": "Sélectionnez une section",
|
||||
"groups": {
|
||||
"models": "Modèles",
|
||||
"defaults": "Valeurs par défaut",
|
||||
"providers": "Fournisseurs",
|
||||
"ai": "IA",
|
||||
"maintenance": "Maintenance",
|
||||
"email": "E-mail"
|
||||
},
|
||||
"nav": {
|
||||
"profile": "Profil",
|
||||
"ai": "IA",
|
||||
"system": "Système",
|
||||
"integrations": "Intégrations",
|
||||
"users": "Utilisateurs"
|
||||
},
|
||||
"ai": {
|
||||
"noModels": "Aucun modèle disponible. Configurez les clés API dans Fournisseurs.",
|
||||
"dragToShow": "Faites glisser des modèles ici pour les afficher",
|
||||
"allVisible": "Tous les modèles sont visibles",
|
||||
"dragToEnable": "Faites glisser des modèles ici pour les activer",
|
||||
"allEnabled": "Tous les modèles sont activés",
|
||||
"visible": "Visibles",
|
||||
"hidden": "Masqués",
|
||||
"enabled": "Activés",
|
||||
"disabled": "Désactivés",
|
||||
"refresh": "Actualiser les modèles depuis les fournisseurs",
|
||||
"refreshed": "Modèles actualisés",
|
||||
"refreshFailed": "Échec de l'actualisation des modèles"
|
||||
},
|
||||
"integrations": {
|
||||
"enterprise": "Entreprise",
|
||||
"personal": "Personnel"
|
||||
},
|
||||
"server": {
|
||||
"loading": "Chargement…",
|
||||
"provider": "Fournisseur",
|
||||
"model": "Modèle",
|
||||
"voice": "Voix",
|
||||
"ocr": {
|
||||
"serverUrl": "URL du serveur du modèle de vision",
|
||||
"refreshModels": "Actualiser les modèles",
|
||||
"selectModel": "Sélectionnez un modèle",
|
||||
"saved": "Paramètres OCR enregistrés",
|
||||
"saveFailed": "Échec de l'enregistrement des paramètres OCR",
|
||||
"reachable": "Le serveur du modèle de vision est accessible"
|
||||
},
|
||||
"stt": {
|
||||
"serverUrl": "URL du serveur Whisper",
|
||||
"saved": "Paramètres de reconnaissance vocale enregistrés",
|
||||
"saveFailed": "Échec de l'enregistrement des paramètres de reconnaissance vocale",
|
||||
"reachable": "Le serveur Whisper est accessible"
|
||||
},
|
||||
"tts": {
|
||||
"openaiCompatible": "Compatible OpenAI (Kokoro, OpenAI, etc.)",
|
||||
"elevenLabs": "ElevenLabs",
|
||||
"refreshVoices": "Actualiser les voix",
|
||||
"selectVoice": "Sélectionnez une voix",
|
||||
"saved": "Paramètres de synthèse vocale enregistrés",
|
||||
"saveFailed": "Échec de l'enregistrement des paramètres de synthèse vocale",
|
||||
"playing": "Lecture de l'audio de test",
|
||||
"testFailed": "Échec du test — impossible de joindre le serveur de synthèse vocale"
|
||||
},
|
||||
"smtp": {
|
||||
"mailhog": "MailHog (test local)",
|
||||
"resend": "Resend",
|
||||
"custom": "SMTP personnalisé",
|
||||
"apiKey": "Clé API",
|
||||
"host": "Hôte",
|
||||
"port": "Port",
|
||||
"username": "Nom d'utilisateur",
|
||||
"password": "Mot de passe",
|
||||
"tls": "TLS / SSL",
|
||||
"fromName": "Nom de l'expéditeur",
|
||||
"fromEmail": "E-mail de l'expéditeur",
|
||||
"sendTest": "Envoyer un e-mail de test",
|
||||
"userExample": "utilisateur@exemple.com",
|
||||
"recipientExample": "destinataire@exemple.com",
|
||||
"saved": "Paramètres SMTP enregistrés",
|
||||
"saveFailed": "Échec de l'enregistrement des paramètres SMTP",
|
||||
"connected": "Connexion réussie",
|
||||
"testSent": "E-mail de test envoyé à {{email}}"
|
||||
},
|
||||
"maintenance": {
|
||||
"updateTitle": "Mettre à jour la plateforme",
|
||||
"updateHint": "Ne change rien à ce qui est en cours d'exécution. Ce dont la mise à jour a besoin est listé ici, et le choix vous revient.",
|
||||
"rebuild": "Reconstruire le frontend",
|
||||
"restart": "Redémarrer le serveur",
|
||||
"upToDate": "Déjà à jour",
|
||||
"rebuilt": "Frontend reconstruit — actualisez cette page pour le voir",
|
||||
"restarting": "Redémarrage — la page se reconnectera d'elle-même",
|
||||
"pulled": {
|
||||
"one": "{{count}} commit récupéré",
|
||||
"other": "{{count}} commits récupérés"
|
||||
}
|
||||
},
|
||||
"harnesses": {
|
||||
"piMono": "Pi Mono",
|
||||
"checking": "Vérification…",
|
||||
"localProviders": "Fournisseurs locaux",
|
||||
"remoteProviders": "Fournisseurs distants",
|
||||
"connect": "Connecter",
|
||||
"authenticate": "Authentifier",
|
||||
"connectLocal": "Connecter un fournisseur local",
|
||||
"connectProvider": "Connecter un fournisseur",
|
||||
"none": "Aucun fournisseur trouvé.",
|
||||
"updatePiMono": "Mettre à jour Pi Mono",
|
||||
"removeProvider": "Supprimer le fournisseur",
|
||||
"namePlaceholder": "Nom (ex. Mon Ollama)",
|
||||
"username": "Nom d'utilisateur",
|
||||
"password": "Mot de passe",
|
||||
"apiKey": "Clé API",
|
||||
"editKey": "Modifier la clé",
|
||||
"disconnect": "Déconnecter le fournisseur",
|
||||
"saveKey": "Enregistrer la clé",
|
||||
"enterKey": "Saisissez la clé API",
|
||||
"search": "Rechercher des fournisseurs…",
|
||||
"connectFailed": "Échec de la connexion",
|
||||
"authFailed": "Échec de l'authentification",
|
||||
"saveFailed": "Échec de l'enregistrement du fournisseur",
|
||||
"connectedTo": "Connecté à {{name}}"
|
||||
}
|
||||
},
|
||||
"users": {
|
||||
"loadingPermissions": "Chargement des permissions…",
|
||||
"permissionsFailed": "Impossible de charger les permissions.",
|
||||
"deniedUnlessGranted": "Tout est refusé sauf ce qui est accordé ici.",
|
||||
"read": "Lecture",
|
||||
"full": "Total",
|
||||
"noAccess": "Aucun accès",
|
||||
"role": "Rôle",
|
||||
"permissionsSaved": "Ce que les {{role}} peuvent atteindre a été enregistré",
|
||||
"copyBeforeClosing": "Copiez ce dont vous avez besoin avant de fermer — rien de tout cela ne peut être réaffiché.",
|
||||
"password": "Mot de passe",
|
||||
"linuxAccount": "Compte Linux",
|
||||
"sshKey": "Leur clé publique SSH",
|
||||
"addToGitea": "Ajoutez ceci à leur compte Gitea",
|
||||
"linuxIncomplete": "Le compte fonctionne, mais sa partie Linux ne s'est pas terminée",
|
||||
"done": "Terminé",
|
||||
"addAccount": "Ajouter un compte",
|
||||
"newAccount": "Nouveau compte",
|
||||
"email": "E-mail",
|
||||
"name": "Nom",
|
||||
"username": "Nom d'utilisateur",
|
||||
"usernameHint": "Lettres, chiffres, points, tirets et underscores.",
|
||||
"generatePassword": "Générer un mot de passe",
|
||||
"copyPassword": "Copier le mot de passe",
|
||||
"passwordHint": "Au moins 12 caractères, avec majuscules et minuscules, un chiffre et un symbole.",
|
||||
"sshPlaceholder": "ssh-ed25519 AAAAC3Nza… ana@portable",
|
||||
"passwordCopied": "Mot de passe copié",
|
||||
"loadingAccounts": "Chargement des comptes…",
|
||||
"accountsFailed": "Impossible de charger les comptes.",
|
||||
"removeAccount": "Supprimer le compte",
|
||||
"generateNewPassword": "Générer un nouveau mot de passe",
|
||||
"serverOwner": "Propriétaire du serveur",
|
||||
"copySshTitle": "Copier leur clé publique SSH (ajoutez-la à leur compte Gitea)",
|
||||
"resetPasswordTitle": "Générer un nouveau mot de passe — affiché une seule fois, et les déconnecte partout",
|
||||
"copyNewPassword": "Copier le nouveau mot de passe",
|
||||
"publicKeyCopied": "Clé publique copiée",
|
||||
"copyFailed": "Impossible de copier — sélectionnez-la et copiez-la à la main",
|
||||
"repairFor": "Réparer le compte Linux de {{email}}",
|
||||
"linuxAccountFor": "Compte Linux : {{osUser}} — cliquez pour réparer ou remplacer sa clé SSH",
|
||||
"copySshFor": "Copier la clé publique SSH de {{email}}",
|
||||
"resetPasswordFor": "Réinitialiser le mot de passe de {{email}}",
|
||||
"removeFor": "Supprimer {{email}}",
|
||||
"provisionPrompt": "Compte Linux pour {{email}}.\n\nCollez une clé publique SSH pour lui permettre de se connecter en SSH, ou laissez vide pour conserver l'actuelle.",
|
||||
"createLinux": "Créer un compte Linux",
|
||||
"noLinux": "Aucun compte Linux — cliquez pour en créer un",
|
||||
"removeTitle": "Supprimer {{email}} ?",
|
||||
"removeBody": "Cela supprime le compte et tout ce qui lui appartient — passkeys, Dashboards, dispositions enregistrées, et tout ce qu'un plugin installé conserve pour lui. Cette action est irréversible.",
|
||||
"resetTitle": "Réinitialiser le mot de passe de {{email}} ?",
|
||||
"resetBody": "Un nouveau mot de passe est généré et vous est affiché une seule fois — il n'est stocké nulle part et ne peut plus être consulté ensuite. Son mot de passe actuel cesse de fonctionner immédiatement, et il est déconnecté partout.",
|
||||
"resetLinuxNote": "Son compte Linux ({{osUser}}) n'est pas affecté : il n'a pas de mot de passe, et les clés SSH restent inchangées."
|
||||
},
|
||||
"dav": {
|
||||
"newDevice": "Nouvel appareil",
|
||||
"create": "Créer",
|
||||
"none": "Aucun appareil pour l'instant.",
|
||||
"placeholder": "Pixel 9, iPad, Thunderbird…",
|
||||
"revoke": "Révoquer",
|
||||
"delete": "Supprimer",
|
||||
"copyFailed": "Impossible de copier — sélectionnez et copiez manuellement",
|
||||
"createFailed": "Impossible de créer le mot de passe d'application",
|
||||
"revoked": "Révoqué — cet appareil arrêtera de se synchroniser",
|
||||
"revokeFailed": "Impossible de révoquer",
|
||||
"deleteFailed": "Impossible de supprimer",
|
||||
"copyField": "Copier {{field}}",
|
||||
"copied": "{{what}} copié"
|
||||
},
|
||||
"google": {
|
||||
"guideToggle": "Guide de configuration pas à pas",
|
||||
"step1": {
|
||||
"title": "Créer un projet Google Cloud",
|
||||
"body": "Ouvrez la page de création de projet, donnez-lui un nom (par exemple “Officer”), et cliquez sur :",
|
||||
"link": "New Project"
|
||||
},
|
||||
"step2": {
|
||||
"title": "Activer les API",
|
||||
"body": "Ouvrez la bibliothèque d'API, recherchez chacune d'entre elles, puis cliquez sur :",
|
||||
"link": "API Library"
|
||||
},
|
||||
"step3": {
|
||||
"title": "Configurer l'écran de consentement OAuth",
|
||||
"link": "OAuth Branding",
|
||||
"appName": "Définissez le nom de votre organisation, ou “Officer”, comme :",
|
||||
"supportEmail": "Définissez l'e-mail de votre administrateur comme :",
|
||||
"devContact": "Ajoutez l'e-mail de votre administrateur sous :",
|
||||
"save": "Puis cliquez sur :"
|
||||
},
|
||||
"step4": {
|
||||
"title": "Définir l'audience",
|
||||
"link": "OAuth Audience",
|
||||
"internal": "Si votre équipe utilise Google Workspace, choisissez — aucune vérification requise :",
|
||||
"external": "Sinon choisissez, et ajoutez les e-mails de votre équipe sous Test users (obligatoire tant que l'application n'est pas vérifiée ; limite de 100 utilisateurs de test) :"
|
||||
},
|
||||
"step5": {
|
||||
"title": "Ajouter des scopes",
|
||||
"link": "Data Access",
|
||||
"body": "Ouvrez l'accès aux données, recherchez et ajoutez les scopes ci-dessous, puis cliquez sur :",
|
||||
"note": "Google classe le scope du calendrier comme sensible et celui de Gmail comme restreint. Cela ne pose pas de problème pour les applications Internal (Google Workspace) ni pour les applications External en mode test ; publier en production avec des scopes restreints nécessite une vérification par Google."
|
||||
},
|
||||
"step6": {
|
||||
"title": "Créer des identifiants OAuth",
|
||||
"link": "Clients",
|
||||
"body": "Ouvrez la page des clients et cliquez sur :",
|
||||
"appType": "Type d'application :",
|
||||
"nameAnything": "Nom : n'importe lequel (par exemple “Officer”)",
|
||||
"redirectUris": "URI de redirection autorisés — ajoutez :",
|
||||
"thenClick": "Puis cliquez sur :"
|
||||
},
|
||||
"step7": {
|
||||
"title": "Copier les identifiants",
|
||||
"body": "Une boîte de dialogue affiche votre client ID et votre client secret. Copiez les deux et collez-les dans les champs ci-dessous."
|
||||
},
|
||||
"scopeGmail": "Lire les e-mails",
|
||||
"scopeCalendar": "Lire les événements du calendrier",
|
||||
"verifying": "Vérification des identifiants…",
|
||||
"valid": "Identifiants valides",
|
||||
"invalid": "Identifiants invalides",
|
||||
"verifyFailed": "La demande de vérification a échoué",
|
||||
"clientId": "Client ID",
|
||||
"clientSecret": "Client Secret",
|
||||
"redirectUri": "URI de redirection",
|
||||
"redirectUriHint": "Ajoutez cet URI aux URI de redirection autorisés de votre client OAuth Google",
|
||||
"saved": "Configuration Google OAuth enregistrée",
|
||||
"saveFailed": "Échec de l'enregistrement de la configuration Google OAuth"
|
||||
},
|
||||
"googleAccount": {
|
||||
"title": "Compte Google",
|
||||
"hint": "Connectez votre compte Google pour le Calendrier, les Contacts et d'autres services Google.",
|
||||
"connected": "Connecté",
|
||||
"disconnect": "Déconnecter",
|
||||
"connect": "Connecter le compte Google",
|
||||
"connectedToast": "Compte Google connecté",
|
||||
"connectFailed": "Échec de la connexion du compte Google",
|
||||
"disconnectedToast": "Compte Google déconnecté",
|
||||
"disconnectFailed": "Échec de la déconnexion du compte Google"
|
||||
},
|
||||
"apify": {
|
||||
"token": "Jeton API",
|
||||
"saved": "Jeton API Apify enregistré",
|
||||
"saveFailed": "Échec de l'enregistrement du jeton API Apify"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
"userMenu": {
|
||||
"profile": "Profil",
|
||||
"systemSettings": "Paramètres système",
|
||||
"signOut": "Se déconnecter",
|
||||
"ai": "IA",
|
||||
"integrations": "Intégrations",
|
||||
"userManagement": "Gestion des utilisateurs"
|
||||
},
|
||||
"tabName": "Nom de l'onglet",
|
||||
"tabNameHint": "Cliquez pour renommer cet onglet — videz-le pour revenir au nom de la page",
|
||||
"logoAlt": "Officer",
|
||||
"navigation": "Navigation",
|
||||
"jobsRunning": "Job en cours",
|
||||
"jobsNothingRunning": "Rien en cours",
|
||||
"jobsQueued": "Jobs en file d'attente"
|
||||
},
|
||||
"nav": {
|
||||
"home": "Accueil",
|
||||
"files": "Fichiers",
|
||||
"chat": "Chat",
|
||||
"commandHq": "Command HQ",
|
||||
"editor": "Éditeur",
|
||||
"jobs": "Jobs",
|
||||
"terminal": "Terminal",
|
||||
"browser": "Navigateur",
|
||||
"monitor": "Monitor",
|
||||
"activity": "Activité",
|
||||
"dashboards": "Dashboards",
|
||||
"sendOff": "SendOff",
|
||||
"calendar": "Calendrier",
|
||||
"contacts": "Contacts",
|
||||
"appStore": "Boutique d'applications",
|
||||
"marketplace": "Marketplace",
|
||||
"codeEditor": "Éditeur de code"
|
||||
},
|
||||
"theme": {
|
||||
"pillTitle": "Thème : {{name}} · ctrl+shift+←/→ parcourir · ctrl+shift+space explorer · ctrl+shift+f {{starAction}}",
|
||||
"pillLabel": "Thème : {{name}}",
|
||||
"star": "marquer",
|
||||
"unstar": "démarquer",
|
||||
"cycleHint": "ctrl+shift+←/→ parcourir · ctrl+shift+space explorer",
|
||||
"everyShortcutHint": "ctrl+? pour tous les raccourcis",
|
||||
"favouritesOnly": "Favoris uniquement ({{count}})",
|
||||
"favourites": "Favoris ({{count}})",
|
||||
"noFavourites": "Aucun favori pour l'instant — marquez-en un avec le bouton sur sa ligne.",
|
||||
"starNamed": "Marquer {{name}}",
|
||||
"unstarNamed": "Démarquer {{name}}",
|
||||
"addToFavourites": "Ajouter aux favoris",
|
||||
"removeFromFavourites": "Retirer des favoris",
|
||||
"addToFavouritesKey": "Ajouter aux favoris (f)",
|
||||
"removeFromFavouritesKey": "Retirer des favoris (f)",
|
||||
"active": "actif",
|
||||
"browseHint": "j/k ou ↑/↓ déplacer · l ou → aperçu · f marquer · h ou ← réinitialiser · entrée garder · esc annuler"
|
||||
},
|
||||
"spotlight": {
|
||||
"placeholder": "Aller à…",
|
||||
"noResults": "Rien ne correspond à “{{query}}”."
|
||||
},
|
||||
"shortcutsModal": {
|
||||
"title": "Raccourcis clavier",
|
||||
"hint": "Ctrl+? · Échap pour fermer"
|
||||
},
|
||||
"screenError": {
|
||||
"title": "Cet écran n'a pas pu s'afficher",
|
||||
"description": "Rien n'a été perdu — c'est un échec d'affichage, pas un échec de données. Les transcriptions de l'agent, les jobs et les fichiers sont intacts.",
|
||||
"unknownError": "Erreur inconnue",
|
||||
"tryAgain": "Réessayer",
|
||||
"reload": "Recharger",
|
||||
"goHome": "Retour à l'accueil",
|
||||
"resetHint": "Si cela échoue à nouveau au même endroit, une disposition de panneaux enregistrée en est généralement la cause. La réinitialisation remet les panneaux de tous les écrans à leurs valeurs par défaut.",
|
||||
"dashboardsSafe": "Vos Dashboards ne sont pas touchés.",
|
||||
"resetting": "Réinitialisation…",
|
||||
"resetConfirm": "Oui — réinitialiser la disposition de tous les écrans",
|
||||
"reset": "Réinitialiser les dispositions des écrans"
|
||||
},
|
||||
"rescan": {
|
||||
"label": "Réanalyser les items",
|
||||
"title": "Réanalyser officer-items (appliquer les changements sans redémarrer)",
|
||||
"failed": "Échec de la réanalyse des items",
|
||||
"done": "Items réanalysés — {{summary}}",
|
||||
"counts": {
|
||||
"tasks": {
|
||||
"one": "{{count}} tâche",
|
||||
"other": "{{count}} tâches"
|
||||
},
|
||||
"agents": {
|
||||
"one": "{{count}} agent",
|
||||
"other": "{{count}} agents"
|
||||
},
|
||||
"tools": {
|
||||
"one": "{{count}} outil",
|
||||
"other": "{{count}} outils"
|
||||
},
|
||||
"skills": {
|
||||
"one": "{{count}} skill",
|
||||
"other": "{{count}} skills"
|
||||
},
|
||||
"processes": {
|
||||
"one": "{{count}} processus",
|
||||
"other": "{{count}} processus"
|
||||
},
|
||||
"affordances": {
|
||||
"one": "{{count}} affordance",
|
||||
"other": "{{count}} affordances"
|
||||
}
|
||||
},
|
||||
"newChatsOnly": "S'applique aux nouveaux Chats — un Chat déjà ouvert garde ce avec quoi il a démarré.",
|
||||
"noDescription": {
|
||||
"one": "{{count}} skill n'a pas de description, donc elle n'est proposée à aucun agent.",
|
||||
"other": "{{count}} skills n'ont pas de description, donc elles ne sont proposées à aucun agent."
|
||||
}
|
||||
},
|
||||
"bugReport": {
|
||||
"label": "Signaler un bug",
|
||||
"title": "Signaler un bug",
|
||||
"describePlaceholder": "Décrivez ce qui s'est mal passé...",
|
||||
"screenshot": "Capture d'écran",
|
||||
"noScreenshot": "Aucune capture d'écran effectuée",
|
||||
"collectedNote": "Les journaux de la console, les informations du navigateur et les erreurs API récentes sont collectés automatiquement.",
|
||||
"submitted": "Signalement de bug envoyé",
|
||||
"failed": "Échec de l'envoi du signalement de bug",
|
||||
"submit": "Envoyer"
|
||||
},
|
||||
"auth": {
|
||||
"signInTitle": "Content de vous revoir",
|
||||
"signInSubtitle": "Connectez-vous à votre compte",
|
||||
"emailOrUsername": "E-mail ou nom d'utilisateur",
|
||||
"password": "Mot de passe",
|
||||
"forgotPassword": "Mot de passe oublié ?",
|
||||
"emailPlaceholder": "vous@exemple.com",
|
||||
"passwordPlaceholder": "Votre mot de passe",
|
||||
"forgotTitle": "Mot de passe oublié",
|
||||
"forgotSubtitle": "Saisissez votre e-mail pour recevoir un lien de réinitialisation",
|
||||
"email": "E-mail",
|
||||
"backToLogin": "Retour à la connexion",
|
||||
"checkEmail": "Consultez votre e-mail",
|
||||
"checkEmailHint": "Cliquez dessus pour réinitialiser votre mot de passe.",
|
||||
"resetOk": "Mot de passe réinitialisé avec succès. Veuillez vous connecter.",
|
||||
"verifying": "Vérification…",
|
||||
"invalidLink": "Lien invalide",
|
||||
"invalidLinkHint": "Ce lien de réinitialisation est invalide ou a expiré.",
|
||||
"resetTitle": "Réinitialiser le mot de passe",
|
||||
"resetSubtitle": "Saisissez votre nouveau mot de passe",
|
||||
"confirmPassword": "Confirmer le mot de passe",
|
||||
"minChars": "12 caractères minimum",
|
||||
"confirmPlaceholder": "Confirmez votre mot de passe",
|
||||
"bootstrapTitle": "Bienvenue, Administrateur",
|
||||
"bootstrapSubtitle": "Créez votre compte administrateur",
|
||||
"name": "Nom",
|
||||
"username": "Nom d'utilisateur",
|
||||
"namePlaceholder": "Votre nom",
|
||||
"adminEmailPlaceholder": "admin@exemple.com",
|
||||
"bootstrapOk": "Compte administrateur créé. Veuillez vous connecter.",
|
||||
"debug": {
|
||||
"controls": "Contrôles :",
|
||||
"camera": "Caméra :",
|
||||
"model": "Modèle :"
|
||||
}
|
||||
},
|
||||
"shortcuts": {
|
||||
"navigation": "Navigation",
|
||||
"spotlight": "Spotlight — aller à n'importe quel écran, plugin ou paramètre par son nom",
|
||||
"thisList": "Cette liste",
|
||||
"themes": "Thèmes",
|
||||
"themesNote": "L'explorateur applique le thème au fur et à mesure que vous vous déplacez, pour que vous voyiez l'application changer plutôt que de lire des noms.",
|
||||
"openBrowser": "Ouvrir l'explorateur de thèmes",
|
||||
"prevNextTheme": "Thème précédent / suivant, sans rien ouvrir",
|
||||
"starCurrent": "Marquer ou démarquer le thème actuel, depuis n'importe où",
|
||||
"inBrowser": "Dans l'explorateur de thèmes",
|
||||
"moveList": "Parcourir la liste",
|
||||
"applyKeepBrowsing": "Appliquer le thème en focus et continuer à explorer",
|
||||
"restoreStart": "Rétablir le thème sur lequel vous avez commencé",
|
||||
"starFocused": "Marquer ou démarquer le thème en focus",
|
||||
"keepLooking": "Garder ce que vous regardez",
|
||||
"cancelRestore": "Annuler et restaurer",
|
||||
"inSpotlight": "Dans Spotlight",
|
||||
"moveResults": "Parcourir les résultats",
|
||||
"goThere": "Y aller",
|
||||
"close": "Fermer",
|
||||
"chat": "Chat",
|
||||
"chatNote": "Le rappel n'utilise la touche fléchée que sur un compositeur VIDE, et toute autre touche met fin au rappel — un brouillon n'est ainsi jamais écrasé.",
|
||||
"prevNextPrompt": "Prompt précédent / suivant que vous avez envoyé, à la manière d'un shell",
|
||||
"send": "Envoyer · sur un compositeur vide avec quelque chose en file, envoyer cela maintenant",
|
||||
"newline": "Nouvelle ligne",
|
||||
"stopTurn": "Arrêter le tour en cours de génération"
|
||||
},
|
||||
"jobs": {
|
||||
"runningQueued": "En cours et en file d'attente",
|
||||
"loading": "Chargement…",
|
||||
"nothingRunning": "Rien en cours",
|
||||
"history": "Historique",
|
||||
"clearAll": "Tout effacer",
|
||||
"selectJob": "Sélectionner un job",
|
||||
"notFound": "Job introuvable.",
|
||||
"backToJobs": "Retour aux jobs",
|
||||
"stop": "Arrêter",
|
||||
"testNotification": "Envoyer une notification de test à tous les canaux configurés",
|
||||
"search": "Rechercher…",
|
||||
"deleteFinished": "Supprimer tous les jobs terminés",
|
||||
"noChannels": "Aucun canal configuré",
|
||||
"notifyFailed": "Échec de l'envoi de la notification",
|
||||
"stopRequested": "Arrêt demandé",
|
||||
"stopFailed": "Échec de l'arrêt",
|
||||
"titles": "Titres",
|
||||
"download": "Télécharger",
|
||||
"input": "Entrée",
|
||||
"output": "Sortie",
|
||||
"steps": "Étapes",
|
||||
"selectStep": "Sélectionnez une étape ou une sous-tâche pour voir la sortie",
|
||||
"noOutput": "Aucune sortie pour l'instant",
|
||||
"tokens": "Tokens :",
|
||||
"cost": "Coût :",
|
||||
"status": {
|
||||
"running": "En cours",
|
||||
"queued": "En file d'attente",
|
||||
"completed": "Terminé",
|
||||
"failed": "Échoué",
|
||||
"stopped": "Arrêté",
|
||||
"interrupted": "Interrompu"
|
||||
},
|
||||
"failedWithCode": "Échoué (sortie {{code}})"
|
||||
},
|
||||
"activity": {
|
||||
"title": "Activité",
|
||||
"backgroundTasks": "Tâches en arrière-plan",
|
||||
"detached": "Détachée",
|
||||
"backToList": "Retour à la liste",
|
||||
"selectTask": "Sélectionnez une tâche pour suivre sa sortie en direct",
|
||||
"noneRunning": "aucune en cours"
|
||||
},
|
||||
"passkeyGate": {
|
||||
"setUp": "Configurez votre passkey"
|
||||
},
|
||||
"chatHistory": {
|
||||
"loadFailed": "Impossible de charger cette conversation : {{reason}}"
|
||||
},
|
||||
"capability": {
|
||||
"frontmatter": "Frontmatter",
|
||||
"noMatches": "Aucun résultat",
|
||||
"emptyFile": "Fichier vide",
|
||||
"createFailed": "Impossible de créer le {{kind}}",
|
||||
"deleteFailed": "Impossible de supprimer le {{kind}}",
|
||||
"searchPlaceholder": "Rechercher {{kind}}…",
|
||||
"selectOne": "Sélectionnez un {{kind}}",
|
||||
"selectToView": "Sélectionnez un {{kind}} pour voir son contenu",
|
||||
"delete": "Supprimer",
|
||||
"createPrompt": "Aide-moi à créer le contenu de ce nouveau fichier {{kind}}",
|
||||
"improvePrompt": "Aide-moi à comprendre et améliorer ce fichier {{kind}}"
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,37 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { findMissingKeys } from 'i18n';
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { findMissingKeys, findExtraKeys } from 'i18n';
|
||||
import en from './en-GB.json';
|
||||
import ptPT from './pt-PT.json';
|
||||
|
||||
// D2: every shipped locale ships a COMPLETE catalog. A key en-GB has and pt-PT lacks is a red build,
|
||||
// not a warning — there is no regional fallback to paper over the gap.
|
||||
/**
|
||||
* Parity, over whatever locales are actually ON DISK — not a list written here.
|
||||
*
|
||||
* This used to name its locales literally, and that made it a check you had to remember to update. Adding
|
||||
* `sv-SE` added a file no parity test looked at, so it shipped out of step and every test stayed green.
|
||||
* A guard that needs maintaining to keep guarding is the thing it was written to prevent.
|
||||
*
|
||||
* Both directions, too. `findMissingKeys` alone reports half of a rename: the new key is missing from the
|
||||
* translation and caught, while the stale one it still carries is invisible. That is exactly how sv-SE
|
||||
* ended up naming two keys en-GB did not have.
|
||||
*/
|
||||
const LOCALES: Record<string, Record<string, unknown>> = Object.fromEntries(
|
||||
readdirSync(__dirname)
|
||||
.filter((f) => f.endsWith('.json') && f !== 'en-GB.json')
|
||||
.map((f) => [f.replace(/\.json$/, ''), JSON.parse(readFileSync(join(__dirname, f), 'utf8'))]),
|
||||
);
|
||||
|
||||
describe('officer-web (app.*) locale catalogs', () => {
|
||||
test('pt-PT has every key en-GB has', () => {
|
||||
expect(findMissingKeys(en, { 'pt-PT': ptPT })).toEqual([]);
|
||||
test('there is at least one locale besides en-GB', () => {
|
||||
// Otherwise every assertion below passes over an empty set and says nothing.
|
||||
expect(Object.keys(LOCALES).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('every locale has every key en-GB has', () => {
|
||||
expect(findMissingKeys(en, LOCALES)).toEqual([]);
|
||||
});
|
||||
|
||||
test('no locale has a key en-GB does not', () => {
|
||||
expect(findExtraKeys(en, LOCALES)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,704 @@
|
||||
{
|
||||
"common": {
|
||||
"save": "Spara",
|
||||
"saving": "Sparar...",
|
||||
"cancel": "Avbryt"
|
||||
},
|
||||
"settings": {
|
||||
"profile": {
|
||||
"userData": {
|
||||
"email": "E-post",
|
||||
"username": "Användarnamn",
|
||||
"name": "Namn",
|
||||
"namePlaceholder": "Ditt namn",
|
||||
"updated": "Profilen uppdaterad",
|
||||
"updateFailed": "Det gick inte att uppdatera profilen",
|
||||
"selectImage": "Välj en bildfil",
|
||||
"imageTooLarge": "Bilden måste vara mindre än 384 kB"
|
||||
},
|
||||
"apiKeys": {
|
||||
"intro": "En API-nyckel loggar in som dig, utan lösenord och utan att gå ut. En nyckel fungerar överallt — en nyckel är inte knuten till en app. Att ge varje enhet sin egen är bara så att du kan stänga av en utan att röra resten. Skicka den som",
|
||||
"loading": "Läser in nycklar…",
|
||||
"mintedTitle": "Nyckel för ”{{name}}” — visas en gång",
|
||||
"savedIt": "Jag har sparat den",
|
||||
"copyKey": "Kopiera nyckel",
|
||||
"copied": "Nyckel kopierad",
|
||||
"copyFailed": "Det gick inte att kopiera — markera och kopiera manuellt",
|
||||
"newKey": "Ny nyckel",
|
||||
"namePlaceholder": "iPhone, Musik-appen, laptop-CLI…",
|
||||
"create": "Skapa",
|
||||
"createFailed": "Det gick inte att skapa nyckeln",
|
||||
"noneYet": "Inga nycklar än.",
|
||||
"noneInUse": "Inga nycklar används.",
|
||||
"revoked": "återkallad",
|
||||
"expired": "utgången",
|
||||
"lastUsed": "senast använd {{date}}",
|
||||
"neverUsed": "aldrig använd",
|
||||
"forget": "Ta bort denna post",
|
||||
"forgetFailed": "Det gick inte att ta bort den",
|
||||
"revoke": "Återkalla",
|
||||
"revokeDone": "Återkallad — allt som använder den nyckeln loggas ut",
|
||||
"revokeFailed": "Det gick inte att återkalla",
|
||||
"hideDead": "Dölj återkallade och utgångna",
|
||||
"showDead": {
|
||||
"one": "Visa {{count}} återkallad eller utgången nyckel",
|
||||
"other": "Visa {{count}} återkallade eller utgångna nycklar"
|
||||
}
|
||||
},
|
||||
"voice": {
|
||||
"label": "Röst",
|
||||
"refresh": "Uppdatera röster",
|
||||
"serverDefault": "Serverstandard",
|
||||
"hint": "Välj en röst för text-till-tal. Behåll serverstandard för att använda rösten administratören har konfigurerat.",
|
||||
"stop": "Stoppa",
|
||||
"listen": "Lyssna"
|
||||
},
|
||||
"dock": {
|
||||
"visible": "Synlig",
|
||||
"hidden": "Dold",
|
||||
"dragToShow": "Dra objekt hit för att visa i dockan",
|
||||
"allVisible": "Alla objekt synliga",
|
||||
"reset": "Återställ till standard"
|
||||
},
|
||||
"languages": {
|
||||
"spokenLabel": "Språk du talar",
|
||||
"addLanguage": "Lägg till ett språk...",
|
||||
"defaultLabel": "Standardspråk",
|
||||
"defaultHint": "Används för lokalisering av gränssnittet.",
|
||||
"translateToLabel": "Översätt till",
|
||||
"translateToHint": "Målspråk vid översättning av innehåll du inte talar."
|
||||
},
|
||||
"changePassword": {
|
||||
"currentPasswordLabel": "Nuvarande lösenord",
|
||||
"currentPasswordPlaceholder": "Nuvarande lösenord",
|
||||
"newPasswordLabel": "Nytt lösenord",
|
||||
"newPasswordPlaceholder": "Nytt lösenord",
|
||||
"confirmPasswordLabel": "Bekräfta nytt lösenord",
|
||||
"confirmPasswordPlaceholder": "Bekräfta nytt lösenord",
|
||||
"changeButton": "Byt lösenord",
|
||||
"changingButton": "Byter...",
|
||||
"passwordChanged": "Lösenordet ändrat",
|
||||
"passwordChangeFailed": "Det gick inte att byta lösenord"
|
||||
},
|
||||
"models": {
|
||||
"defaultModel": "Standardmodell",
|
||||
"sameAsChat": "Samma som chattstandard",
|
||||
"taskDefaultsSaved": "Standardvärden för uppgifter sparade",
|
||||
"saveFailed": "Det gick inte att spara inställningarna",
|
||||
"defaultChat": "Standardmodell för chatt",
|
||||
"defaultChatHint": "Används när en ny chatt startas från hemskärmen",
|
||||
"defaultProject": "Standardmodell för projekt",
|
||||
"defaultProjectHint": "Används när en ny chatt startas inuti en projektdashboard",
|
||||
"defaultTask": "Standardmodell för uppgifter",
|
||||
"defaultTaskHint": "Används när uppgifter körs från filhanteraren",
|
||||
"aiDefaultsSaved": "Standardvärden för AI-modell sparade"
|
||||
}
|
||||
},
|
||||
"sections": {
|
||||
"profile": {
|
||||
"label": "Profil",
|
||||
"hint": "avatar, namn, e-post",
|
||||
"description": "Avatar, namn och e-post"
|
||||
},
|
||||
"changePassword": {
|
||||
"label": "Byt lösenord",
|
||||
"description": "Uppdatera ditt lösenord"
|
||||
},
|
||||
"apiKeys": {
|
||||
"label": "API-nycklar",
|
||||
"hint": "nycklar som loggar in som dig",
|
||||
"description": "Nycklar som loggar in som dig, kan återkallas en i taget"
|
||||
},
|
||||
"languages": {
|
||||
"label": "Språk",
|
||||
"hint": "talade och översättning",
|
||||
"description": "Talade, standard och översättning"
|
||||
},
|
||||
"voice": {
|
||||
"label": "Röst",
|
||||
"hint": "text-till-tal",
|
||||
"description": "Inställning för text-till-tal-röst"
|
||||
},
|
||||
"dock": {
|
||||
"label": "Docka",
|
||||
"hint": "välj och ordna om",
|
||||
"description": "Välj och ordna om objekt i dockan"
|
||||
},
|
||||
"myModels": {
|
||||
"label": "Mina modeller",
|
||||
"description": "Visa eller dölj modeller för dig själv"
|
||||
},
|
||||
"channelModels": {
|
||||
"label": "Kanalmodeller",
|
||||
"description": "Modeller som andra konton än ägaren kan nå"
|
||||
},
|
||||
"defaultModels": {
|
||||
"label": "Standardmodeller",
|
||||
"description": "Standardmodeller för chatt, projekt och uppgifter"
|
||||
},
|
||||
"providers": {
|
||||
"label": "Leverantörer",
|
||||
"hint": "körmiljöer",
|
||||
"description": "Fjärran och lokala AI-leverantörer"
|
||||
},
|
||||
"tts": {
|
||||
"label": "Text till tal",
|
||||
"description": "TTS-leverantör och röst"
|
||||
},
|
||||
"stt": {
|
||||
"label": "Tal till text",
|
||||
"description": "Whisper-serverns URL"
|
||||
},
|
||||
"ocr": {
|
||||
"label": "OCR",
|
||||
"description": "Synmodell för textextrahering"
|
||||
},
|
||||
"maintenance": {
|
||||
"label": "Bygg om och starta om",
|
||||
"hint": "underhåll",
|
||||
"description": "Läs in ändringar utan en terminal"
|
||||
},
|
||||
"smtp": {
|
||||
"label": "SMTP",
|
||||
"hint": "utgående e-post",
|
||||
"description": "Leverantör för e-postleverans"
|
||||
},
|
||||
"googleOauth": {
|
||||
"label": "Google OAuth",
|
||||
"description": "Klient-ID och hemlighet för Google-API:er"
|
||||
},
|
||||
"apify": {
|
||||
"label": "Apify",
|
||||
"description": "API-token för webbskrapningsaktörer"
|
||||
},
|
||||
"davSync": {
|
||||
"label": "Kalender- och kontaktsynkronisering",
|
||||
"hint": "applösenord",
|
||||
"description": "Lösenord per enhet för CalDAV-/CardDAV-klienter"
|
||||
},
|
||||
"accounts": {
|
||||
"label": "Konton",
|
||||
"hint": "användare",
|
||||
"description": "Vem som har åtkomst, och som vad"
|
||||
},
|
||||
"permissions": {
|
||||
"label": "Behörigheter",
|
||||
"description": "Vad varje roll kan nå"
|
||||
}
|
||||
},
|
||||
"searchPlaceholder": "Sök inställningar…",
|
||||
"selectSection": "Välj en sektion",
|
||||
"groups": {
|
||||
"models": "Modeller",
|
||||
"defaults": "Standardvärden",
|
||||
"providers": "Leverantörer",
|
||||
"ai": "AI",
|
||||
"maintenance": "Underhåll",
|
||||
"email": "E-post"
|
||||
},
|
||||
"nav": {
|
||||
"profile": "Profil",
|
||||
"ai": "AI",
|
||||
"system": "System",
|
||||
"integrations": "Integrationer",
|
||||
"users": "Användare"
|
||||
},
|
||||
"ai": {
|
||||
"noModels": "Inga modeller tillgängliga. Konfigurera API-nycklar under Leverantörer.",
|
||||
"dragToShow": "Dra modeller hit för att visa",
|
||||
"allVisible": "Alla modeller synliga",
|
||||
"dragToEnable": "Dra modeller hit för att aktivera",
|
||||
"allEnabled": "Alla modeller aktiverade",
|
||||
"visible": "Synlig",
|
||||
"hidden": "Dold",
|
||||
"enabled": "Aktiverad",
|
||||
"disabled": "Inaktiverad",
|
||||
"refresh": "Uppdatera modeller från leverantörer",
|
||||
"refreshed": "Modeller uppdaterade",
|
||||
"refreshFailed": "Det gick inte att uppdatera modellerna"
|
||||
},
|
||||
"integrations": {
|
||||
"enterprise": "Företag",
|
||||
"personal": "Personligt"
|
||||
},
|
||||
"server": {
|
||||
"loading": "Läser in…",
|
||||
"provider": "Leverantör",
|
||||
"model": "Modell",
|
||||
"voice": "Röst",
|
||||
"ocr": {
|
||||
"serverUrl": "URL till synmodellsserver",
|
||||
"refreshModels": "Uppdatera modeller",
|
||||
"selectModel": "Välj en modell",
|
||||
"saved": "OCR-inställningarna sparade",
|
||||
"saveFailed": "Det gick inte att spara OCR-inställningarna",
|
||||
"reachable": "Synmodellsservern är nåbar"
|
||||
},
|
||||
"stt": {
|
||||
"serverUrl": "URL till Whisper-server",
|
||||
"saved": "STT-inställningarna sparade",
|
||||
"saveFailed": "Det gick inte att spara STT-inställningarna",
|
||||
"reachable": "Whisper-servern är nåbar"
|
||||
},
|
||||
"tts": {
|
||||
"openaiCompatible": "OpenAI-kompatibel (Kokoro, OpenAI, m.fl.)",
|
||||
"elevenLabs": "ElevenLabs",
|
||||
"refreshVoices": "Uppdatera röster",
|
||||
"selectVoice": "Välj en röst",
|
||||
"saved": "TTS-inställningarna sparade",
|
||||
"saveFailed": "Det gick inte att spara TTS-inställningarna",
|
||||
"playing": "Spelar upp testljud",
|
||||
"testFailed": "Testet misslyckades — det gick inte att nå TTS-servern"
|
||||
},
|
||||
"smtp": {
|
||||
"mailhog": "MailHog (lokal testning)",
|
||||
"resend": "Resend",
|
||||
"custom": "Anpassad SMTP",
|
||||
"apiKey": "API-nyckel",
|
||||
"host": "Värd",
|
||||
"port": "Port",
|
||||
"username": "Användarnamn",
|
||||
"password": "Lösenord",
|
||||
"tls": "TLS/SSL",
|
||||
"fromName": "Avsändarnamn",
|
||||
"fromEmail": "Avsändaradress",
|
||||
"sendTest": "Skicka testmejl",
|
||||
"userExample": "user@example.com",
|
||||
"recipientExample": "recipient@example.com",
|
||||
"saved": "SMTP-inställningarna sparade",
|
||||
"saveFailed": "Det gick inte att spara SMTP-inställningarna",
|
||||
"connected": "Anslutningen lyckades",
|
||||
"testSent": "Testmejl skickat till {{email}}"
|
||||
},
|
||||
"maintenance": {
|
||||
"updateTitle": "Uppdatera plattformen",
|
||||
"updateHint": "Ändrar ingenting som körs. Allt uppdateringen behöver listas här, och det är alltid ditt beslut.",
|
||||
"rebuild": "Bygg om gränssnittet",
|
||||
"restart": "Starta om servern",
|
||||
"upToDate": "Redan uppdaterad",
|
||||
"rebuilt": "Gränssnittet ombyggt — uppdatera sidan för att se det",
|
||||
"restarting": "Startar om — sidan återansluter av sig själv",
|
||||
"pulled": {
|
||||
"one": "Hämtade {{count}} commit",
|
||||
"other": "Hämtade {{count}} commits"
|
||||
}
|
||||
},
|
||||
"harnesses": {
|
||||
"piMono": "Pi Mono",
|
||||
"checking": "Kontrollerar…",
|
||||
"localProviders": "Lokala leverantörer",
|
||||
"remoteProviders": "Fjärrleverantörer",
|
||||
"connect": "Anslut",
|
||||
"authenticate": "Autentisera",
|
||||
"connectLocal": "Anslut lokal leverantör",
|
||||
"connectProvider": "Anslut leverantör",
|
||||
"none": "Inga leverantörer hittades.",
|
||||
"updatePiMono": "Uppdatera Pi Mono",
|
||||
"removeProvider": "Ta bort leverantör",
|
||||
"namePlaceholder": "Namn (t.ex. Min Ollama)",
|
||||
"username": "Användarnamn",
|
||||
"password": "Lösenord",
|
||||
"apiKey": "API-nyckel",
|
||||
"editKey": "Redigera nyckel",
|
||||
"disconnect": "Koppla från leverantör",
|
||||
"saveKey": "Spara nyckel",
|
||||
"enterKey": "Ange API-nyckel",
|
||||
"search": "Sök leverantörer…",
|
||||
"connectFailed": "Det gick inte att ansluta",
|
||||
"authFailed": "Autentiseringen misslyckades",
|
||||
"saveFailed": "Det gick inte att spara leverantören",
|
||||
"connectedTo": "Ansluten till {{name}}"
|
||||
}
|
||||
},
|
||||
"users": {
|
||||
"loadingPermissions": "Läser in behörigheter…",
|
||||
"permissionsFailed": "Det gick inte att läsa in behörigheterna.",
|
||||
"deniedUnlessGranted": "Allt nekas om det inte beviljas här.",
|
||||
"read": "Läs",
|
||||
"full": "Fullständig",
|
||||
"noAccess": "Ingen åtkomst",
|
||||
"role": "Roll",
|
||||
"permissionsSaved": "Sparade vad {{role}} kan nå",
|
||||
"copyBeforeClosing": "Kopiera det du behöver innan du stänger — inget av det kan visas igen.",
|
||||
"password": "Lösenord",
|
||||
"linuxAccount": "Linuxkonto",
|
||||
"sshKey": "Deras publika SSH-nyckel",
|
||||
"addToGitea": "Lägg till detta i deras Gitea-konto",
|
||||
"linuxIncomplete": "Kontot fungerar, men dess Linux-del blev inte klar",
|
||||
"done": "Klar",
|
||||
"addAccount": "Lägg till konto",
|
||||
"newAccount": "Nytt konto",
|
||||
"email": "E-post",
|
||||
"name": "Namn",
|
||||
"username": "Användarnamn",
|
||||
"usernameHint": "Bokstäver, siffror, punkter, bindestreck och understreck.",
|
||||
"generatePassword": "Generera ett lösenord",
|
||||
"copyPassword": "Kopiera lösenordet",
|
||||
"passwordHint": "Minst 12 tecken, med stora och små bokstäver, en siffra och ett specialtecken.",
|
||||
"sshPlaceholder": "ssh-ed25519 AAAAC3Nza… ana@laptop",
|
||||
"passwordCopied": "Lösenordet kopierat",
|
||||
"loadingAccounts": "Läser in konton…",
|
||||
"accountsFailed": "Det gick inte att läsa in kontona.",
|
||||
"removeAccount": "Ta bort konto",
|
||||
"generateNewPassword": "Generera ett nytt lösenord",
|
||||
"serverOwner": "Serverägare",
|
||||
"copySshTitle": "Kopiera deras publika SSH-nyckel (lägg till den i deras Gitea-konto)",
|
||||
"resetPasswordTitle": "Generera ett nytt lösenord — visas en gång, och loggar ut dem överallt",
|
||||
"copyNewPassword": "Kopiera det nya lösenordet",
|
||||
"publicKeyCopied": "Publik nyckel kopierad",
|
||||
"copyFailed": "Det gick inte att kopiera — markera den och kopiera för hand",
|
||||
"repairFor": "Reparera {{email}}s Linuxkonto",
|
||||
"linuxAccountFor": "Linuxkonto: {{osUser}} — klicka för att reparera eller ersätta deras SSH-nyckel",
|
||||
"copySshFor": "Kopiera {{email}}s publika SSH-nyckel",
|
||||
"resetPasswordFor": "Återställ {{email}}s lösenord",
|
||||
"removeFor": "Ta bort {{email}}",
|
||||
"provisionPrompt": "Linuxkonto för {{email}}.\n\nKlistra in en publik SSH-nyckel för att tillåta dem att SSH:a in, eller lämna tomt för att behålla den nuvarande.",
|
||||
"createLinux": "Skapa ett Linuxkonto",
|
||||
"noLinux": "Inget Linuxkonto — klicka för att skapa ett",
|
||||
"removeTitle": "Ta bort {{email}}?",
|
||||
"removeBody": "Detta tar bort kontot och allt som tillhör det — passkeys, dashboards, sparade layouter och allt en installerad plugin sparar åt dem. Det kan inte ångras.",
|
||||
"resetTitle": "Återställa lösenordet för {{email}}?",
|
||||
"resetBody": "Ett nytt lösenord genereras och visas för dig en gång — det sparas ingenstans och kan inte hämtas i efterhand. Deras befintliga lösenord slutar fungera omedelbart, och de loggas ut överallt.",
|
||||
"resetLinuxNote": "Deras Linuxkonto ({{osUser}}) påverkas inte: det har inget lösenord, och SSH-nycklarna är oförändrade."
|
||||
},
|
||||
"dav": {
|
||||
"newDevice": "Ny enhet",
|
||||
"create": "Skapa",
|
||||
"none": "Inga enheter än.",
|
||||
"placeholder": "Pixel 9, iPad, Thunderbird…",
|
||||
"revoke": "Återkalla",
|
||||
"delete": "Ta bort",
|
||||
"copyFailed": "Det gick inte att kopiera — markera och kopiera manuellt",
|
||||
"createFailed": "Det gick inte att skapa applösenordet",
|
||||
"revoked": "Återkallat — den enheten slutar synkronisera",
|
||||
"revokeFailed": "Det gick inte att återkalla",
|
||||
"deleteFailed": "Det gick inte att ta bort",
|
||||
"copyField": "Kopiera {{field}}",
|
||||
"copied": "{{what}} kopierat"
|
||||
},
|
||||
"google": {
|
||||
"guideToggle": "Steg-för-steg-guide",
|
||||
"step1": {
|
||||
"title": "Skapa ett Google Cloud-projekt",
|
||||
"body": "Öppna sidan för att skapa projekt, ge det ett namn (till exempel ”Officer”), och klicka på:",
|
||||
"link": "New Project"
|
||||
},
|
||||
"step2": {
|
||||
"title": "Aktivera API:erna",
|
||||
"body": "Öppna API-biblioteket och sök efter var och en av dessa, klicka sedan på:",
|
||||
"link": "API Library"
|
||||
},
|
||||
"step3": {
|
||||
"title": "Konfigurera OAuth-samtyckesskärmen",
|
||||
"link": "OAuth Branding",
|
||||
"appName": "Ange din organisations namn, eller ”Officer”, som:",
|
||||
"supportEmail": "Ange din administratörs e-post som:",
|
||||
"devContact": "Lägg till din administratörs e-post under:",
|
||||
"save": "Klicka sedan på:"
|
||||
},
|
||||
"step4": {
|
||||
"title": "Ställ in målgruppen",
|
||||
"link": "OAuth Audience",
|
||||
"internal": "Om ditt team använder Google Workspace, välj — ingen verifiering behövs:",
|
||||
"external": "Välj annars, och lägg till ditt teams e-postadresser under Test users (krävs medan appen är overifierad; gräns på 100 testanvändare):"
|
||||
},
|
||||
"step5": {
|
||||
"title": "Lägg till scopes",
|
||||
"link": "Data Access",
|
||||
"body": "Öppna dataåtkomst, sök efter och lägg till scopen nedan, klicka sedan på:",
|
||||
"note": "Google klassar kalenderscopet som känsligt och Gmail-scopet som begränsat. Det är okej för Interna appar (Google Workspace) och för Externa appar i testläge; publicering i produktion med begränsade scopes kräver Google-verifiering."
|
||||
},
|
||||
"step6": {
|
||||
"title": "Skapa OAuth-inloggningsuppgifter",
|
||||
"link": "Clients",
|
||||
"body": "Öppna klientsidan och klicka på:",
|
||||
"appType": "Application type:",
|
||||
"nameAnything": "Namn: vad som helst (till exempel ”Officer”)",
|
||||
"redirectUris": "Authorized redirect URIs — lägg till:",
|
||||
"thenClick": "Klicka sedan på:"
|
||||
},
|
||||
"step7": {
|
||||
"title": "Kopiera inloggningsuppgifterna",
|
||||
"body": "En dialog visar ditt klient-ID och din klienthemlighet. Kopiera båda och klistra in dem i fälten nedan."
|
||||
},
|
||||
"scopeGmail": "Läs e-post",
|
||||
"scopeCalendar": "Läs kalenderhändelser",
|
||||
"verifying": "Verifierar inloggningsuppgifter…",
|
||||
"valid": "Inloggningsuppgifterna är giltiga",
|
||||
"invalid": "Ogiltiga inloggningsuppgifter",
|
||||
"verifyFailed": "Verifieringsförfrågan misslyckades",
|
||||
"clientId": "Klient-ID",
|
||||
"clientSecret": "Klienthemlighet",
|
||||
"redirectUri": "Omdirigerings-URI",
|
||||
"redirectUriHint": "Lägg till denna URI i din Google OAuth-klients auktoriserade omdirigerings-URI:er",
|
||||
"saved": "Google OAuth-konfigurationen sparad",
|
||||
"saveFailed": "Det gick inte att spara Google OAuth-konfigurationen"
|
||||
},
|
||||
"googleAccount": {
|
||||
"title": "Googlekonto",
|
||||
"hint": "Anslut ditt Googlekonto för Kalender, Kontakter och andra Google-tjänster.",
|
||||
"connected": "Anslutet",
|
||||
"disconnect": "Koppla från",
|
||||
"connect": "Anslut Googlekonto",
|
||||
"connectedToast": "Googlekontot anslutet",
|
||||
"connectFailed": "Det gick inte att ansluta Googlekontot",
|
||||
"disconnectedToast": "Googlekontot frånkopplat",
|
||||
"disconnectFailed": "Det gick inte att koppla från Googlekontot"
|
||||
},
|
||||
"apify": {
|
||||
"token": "API-token",
|
||||
"saved": "Apify API-token sparad",
|
||||
"saveFailed": "Det gick inte att spara Apify API-token"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
"userMenu": {
|
||||
"profile": "Profil",
|
||||
"systemSettings": "Systeminställningar",
|
||||
"signOut": "Logga ut",
|
||||
"ai": "AI",
|
||||
"integrations": "Integrationer",
|
||||
"userManagement": "Användarhantering"
|
||||
},
|
||||
"tabName": "Flikens namn",
|
||||
"tabNameHint": "Klicka för att döpa om denna flik — rensa den för att gå tillbaka till sidans namn",
|
||||
"logoAlt": "Officer",
|
||||
"navigation": "Navigering",
|
||||
"jobsRunning": "Körande jobb",
|
||||
"jobsNothingRunning": "Inget körs",
|
||||
"jobsQueued": "Köade jobb"
|
||||
},
|
||||
"nav": {
|
||||
"home": "Hem",
|
||||
"files": "Filer",
|
||||
"chat": "Chatt",
|
||||
"commandHq": "Command HQ",
|
||||
"editor": "Editor",
|
||||
"jobs": "Jobb",
|
||||
"terminal": "Terminal",
|
||||
"browser": "Webbläsare",
|
||||
"monitor": "Monitor",
|
||||
"activity": "Aktivitet",
|
||||
"dashboards": "Dashboards",
|
||||
"sendOff": "SendOff",
|
||||
"calendar": "Kalender",
|
||||
"contacts": "Kontakter",
|
||||
"appStore": "Appbutik",
|
||||
"marketplace": "Marketplace",
|
||||
"codeEditor": "Kod-editor"
|
||||
},
|
||||
"theme": {
|
||||
"pillTitle": "Tema: {{name}} · ctrl+shift+←/→ bläddra · ctrl+shift+space bläddra i galleri · ctrl+shift+f {{starAction}}",
|
||||
"pillLabel": "Tema: {{name}}",
|
||||
"star": "stjärnmärk",
|
||||
"unstar": "avmarkera stjärna",
|
||||
"cycleHint": "ctrl+shift+←/→ bläddra · ctrl+shift+space galleri",
|
||||
"everyShortcutHint": "ctrl+? för alla kortkommandon",
|
||||
"favouritesOnly": "Endast favoriter ({{count}})",
|
||||
"favourites": "Favoriter ({{count}})",
|
||||
"noFavourites": "Inga favoriter än — stjärnmärk en med knappen på dess rad.",
|
||||
"starNamed": "Stjärnmärk {{name}}",
|
||||
"unstarNamed": "Avmarkera stjärna för {{name}}",
|
||||
"addToFavourites": "Lägg till i favoriter",
|
||||
"removeFromFavourites": "Ta bort från favoriter",
|
||||
"addToFavouritesKey": "Lägg till i favoriter (f)",
|
||||
"removeFromFavouritesKey": "Ta bort från favoriter (f)",
|
||||
"active": "aktiv",
|
||||
"browseHint": "j/k eller ↑/↓ flytta · l eller → förhandsgranska · f stjärna · h eller ← återställ · enter behåll · esc avbryt"
|
||||
},
|
||||
"spotlight": {
|
||||
"placeholder": "Gå till…",
|
||||
"noResults": "Inget matchar ”{{query}}”."
|
||||
},
|
||||
"shortcutsModal": {
|
||||
"title": "Kortkommandon",
|
||||
"hint": "Ctrl+? · Esc för att stänga"
|
||||
},
|
||||
"screenError": {
|
||||
"title": "Den här skärmen kunde inte renderas",
|
||||
"description": "Inget gick förlorat — detta är ett visningsfel, inte ett datafel. Agenttranskript, jobb och filer är opåverkade.",
|
||||
"unknownError": "Okänt fel",
|
||||
"tryAgain": "Försök igen",
|
||||
"reload": "Läs om",
|
||||
"goHome": "Gå hem",
|
||||
"resetHint": "Om det misslyckas igen på samma ställe är en sparad panellayout den vanliga orsaken. Återställning sätter tillbaka varje skärms paneler till standardläget.",
|
||||
"dashboardsSafe": "Dina dashboards påverkas inte.",
|
||||
"resetting": "Återställer…",
|
||||
"resetConfirm": "Ja — återställ alla skärmlayouter",
|
||||
"reset": "Återställ skärmlayouter"
|
||||
},
|
||||
"rescan": {
|
||||
"label": "Skanna om objekt",
|
||||
"title": "Skanna om officer-items (tillämpa ändringar utan omstart)",
|
||||
"failed": "Det gick inte att skanna om objekten",
|
||||
"done": "Skannade om objekt — {{summary}}",
|
||||
"counts": {
|
||||
"tasks": {
|
||||
"one": "{{count}} uppgift",
|
||||
"other": "{{count}} uppgifter"
|
||||
},
|
||||
"agents": {
|
||||
"one": "{{count}} agent",
|
||||
"other": "{{count}} agenter"
|
||||
},
|
||||
"tools": {
|
||||
"one": "{{count}} verktyg",
|
||||
"other": "{{count}} verktyg"
|
||||
},
|
||||
"skills": {
|
||||
"one": "{{count}} skill",
|
||||
"other": "{{count}} skills"
|
||||
},
|
||||
"processes": {
|
||||
"one": "{{count}} process",
|
||||
"other": "{{count}} processer"
|
||||
},
|
||||
"affordances": {
|
||||
"one": "{{count}} affordance",
|
||||
"other": "{{count}} affordances"
|
||||
}
|
||||
},
|
||||
"newChatsOnly": "Gäller nya chattar — en öppen chatt behåller det den startade med.",
|
||||
"noDescription": {
|
||||
"one": "{{count}} skill saknar beskrivning, så ingen agent erbjuds den.",
|
||||
"other": "{{count}} skills saknar beskrivning, så ingen agent erbjuds dem."
|
||||
}
|
||||
},
|
||||
"bugReport": {
|
||||
"label": "Rapportera ett fel",
|
||||
"title": "Rapportera ett fel",
|
||||
"describePlaceholder": "Beskriv vad som gick fel...",
|
||||
"screenshot": "Skärmbild",
|
||||
"noScreenshot": "Ingen skärmbild togs",
|
||||
"collectedNote": "Konsolloggar, webbläsarinfo och nyliga API-fel samlas in automatiskt.",
|
||||
"submitted": "Felrapport skickad",
|
||||
"failed": "Det gick inte att skicka felrapporten",
|
||||
"submit": "Skicka"
|
||||
},
|
||||
"auth": {
|
||||
"signInTitle": "Välkommen tillbaka",
|
||||
"signInSubtitle": "Logga in på ditt konto",
|
||||
"emailOrUsername": "E-post eller användarnamn",
|
||||
"password": "Lösenord",
|
||||
"forgotPassword": "Glömt lösenordet?",
|
||||
"emailPlaceholder": "you@example.com",
|
||||
"passwordPlaceholder": "Ditt lösenord",
|
||||
"forgotTitle": "Glömt lösenord",
|
||||
"forgotSubtitle": "Ange din e-post för att få en återställningslänk",
|
||||
"email": "E-post",
|
||||
"backToLogin": "Tillbaka till inloggning",
|
||||
"checkEmail": "Kolla din e-post",
|
||||
"checkEmailHint": "Klicka på den för att återställa ditt lösenord.",
|
||||
"resetOk": "Lösenordet återställdes. Logga in.",
|
||||
"verifying": "Verifierar…",
|
||||
"invalidLink": "Ogiltig länk",
|
||||
"invalidLinkHint": "Den här återställningslänken är ogiltig eller har gått ut.",
|
||||
"resetTitle": "Återställ lösenord",
|
||||
"resetSubtitle": "Ange ditt nya lösenord",
|
||||
"confirmPassword": "Bekräfta lösenord",
|
||||
"minChars": "Minst 12 tecken",
|
||||
"confirmPlaceholder": "Bekräfta ditt lösenord",
|
||||
"bootstrapTitle": "Välkommen, administratör",
|
||||
"bootstrapSubtitle": "Skapa ditt administratörskonto",
|
||||
"name": "Namn",
|
||||
"username": "Användarnamn",
|
||||
"namePlaceholder": "Ditt namn",
|
||||
"adminEmailPlaceholder": "admin@example.com",
|
||||
"bootstrapOk": "Administratörskontot skapades. Logga in.",
|
||||
"debug": {
|
||||
"controls": "Kontroller:",
|
||||
"camera": "Kamera:",
|
||||
"model": "Modell:"
|
||||
}
|
||||
},
|
||||
"shortcuts": {
|
||||
"navigation": "Navigering",
|
||||
"spotlight": "Spotlight — gå till vilken skärm, plugin eller inställning som helst med namn",
|
||||
"thisList": "Denna lista",
|
||||
"themes": "Teman",
|
||||
"themesNote": "Webbläsaren tillämpar temat medan du flyttar dig, så du ser appen förändras i stället för att läsa namn.",
|
||||
"openBrowser": "Öppna temabläddraren",
|
||||
"prevNextTheme": "Föregående/nästa tema, utan att öppna något",
|
||||
"starCurrent": "Stjärnmärk eller avmarkera det aktuella temat, varifrån som helst",
|
||||
"inBrowser": "I temabläddraren",
|
||||
"moveList": "Flytta genom listan",
|
||||
"applyKeepBrowsing": "Tillämpa det markerade temat och fortsätt bläddra",
|
||||
"restoreStart": "Sätt tillbaka temat du började med",
|
||||
"starFocused": "Stjärnmärk eller avmarkera det markerade temat",
|
||||
"keepLooking": "Behåll det du tittar på",
|
||||
"cancelRestore": "Avbryt och återställ",
|
||||
"inSpotlight": "I Spotlight",
|
||||
"moveResults": "Flytta genom resultaten",
|
||||
"goThere": "Gå dit",
|
||||
"close": "Stäng",
|
||||
"chat": "Chatt",
|
||||
"chatNote": "Återkallning tar bara piltangenten från ett TOMT skrivfält, och varje annan tangent avslutar återkallningen — så ett utkast skrivs aldrig över.",
|
||||
"prevNextPrompt": "Föregående/nästa prompt du skickade, i skalstil",
|
||||
"send": "Skicka · på ett tomt skrivfält med något köat, skicka det nu",
|
||||
"newline": "Ny rad",
|
||||
"stopTurn": "Stoppa turen som genereras"
|
||||
},
|
||||
"jobs": {
|
||||
"runningQueued": "Körs och köade",
|
||||
"loading": "Läser in…",
|
||||
"nothingRunning": "Inget körs",
|
||||
"history": "Historik",
|
||||
"clearAll": "Rensa alla",
|
||||
"selectJob": "Välj ett jobb",
|
||||
"notFound": "Jobbet hittades inte.",
|
||||
"backToJobs": "Tillbaka till jobb",
|
||||
"stop": "Stoppa",
|
||||
"testNotification": "Skicka en testnotis till varje konfigurerad kanal",
|
||||
"search": "Sök…",
|
||||
"deleteFinished": "Ta bort alla avslutade jobb",
|
||||
"noChannels": "Inga kanaler konfigurerade",
|
||||
"notifyFailed": "Det gick inte att skicka notisen",
|
||||
"stopRequested": "Stopp begärt",
|
||||
"stopFailed": "Det gick inte att stoppa",
|
||||
"titles": "Titlar",
|
||||
"download": "Ladda ner",
|
||||
"input": "Indata",
|
||||
"output": "Utdata",
|
||||
"steps": "Steg",
|
||||
"selectStep": "Välj ett steg eller en deluppgift för att visa utdata",
|
||||
"noOutput": "Ingen utdata än",
|
||||
"tokens": "Tokens:",
|
||||
"cost": "Kostnad:",
|
||||
"status": {
|
||||
"running": "Körs",
|
||||
"queued": "Köad",
|
||||
"completed": "Klar",
|
||||
"failed": "Misslyckades",
|
||||
"stopped": "Stoppad",
|
||||
"interrupted": "Avbruten"
|
||||
},
|
||||
"failedWithCode": "Misslyckades (avslut {{code}})"
|
||||
},
|
||||
"activity": {
|
||||
"title": "Aktivitet",
|
||||
"backgroundTasks": "Bakgrundsuppgifter",
|
||||
"detached": "Frikopplad",
|
||||
"backToList": "Tillbaka till listan",
|
||||
"selectTask": "Välj en uppgift för att följa dess utdata live",
|
||||
"noneRunning": "inget körs"
|
||||
},
|
||||
"passkeyGate": {
|
||||
"setUp": "Ställ in din passkey"
|
||||
},
|
||||
"chatHistory": {
|
||||
"loadFailed": "Det gick inte att läsa in den här konversationen: {{reason}}"
|
||||
},
|
||||
"capability": {
|
||||
"frontmatter": "Frontmatter",
|
||||
"noMatches": "Inga träffar",
|
||||
"emptyFile": "Tom fil",
|
||||
"createFailed": "Det gick inte att skapa {{kind}}",
|
||||
"deleteFailed": "Det gick inte att ta bort {{kind}}",
|
||||
"searchPlaceholder": "Sök {{kind}}…",
|
||||
"selectOne": "Välj en {{kind}}",
|
||||
"selectToView": "Välj en {{kind}} för att visa dess innehåll",
|
||||
"delete": "Ta bort",
|
||||
"createPrompt": "Hjälp mig skapa innehållet för den här nya {{kind}}-filen",
|
||||
"improvePrompt": "Hjälp mig förstå och förbättra den här {{kind}}-filen"
|
||||
}
|
||||
}
|
||||
+60
-17
@@ -1,5 +1,34 @@
|
||||
# Database Patterns
|
||||
|
||||
## Dropping and adding a column in the same push makes drizzle ASK
|
||||
|
||||
`drizzle-kit push` cannot tell a drop-plus-add from a rename, so it stops and asks:
|
||||
|
||||
```
|
||||
Is grants_postgres column in roles table created or renamed from another column?
|
||||
❯ + grants_postgres create column
|
||||
~ provisioning_script › grants_postgres rename column
|
||||
```
|
||||
|
||||
**Non-interactively that is fatal and silent.** With stdin closed it bails, `bun db:push` still exits 0, and
|
||||
the `postdb:push` seed then runs against a schema that never changed — which is how you get a confusing
|
||||
error about a column that does not exist, several steps from the thing that failed.
|
||||
|
||||
Hit on 2026-08-23 replacing `roles.provisioning_script` with `grants_postgres`/`grants_docker`. It is the
|
||||
same family as the composite-unique truncate prompt below, and it matters more on a deploy: no agent driving
|
||||
a rollout over SSH has a terminal to answer with.
|
||||
|
||||
**So a release whose schema drops a column carries the SQL in its plan**, guarded and idempotent, and treats
|
||||
`bun db:push` as the check that it worked rather than the thing that does it:
|
||||
|
||||
```sql
|
||||
ALTER TABLE roles ADD COLUMN IF NOT EXISTS grants_postgres boolean NOT NULL DEFAULT false;
|
||||
ALTER TABLE roles ADD COLUMN IF NOT EXISTS grants_docker boolean NOT NULL DEFAULT false;
|
||||
ALTER TABLE roles DROP COLUMN IF EXISTS provisioning_script;
|
||||
```
|
||||
|
||||
Then push, and expect zero DDL. If it still wants to change something, the SQL was wrong.
|
||||
|
||||
## Overview
|
||||
|
||||
**One PostgreSQL database**, `officer_db`, exposed as the workspace package `officerdb`. Managed with
|
||||
@@ -62,8 +91,11 @@ check(
|
||||
'ck_server_integrations_provider',
|
||||
// sql.raw, not sql`${p}` — an interpolated JS string binds as a parameter, so the constraint is
|
||||
// emitted as `IN ($1, $2)` and Postgres refuses it, breaking push for the WHOLE schema.
|
||||
sql`${table.provider} IN (${sql.join(SERVER_PROVIDERS.map((p) => sql.raw(`'${p}'`)), sql`, `)})`,
|
||||
)
|
||||
sql`${table.provider} IN (${sql.join(
|
||||
SERVER_PROVIDERS.map((p) => sql.raw(`'${p}'`)),
|
||||
sql`, `,
|
||||
)})`,
|
||||
);
|
||||
```
|
||||
|
||||
Adding a constraint fails while existing rows violate it. That is the point: push refusing tells you the
|
||||
@@ -77,7 +109,7 @@ drizzle-kit mis-diffs named composite unique CONSTRAINTS. It reads them back fro
|
||||
them against a schema that declares exactly the same name, columns and order, decides they differ, and
|
||||
emits a `DROP CONSTRAINT` + `ADD CONSTRAINT` pair — on every push, forever. Fifteen of them made
|
||||
`db:push` plan 32 statements against a database that already matched, and `ADD UNIQUE` on a populated
|
||||
table is a data-risk statement, so push stopped on an interactive *"do you want to truncate?"* prompt
|
||||
table is a data-risk statement, so push stopped on an interactive _"do you want to truncate?"_ prompt
|
||||
that could never be resolved by answering it.
|
||||
|
||||
Reproduced on a database drizzle had itself created seconds earlier, so it is not drift and not
|
||||
@@ -87,7 +119,7 @@ stable. Verified on drizzle-kit 0.31.9 / drizzle-orm 0.45.1.
|
||||
|
||||
A unique index enforces exactly what the constraint did, and `onConflictDoUpdate({ target: [...] })`
|
||||
accepts it as an arbiter. The one thing it cannot do is be the target of a foreign key — Postgres
|
||||
requires a unique *constraint* there. Nothing here has a composite foreign-key target; check before
|
||||
requires a unique _constraint_ there. Nothing here has a composite foreign-key target; check before
|
||||
adding one.
|
||||
|
||||
**Name a foreign key explicitly when drizzle's generated name would exceed 63 characters.** Postgres
|
||||
@@ -99,22 +131,33 @@ longer version and re-creates the constraint on every push.
|
||||
**Known remaining churn, harmless:** a composite `primaryKey` has the same diffing bug and there is no
|
||||
index form to escape to — a primary key must be a constraint. `music_now_playing` therefore drops and
|
||||
re-adds `pk_music_now_playing` on every push. Two statements, silent, no prompt even with rows in the
|
||||
table, data unaffected. Naming it explicitly does not help. Leave it.
|
||||
table, data unaffected. Neither naming it nor un-naming it helps — both were tested. Leave it.
|
||||
|
||||
**Four foreign keys are over 63 characters and churn the same way**, found 2026-08-19 while pushing
|
||||
`chat_notification_prefs`. These are the case the paragraph above says to name explicitly, and none of
|
||||
them is:
|
||||
**All six over-long foreign keys are now named, 2026-08-23.** They were deferred as "noise rather than a
|
||||
fault" for four days. What ended that was a deploy plan saying _"expect no changes, stop and report if it
|
||||
wants to alter something"_ — which this made impossible to satisfy on any host, forcing the deploy agent to
|
||||
overrule its own instructions and continue. A documented annoyance became a plan instruction nobody could
|
||||
follow.
|
||||
|
||||
command_hq_session_tags_session_row_id_command_hq_sessions_id_fk 64
|
||||
command_hq_watcher_fires_session_row_id_command_hq_sessions_id_fk 65
|
||||
bitcoin_wallet_frozen_utxos_wallet_id_bitcoin_wallet_wallets_id_fk 66
|
||||
bitcoin_wallet_chain_cache_wallet_id_bitcoin_wallet_wallets_id_fk 65
|
||||
The six, and where each lives:
|
||||
|
||||
The tell is in the plan itself: the `DROP` names them at 63 characters and the `ADD` at their full
|
||||
length, which is Postgres's truncation and drizzle's copy disagreeing. Eight statements per push, no
|
||||
prompt, data unaffected — deferred rather than fixed (see `TODO.md`), because it is noise rather than
|
||||
a fault. Two live in `officer_db/src/command-hq/schema.ts`; the other two are the bitcoin-wallet
|
||||
plugin's, in its own repository.
|
||||
fk_command_hq_watcher_fires_session officer_db/src/command-hq/schema.ts
|
||||
fk_command_hq_session_tags_session officer_db/src/command-hq/schema.ts
|
||||
fk_bitcoin_wallet_frozen_utxos_wallet plugins/bitcoin-wallet/db/schema.ts
|
||||
fk_bitcoin_wallet_chain_cache_wallet plugins/bitcoin-wallet/db/schema.ts
|
||||
fk_atelier_collection_items_collection plugins/atelier/sidecar/db/collections/schema.ts
|
||||
fk_atelier_collection_items_generation plugins/atelier/sidecar/db/collections/schema.ts
|
||||
|
||||
Two things worth knowing if you add another. Naming it means REMOVING the inline `.references()` on the
|
||||
column, or drizzle creates both. And `walletChainCache` had to move from the two-argument `pgTable` form to
|
||||
the three-argument one, because there was nowhere else to put the declaration.
|
||||
|
||||
**`pk_music_now_playing` still churns, and it cannot be fixed.** Two statements per push. Tested on
|
||||
2026-08-23 rather than assumed: removing the explicit name produces the identical drop-and-re-add under
|
||||
drizzle's generated name (`music_now_playing_user_id_device_pk`), so this is the named-composite mis-diff
|
||||
rather than the truncation one, and there is no index form to escape to because a primary key must be a
|
||||
constraint. **A push is therefore expected to print exactly these two statements and nothing else** — which
|
||||
is a far more useful baseline than ten, because anything else really is your change.
|
||||
|
||||
**Rules that still apply:**
|
||||
|
||||
|
||||
@@ -20,10 +20,11 @@ export {
|
||||
cleanupExpiredTokens,
|
||||
} from './queries';
|
||||
|
||||
// Exported as a value, not just a type: the API and the UI need to enumerate the roles, and the
|
||||
// column definition is the only place that list should exist.
|
||||
export { USER_ROLES, OWNER_USER_ID } from './schema';
|
||||
export { OWNER_USER_ID } from './schema';
|
||||
|
||||
// A role list is no longer enumerable at the type level — see the comment on `UserRole` in schema.ts.
|
||||
// The API and the UI enumerate roles by querying the `roles` table (`listRoles` in ../roles), not by
|
||||
// importing a value from here.
|
||||
export type { UserRole } from './schema';
|
||||
export { listProvisionableUsers } from './queries';
|
||||
export type { ProvisionableUser } from './queries';
|
||||
|
||||
@@ -85,14 +85,18 @@ export async function getUserByUsername(username: string): Promise<UserSelect |
|
||||
// #1. Returns undefined rather than falling back to the lowest id when no row holds the role: the agent
|
||||
// sidecar refusing to start beats it silently running as the wrong person.
|
||||
//
|
||||
// `order by id` is not cosmetic. Without it, two rows holding the role would make "who owns this
|
||||
// server" whatever Postgres happened to return first — and that answer feeds the agent sidecar's
|
||||
// identity, the vault and origin scoping. The write paths refuse to create a second Super Admin
|
||||
// (create-user.ts and updateUserRoleHandler), so this should never have a choice to make; ordering is
|
||||
// what makes the outcome deterministic if one ever gets in by another route, and id 1 is the bootstrap
|
||||
// account the CHECK constraint already pins.
|
||||
// The owner is `OWNER_USER_ID`, so there is nothing to disambiguate and no ordering to get right.
|
||||
//
|
||||
// This used to select on `role = 'Super Admin'` with `order by id` as a tiebreak, because two rows could
|
||||
// hold the role and "who owns this server" would otherwise be whatever Postgres returned first — an answer
|
||||
// that feeds the agent sidecar's identity, the vault and origin scoping. The write paths refused a second
|
||||
// Super Admin to keep it single.
|
||||
//
|
||||
// Since 2026-08-23 ownership is an identity, so several accounts may hold `Super Admin` and none of them
|
||||
// becomes the owner by doing so. The tiebreak, the refusals and the CHECK that pinned user 1's role all
|
||||
// existed to protect an answer that is now a primary key.
|
||||
export async function getOwnerUser(): Promise<UserSelect | undefined> {
|
||||
const [user] = await db.select().from(users).where(eq(users.role, 'Super Admin')).orderBy(users.id).limit(1);
|
||||
const [user] = await db.select().from(users).where(eq(users.id, OWNER_USER_ID)).limit(1);
|
||||
return user;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,25 +1,28 @@
|
||||
import { pgTable, serial, text, integer, timestamp, index, uniqueIndex, check } from 'drizzle-orm/pg-core';
|
||||
import { pgTable, serial, text, integer, timestamp, index, uniqueIndex, check, foreignKey } from 'drizzle-orm/pg-core';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { roles } from '../roles/schema';
|
||||
|
||||
// The bootstrap account. `id` is a serial starting at 1 and bootstrap is gated on an empty user table,
|
||||
// so the first account created is always 1. Exported so the CHECK below and the delete guard in
|
||||
// queries/auth.ts state the number once between them.
|
||||
export const OWNER_USER_ID = 1;
|
||||
|
||||
// Roles, in descending authority. Exported as a value so the API and the UI can enumerate them from
|
||||
// the same place the column is defined — a second hand-written list of role names is how they drift.
|
||||
//
|
||||
// A text column with a TS enum rather than a Postgres enum type, matching `status` below: the set is
|
||||
// still settling, and adding a role should be a one-line schema change that `db:push` applies, not an
|
||||
// ALTER TYPE. Postgres will not reject an unknown string — the TS type is the guard.
|
||||
//
|
||||
// 'Super Admin' is the server owner, and this column is the ONLY thing that says so. isSuperAdmin()
|
||||
// and getOwnerUser() both read it; there is no SUPER_ADMIN_EMAIL and no "first account wins" fallback
|
||||
// any more. Two consequences worth holding on to: bootstrap must set it explicitly on the first
|
||||
// account, and demoting the last Super Admin would leave the platform with no owner. The CHECK on the
|
||||
// table below stops that for the bootstrap account specifically: user 1 cannot be anything else.
|
||||
export const USER_ROLES = ['Super Admin', 'Admin', 'Member', 'Developer'] as const;
|
||||
export type UserRole = (typeof USER_ROLES)[number];
|
||||
/**
|
||||
* Was a TS union (`(typeof USER_ROLES)[number]`) backed by a hardcoded four-role array. Since
|
||||
* docs/roles-and-teams.md phase 2 part 4, roles are data — the `roles` table, administered through
|
||||
* `/api/roles` — and `users.role` is a foreign key onto it rather than a column typed to a fixed set. A
|
||||
* role this column can legally hold is therefore not enumerable at the type level any more, so `UserRole`
|
||||
* is plain `string` and the compile-time guard it used to provide moves to a runtime check at the write
|
||||
* path: `getRole(name)` returning a row before a caller is allowed to assign or grant that name. See
|
||||
* `create-user.ts`, `manage-users.ts` and `permissions-routes.ts`.
|
||||
*
|
||||
* 'Super Admin' is still the server owner's role by name, and this column is the ONLY thing that says so
|
||||
* — isSuperAdmin() and getOwnerUser() both read it. Two consequences worth holding on to: bootstrap must
|
||||
* set it explicitly on the first account, and demoting the last Super Admin would leave the platform with
|
||||
* no owner. The CHECK on the table below stops that for the bootstrap account specifically: user 1 cannot
|
||||
* be anything else.
|
||||
*/
|
||||
export type UserRole = string;
|
||||
|
||||
export const users = pgTable(
|
||||
'users',
|
||||
@@ -31,8 +34,9 @@ export const users = pgTable(
|
||||
.notNull()
|
||||
.default('Unverified'),
|
||||
// Defaults to the least privileged role so a row created by any path that does not think about
|
||||
// authorisation cannot accidentally mint an admin.
|
||||
role: text('role', { enum: USER_ROLES }).notNull().default('Member'),
|
||||
// authorisation cannot accidentally mint an admin. Foreign key onto `roles.name` is declared below,
|
||||
// in the table's third argument — see `fk_users_role`.
|
||||
role: text('role').notNull().default('Member'),
|
||||
name: text('name'),
|
||||
/**
|
||||
* Required, and unique case-INSENSITIVELY — see the index below.
|
||||
@@ -93,6 +97,15 @@ export const users = pgTable(
|
||||
// "some row must be Super Admin" — that would need to see other rows. So this pins the bootstrap
|
||||
// account and nothing more; promoting or demoting anyone else is still free.
|
||||
//
|
||||
// KEPT after ownership stopped being a role, 2026-08-23, and `docs/roles-and-teams.md` phase 1 said to
|
||||
// remove it as redundant. It is redundant for the question it was written for — `isOwner` is now an id
|
||||
// comparison and does not read this column at all — but it still earns its place for a different one:
|
||||
// it stops user 1 being set to `Member` while remaining the owner, which is a state with no benefit and
|
||||
// a confusing display. A constraint that costs nothing and forbids a nonsense state is worth keeping
|
||||
// even after the reason it was added has gone.
|
||||
//
|
||||
// What it must NOT be mistaken for is the thing that makes user 1 the owner. It does not, any more.
|
||||
//
|
||||
// It cannot stop a DELETE either. deleteUser() in queries/auth.ts refuses id 1 for that half.
|
||||
//
|
||||
// sql.raw for the literal — an interpolated JS string binds as `$1`, and Postgres will not accept a
|
||||
@@ -101,6 +114,20 @@ export const users = pgTable(
|
||||
'ck_users_owner_is_super_admin',
|
||||
sql`${table.id} <> ${sql.raw(String(OWNER_USER_ID))} OR ${table.role} = ${sql.raw("'Super Admin'")}`,
|
||||
),
|
||||
// docs/roles-and-teams.md D5 and phase 2 part 2. Named explicitly rather than left to drizzle's
|
||||
// generated name — see src/databases/CLAUDE.md → "Composite keys" on why an over-long generated FK
|
||||
// name gets truncated by Postgres and then re-created on every push; named up front so this one is
|
||||
// never at risk of it regardless of length.
|
||||
//
|
||||
// ON UPDATE CASCADE only, no ON DELETE: a role cannot be deleted while any account holds it
|
||||
// (`deleteRole` in roles/queries.ts enforces D4), so there is no delete path for this to react to —
|
||||
// and leaving it at Postgres's default (effectively NO ACTION) makes the constraint itself a backstop
|
||||
// for that guard rather than silently nulling or cascading a delete the application never intended.
|
||||
foreignKey({
|
||||
name: 'fk_users_role',
|
||||
columns: [table.role],
|
||||
foreignColumns: [roles.name],
|
||||
}).onUpdate('cascade'),
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -3,7 +3,9 @@ export {
|
||||
createCommandHqCategory,
|
||||
renameCommandHqCategory,
|
||||
deleteCommandHqCategory,
|
||||
createCommandHqSession,
|
||||
createCommander,
|
||||
setCommanderSessionId,
|
||||
getCommandHqSessionById,
|
||||
listCommandHqSessions,
|
||||
listCommandHqSessionIds,
|
||||
getCommandHqSession,
|
||||
@@ -31,6 +33,7 @@ export {
|
||||
export type {
|
||||
CommandHqCategory,
|
||||
CommandHqSessionRow,
|
||||
NewCommander,
|
||||
CommandHqTag,
|
||||
CommandHqWatcherFire,
|
||||
CommandHqTurnCost,
|
||||
|
||||
@@ -54,7 +54,12 @@ export async function deleteCommandHqCategory(userId: number, id: number): Promi
|
||||
|
||||
export type CommandHqSessionRow = {
|
||||
id: number;
|
||||
sessionId: string;
|
||||
/** Claude's transcript uuid — null until the first turn reports one. See the schema file's docstring. */
|
||||
sessionId: string | null;
|
||||
/** `command_hq_sessions.dir_name` — the directory under `getCommandersDir(email)` that IS this commander. */
|
||||
dirName: string;
|
||||
/** What this commander operates on. Seeded with one path at creation; grown in conversation. */
|
||||
referencePaths: string[];
|
||||
/** What the owner called it. Null for rows filed before the column existed — fall back to the transcript. */
|
||||
name: string | null;
|
||||
categoryId: number | null;
|
||||
@@ -69,6 +74,8 @@ export type CommandHqSessionRow = {
|
||||
const SESSION_ROW_COLUMNS = {
|
||||
id: commandHqSessions.id,
|
||||
sessionId: commandHqSessions.sessionId,
|
||||
dirName: commandHqSessions.dirName,
|
||||
referencePaths: commandHqSessions.referencePaths,
|
||||
name: commandHqSessions.name,
|
||||
categoryId: commandHqSessions.categoryId,
|
||||
watcherArmed: commandHqSessions.watcherArmed,
|
||||
@@ -78,30 +85,67 @@ const SESSION_ROW_COLUMNS = {
|
||||
createdAt: commandHqSessions.createdAt,
|
||||
};
|
||||
|
||||
export type NewCommander = { userId: number; name: string; referencePath: string; categoryId?: number | null };
|
||||
|
||||
/**
|
||||
* File a session as a commander. Upsert on `(user_id, session_id)` — a second call (a retried request, a
|
||||
* client that raced itself) is a no-op rather than a duplicate-key error, and does NOT move an
|
||||
* already-categorised commander back to uncategorised.
|
||||
* Create a commander row — no transcript, no directory. The directory is the CALLER's job
|
||||
* (`api/command-hq/directory.ts`): this file is Postgres only, and `dirName` has to come back from HERE
|
||||
* before anything can be created on disk, since it is derived from the row's own id.
|
||||
*
|
||||
* Two statements in one transaction rather than a single `INSERT ... RETURNING`: `dirName` is derived FROM
|
||||
* the id Postgres assigns on insert, so the row has to exist — with some not-null placeholder — before its
|
||||
* own directory name is knowable. Nothing else here is a candidate: a name can collide (two commanders
|
||||
* called "Backups"), a random string is one more thing to keep unique, and the id already IS unique and
|
||||
* stable the moment it exists.
|
||||
*/
|
||||
export async function createCommandHqSession(
|
||||
userId: number,
|
||||
sessionId: string,
|
||||
categoryId: number | null = null,
|
||||
name: string | null = null,
|
||||
): Promise<CommandHqSessionRow> {
|
||||
export async function createCommander(input: NewCommander): Promise<CommandHqSessionRow> {
|
||||
return db.transaction(async (tx) => {
|
||||
const [inserted] = await tx
|
||||
.insert(commandHqSessions)
|
||||
.values({
|
||||
userId: input.userId,
|
||||
sessionId: null,
|
||||
dirName: '', // fixed up below, once the id it is derived from exists — never visible outside this transaction
|
||||
referencePaths: [input.referencePath],
|
||||
name: input.name,
|
||||
categoryId: input.categoryId ?? null,
|
||||
})
|
||||
.returning({ id: commandHqSessions.id });
|
||||
const [row] = await tx
|
||||
.update(commandHqSessions)
|
||||
.set({ dirName: String(inserted!.id) })
|
||||
.where(eq(commandHqSessions.id, inserted!.id))
|
||||
.returning(SESSION_ROW_COLUMNS);
|
||||
return row!;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Point a commander at its current conversation — the transcript id, once the harness reports one. Called
|
||||
* from `fileAsCommander` (`api/chat/websocket.ts`) the moment `session:claude` arrives, correlated by the
|
||||
* commander's own row id (`msg.commanderId`), not by upserting on the transcript id the way this table used
|
||||
* to be filed: the row already exists by the time any turn spawns, per `docs/commander-directory.md`, so
|
||||
* there is nothing left to upsert.
|
||||
*
|
||||
* A plain UPDATE, not gated on the column currently being null: a dead session replaced by a fresh one in
|
||||
* the same directory repoints the same row, which is the whole reason this is a pointer and not an identity.
|
||||
*/
|
||||
export async function setCommanderSessionId(userId: number, id: number, sessionId: string): Promise<boolean> {
|
||||
const rows = await db
|
||||
.update(commandHqSessions)
|
||||
.set({ sessionId })
|
||||
.where(and(eq(commandHqSessions.id, id), eq(commandHqSessions.userId, userId)))
|
||||
.returning({ id: commandHqSessions.id });
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
/** One commander's row, by its OWN id — for a caller (the composer, mid-creation) that has no transcript yet. */
|
||||
export async function getCommandHqSessionById(userId: number, id: number): Promise<CommandHqSessionRow | null> {
|
||||
const [row] = await db
|
||||
.insert(commandHqSessions)
|
||||
.values({ userId, sessionId, categoryId, name })
|
||||
.onConflictDoNothing({ target: [commandHqSessions.userId, commandHqSessions.sessionId] })
|
||||
.returning(SESSION_ROW_COLUMNS);
|
||||
// `onConflictDoNothing` returns nothing on the conflict path, so the existing row is fetched explicitly —
|
||||
// the caller still needs an id back (to attach tags, say) even when this call raced an earlier one.
|
||||
if (row) return row;
|
||||
const [existing] = await db
|
||||
.select(SESSION_ROW_COLUMNS)
|
||||
.from(commandHqSessions)
|
||||
.where(and(eq(commandHqSessions.userId, userId), eq(commandHqSessions.sessionId, sessionId)));
|
||||
return existing!;
|
||||
.where(and(eq(commandHqSessions.userId, userId), eq(commandHqSessions.id, id)));
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -129,15 +173,22 @@ const isCategoryNull = () => isNull(commandHqSessions.categoryId);
|
||||
* Just the session ids this user has filed — the whole shape `/chat`'s list needs to exclude them, and
|
||||
* `/command-hq`'s grid needs to include only them. One query, no join against transcripts (they are not in
|
||||
* Postgres).
|
||||
*
|
||||
* A commander between `POST /commanders` and its first turn has no transcript yet — `sessionId` is null —
|
||||
* and is filtered out here rather than returned as a literal `null` in the set: there is no transcript id
|
||||
* to exclude from `/chat`'s list, and a null in a `Set<string>` would only ever fail to match anything.
|
||||
*/
|
||||
export async function listCommandHqSessionIds(userId: number): Promise<Set<string>> {
|
||||
const rows = await db
|
||||
.select({ sessionId: commandHqSessions.sessionId })
|
||||
.from(commandHqSessions)
|
||||
.where(eq(commandHqSessions.userId, userId));
|
||||
return new Set(rows.map((r) => r.sessionId));
|
||||
.where(and(eq(commandHqSessions.userId, userId), isNotNullSessionId()));
|
||||
return new Set(rows.map((r) => r.sessionId!));
|
||||
}
|
||||
|
||||
// drizzle's `eq` cannot express `IS NOT NULL` either.
|
||||
const isNotNullSessionId = () => sql`${commandHqSessions.sessionId} is not null`;
|
||||
|
||||
export async function getCommandHqSession(userId: number, sessionId: string): Promise<CommandHqSessionRow | null> {
|
||||
const [row] = await db
|
||||
.select(SESSION_ROW_COLUMNS)
|
||||
@@ -352,7 +403,13 @@ export async function deleteCommandHqSession(userId: number, sessionId: string):
|
||||
*/
|
||||
export async function purgeCommandHqSessions(userId: number, liveSessionIds: Set<string>): Promise<number> {
|
||||
const all = await listCommandHqSessions(userId);
|
||||
const dead = all.filter((row) => !liveSessionIds.has(row.sessionId)).map((row) => row.sessionId);
|
||||
// A row with `sessionId: null` has no transcript YET — it is mid-creation, not dead — so it is never a
|
||||
// purge candidate regardless of what `liveSessionIds` holds. Only a row that HAD a transcript and lost it
|
||||
// is "dead" in the sense this sweep exists to clean up.
|
||||
const dead = all
|
||||
.filter((row): row is CommandHqSessionRow & { sessionId: string } => row.sessionId !== null)
|
||||
.filter((row) => !liveSessionIds.has(row.sessionId))
|
||||
.map((row) => row.sessionId);
|
||||
if (!dead.length) return 0;
|
||||
const rows = await db
|
||||
.delete(commandHqSessions)
|
||||
@@ -508,7 +565,11 @@ export async function parentCommanderOf(spawnedSessionId: string): Promise<Spawn
|
||||
.innerJoin(commandHqSessions, eq(commandHqSessions.id, commandHqSpawned.sessionRowId))
|
||||
.where(eq(commandHqSpawned.spawnedSessionId, spawnedSessionId))
|
||||
.limit(1);
|
||||
if (!row) return null;
|
||||
// `row.sessionId` is null for a commander mid-creation — no live loop exists to have spawned anything yet
|
||||
// at that point, so a row in this state here would mean the query above found something it should not
|
||||
// have been able to. Treated as "no parent" rather than asserted away, for the same reason every other
|
||||
// null here is: an unexpected fact is not this function's to paper over.
|
||||
if (!row || row.sessionId === null) return null;
|
||||
return {
|
||||
sessionId: row.sessionId,
|
||||
name: row.name,
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
text,
|
||||
timestamp,
|
||||
uniqueIndex,
|
||||
foreignKey,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
import { users } from '../auth/schema';
|
||||
|
||||
@@ -64,13 +65,17 @@ export const commandHqCategories = pgTable(
|
||||
);
|
||||
|
||||
/**
|
||||
* One row per commander — the mapping from a Claude transcript to Command HQ's own metadata about it.
|
||||
* One row per commander. The ROW is the commander now — `docs/commander-directory.md` — with its own
|
||||
* directory on disk (`dirName`) and its own id, not a mapping keyed on a Claude transcript. `sessionId` is
|
||||
* a nullable POINTER to whichever conversation is current, set once the harness reports one and free to be
|
||||
* repointed if that conversation dies and a fresh one replaces it in the same directory.
|
||||
*
|
||||
* `/chat` and `/command-hq` read the SAME underlying session list (transcripts on disk) and this table is
|
||||
* the filter between them: `/chat` excludes any session with a row here, `/command-hq` includes only
|
||||
* sessions that have one. The table is never itself the list of what exists — a row whose transcript is
|
||||
* gone is simply absent from both lists, on purpose. See `docs/command-hq.md` → "The /chat ↔ /command-hq
|
||||
* relationship".
|
||||
* `/chat` and `/command-hq` still read the SAME underlying session list (transcripts on disk) once a row
|
||||
* has a `sessionId`, and this table is still the filter between them: `/chat` excludes any session with a
|
||||
* row here, `/command-hq` includes rows that have a live transcript. A row whose transcript is gone is
|
||||
* simply absent from both lists, on purpose. See `docs/command-hq.md` → "The /chat ↔ /command-hq
|
||||
* relationship" — written before the identity change, still correct for a row that already has a
|
||||
* transcript.
|
||||
*/
|
||||
export const commandHqSessions = pgTable(
|
||||
'command_hq_sessions',
|
||||
@@ -84,8 +89,38 @@ export const commandHqSessions = pgTable(
|
||||
* for the same reason: everything that joins back to a session on disk (models, events, this table)
|
||||
* uses the id the transcript itself carries, because that is the one Officer's `sessionId` and Claude's
|
||||
* id spaces both agree on only sometimes.
|
||||
*
|
||||
* **Nullable, since 2026-08-24 (`docs/commander-directory.md`).** This used to be the commander's
|
||||
* IDENTITY — the row was created only once a transcript existed, which is what forced a commander to be
|
||||
* created after its own working directory rather than before. The row's own `id`/`dirName` is the
|
||||
* identity now; this is a POINTER to whichever conversation is current, null from the moment the row is
|
||||
* created until the first turn reports a real transcript id, and free to be repointed later if a dead
|
||||
* session is replaced by a fresh one in the same directory.
|
||||
*/
|
||||
sessionId: text('session_id').notNull(),
|
||||
sessionId: text('session_id'),
|
||||
/**
|
||||
* The directory under `getCommandersDir(email)` that IS this commander —
|
||||
* `$DATA_PATH/<email>/commanders/<dirName>/`, holding `CLAUDE.md` and `state/`. See
|
||||
* `docs/commander-directory.md`.
|
||||
*
|
||||
* Derived from this row's own serial `id` (`String(id)`) the moment the row is inserted — nothing else
|
||||
* is guaranteed unique and stable before a name is even chosen. Not the transcript id: that does not
|
||||
* exist yet at creation, which is the entire point of this table gaining its own identity.
|
||||
*/
|
||||
dirName: text('dir_name').notNull(),
|
||||
/**
|
||||
* What the commander operates ON — not where it lives. Seeded with one path at creation (the start
|
||||
* dialog collects exactly one; asking for a list up front gets a worse answer than asking later) and
|
||||
* grown as the commander and the owner agree on more in conversation.
|
||||
*
|
||||
* A Postgres `text[]` rather than `jsonb`: every element is a plain path string with no structure of
|
||||
* its own, and an array supports appending in place (`array_append`) without reading the whole column
|
||||
* back to modify it — `jsonb` would buy nothing here but a heavier read-modify-write for the same list.
|
||||
*/
|
||||
referencePaths: text('reference_paths')
|
||||
.array()
|
||||
.notNull()
|
||||
.default([]),
|
||||
/**
|
||||
* What the owner called it, held here rather than only in the transcript's summary record.
|
||||
*
|
||||
@@ -129,6 +164,14 @@ export const commandHqSessions = pgTable(
|
||||
watcherStoppedAt: timestamp('watcher_stopped_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
// `sessionId` is nullable now, and this is left as a PLAIN unique index rather than a partial one
|
||||
// (`.where(isNotNull(...))`) deliberately, not by default: Postgres already treats every NULL as
|
||||
// distinct from every other NULL in a unique index, so any number of a user's commanders can sit with
|
||||
// `session_id: null` at once — which is exactly the window between `POST /commanders` creating the row
|
||||
// and the first turn reporting a real transcript id, for however many commanders are mid-creation at
|
||||
// the same time. The moment `session_id` is set, uniqueness still applies: one commander per transcript,
|
||||
// per user, same guarantee this index always gave. A partial index would enforce the identical rule with
|
||||
// an extra clause to keep in sync — nothing here needs it.
|
||||
(table) => [uniqueIndex('uq_command_hq_sessions_user_session').on(table.userId, table.sessionId)],
|
||||
);
|
||||
|
||||
@@ -145,14 +188,23 @@ export const commandHqWatcherFires = pgTable(
|
||||
'command_hq_watcher_fires',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
sessionRowId: integer('session_row_id')
|
||||
.notNull()
|
||||
.references(() => commandHqSessions.id, { onDelete: 'cascade' }),
|
||||
sessionRowId: integer('session_row_id').notNull(),
|
||||
/** Which watcher task fired — `commandHqSessions.watcherTaskId` at the moment this row was written. */
|
||||
taskId: text('task_id').notNull(),
|
||||
firedAt: timestamp('fired_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => [index('idx_command_hq_watcher_fires_session').on(table.sessionRowId, table.firedAt)],
|
||||
(table) => [
|
||||
// Named, because drizzle's generated name is 65 characters and Postgres truncates identifiers at 63 —
|
||||
// so it stores the short form, drizzle keeps comparing against its own long one, and every push drops
|
||||
// and re-adds an identical constraint. Documented in src/databases/CLAUDE.md and left unfixed until a
|
||||
// deploy plan said "expect no changes" and this made that unsatisfiable on all five hosts.
|
||||
foreignKey({
|
||||
name: 'fk_command_hq_watcher_fires_session',
|
||||
columns: [table.sessionRowId],
|
||||
foreignColumns: [commandHqSessions.id],
|
||||
}).onDelete('cascade'),
|
||||
index('idx_command_hq_watcher_fires_session').on(table.sessionRowId, table.firedAt),
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -198,14 +250,20 @@ export const commandHqTags = pgTable(
|
||||
export const commandHqSessionTags = pgTable(
|
||||
'command_hq_session_tags',
|
||||
{
|
||||
sessionRowId: integer('session_row_id')
|
||||
.notNull()
|
||||
.references(() => commandHqSessions.id, { onDelete: 'cascade' }),
|
||||
sessionRowId: integer('session_row_id').notNull(),
|
||||
tagId: integer('tag_id')
|
||||
.notNull()
|
||||
.references(() => commandHqTags.id, { onDelete: 'cascade' }),
|
||||
},
|
||||
(table) => [uniqueIndex('uq_command_hq_session_tags').on(table.sessionRowId, table.tagId)],
|
||||
(table) => [
|
||||
// 64 characters generated; see the note on `fk_command_hq_watcher_fires_session`.
|
||||
foreignKey({
|
||||
name: 'fk_command_hq_session_tags_session',
|
||||
columns: [table.sessionRowId],
|
||||
foreignColumns: [commandHqSessions.id],
|
||||
}).onDelete('cascade'),
|
||||
uniqueIndex('uq_command_hq_session_tags').on(table.sessionRowId, table.tagId),
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { pgTable, serial, text, timestamp, uniqueIndex, check } from 'drizzle-orm/pg-core';
|
||||
import { pgTable, serial, text, timestamp, uniqueIndex, check, foreignKey } from 'drizzle-orm/pg-core';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { USER_ROLES } from '../auth/schema';
|
||||
import { roles } from '../roles/schema';
|
||||
|
||||
// What a ROLE may reach. The subject of a grant is a role, never a user.
|
||||
//
|
||||
@@ -8,8 +8,8 @@ import { USER_ROLES } from '../auth/schema';
|
||||
// look more flexible and are the obvious next request. Per-user grants make "what can this person do" a
|
||||
// question you answer by reading rows, and "what can a Member do" a question with no answer at all. Roles
|
||||
// keep both answerable: onboarding is picking a role, and auditing is reading one short table. When the
|
||||
// answer really has to differ for one person, that person needs a role, and adding one is a line in
|
||||
// USER_ROLES.
|
||||
// answer really has to differ for one person, that person needs a role, and adding one is a row in
|
||||
// `roles` (see roles/schema.ts and /api/roles).
|
||||
//
|
||||
// A MISSING ROW MEANS NO ACCESS. There is no row that denies; absence denies. This is what makes the table
|
||||
// safe to read as the whole truth — an empty table is a platform where members can reach nothing but their
|
||||
@@ -27,7 +27,8 @@ export const rolePermissions = pgTable(
|
||||
'role_permissions',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
role: text('role', { enum: USER_ROLES }).notNull(),
|
||||
// Foreign key onto `roles.name` is declared below — see `fk_role_permissions_role`.
|
||||
role: text('role').notNull(),
|
||||
/** A permission key from the registry. Validated by the API, not by a constraint — see above. */
|
||||
permission: text('permission').notNull(),
|
||||
/**
|
||||
@@ -43,16 +44,6 @@ export const rolePermissions = pgTable(
|
||||
// re-creates them on every push, which stops db:push on an unanswerable truncate prompt. See
|
||||
// src/databases/CLAUDE.md → "Composite keys".
|
||||
uniqueIndex('uq_role_permissions_role_permission').on(table.role, table.permission),
|
||||
// Postgres will not reject an unknown string in a text column just because TypeScript narrowed it, so
|
||||
// the legal sets are stated here too. sql.raw for the literals: an interpolated JS string binds as a
|
||||
// parameter and Postgres refuses a parameter inside a CHECK, which breaks push for the WHOLE schema.
|
||||
check(
|
||||
'ck_role_permissions_role',
|
||||
sql`${table.role} IN (${sql.join(
|
||||
USER_ROLES.map((r) => sql.raw(`'${r}'`)),
|
||||
sql`, `,
|
||||
)})`,
|
||||
),
|
||||
check(
|
||||
'ck_role_permissions_level',
|
||||
sql`${table.level} IN (${sql.join(
|
||||
@@ -64,5 +55,19 @@ export const rolePermissions = pgTable(
|
||||
// short-circuits every check. A 'Super Admin' row here would be inert, and an inert row that looks
|
||||
// meaningful is worse than no row: someone would eventually revoke one and expect it to do something.
|
||||
check('ck_role_permissions_not_owner', sql`${table.role} <> ${sql.raw("'Super Admin'")}`),
|
||||
// docs/roles-and-teams.md D5 and phase 2 part 2. This REPLACES `ck_role_permissions_role`, which used
|
||||
// to list the four built-in role names literally (`role IN ('Super Admin', 'Admin', ...)`). That check
|
||||
// was already wrong the moment role CRUD (phase 2 part 3, `/api/roles`) could create a role at runtime
|
||||
// — a grant for a new role would have been refused by a constraint enforcing a snapshot of a list that
|
||||
// no longer described the actual role set. A foreign key onto the actual table is the correct
|
||||
// enforcement of "must be a real role" and needs no updating when a role is added.
|
||||
//
|
||||
// Named explicitly for the same reason as `fk_users_role` — see the comment there and
|
||||
// src/databases/CLAUDE.md → "Composite keys".
|
||||
foreignKey({
|
||||
name: 'fk_role_permissions_role',
|
||||
columns: [table.role],
|
||||
foreignColumns: [roles.name],
|
||||
}).onUpdate('cascade'),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1,2 +1,15 @@
|
||||
export { roles } from './schema';
|
||||
export { listRoles, getRole, seedRole, type RoleRow } from './queries';
|
||||
export {
|
||||
listRoles,
|
||||
getRole,
|
||||
seedRole,
|
||||
createRole,
|
||||
renameRole,
|
||||
deleteRole,
|
||||
setRoleGrants,
|
||||
type RoleRow,
|
||||
type CreateRoleResult,
|
||||
type RenameRoleResult,
|
||||
type DeleteRoleResult,
|
||||
type SetRoleGrantsResult,
|
||||
} from './queries';
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import { eq, like } from 'drizzle-orm';
|
||||
import { db } from '../db';
|
||||
import { roles } from './schema';
|
||||
import { createRole, getRole, seedRole, setRoleGrants } from './queries';
|
||||
|
||||
/*
|
||||
Phase 3 of docs/roles-and-teams.md: `grantsPostgres`/`grantsDocker` replace `rolePermitsDevTools()`'s
|
||||
hardcoded `role === 'Developer'`. These tests hit the real database, like chat-session-cleanup.test.ts —
|
||||
the thing being pinned is column plumbing (does the value survive a write and a read back), which a fake
|
||||
store would pass regardless of whether the SQL is right.
|
||||
|
||||
Every role name here is prefixed `zz-test-role-`, cleaned up in `afterEach` by prefix rather than by an
|
||||
exact list, so a test that fails before reaching its own cleanup does not leave a row for the next run to
|
||||
trip over.
|
||||
*/
|
||||
|
||||
const PREFIX = 'zz-test-role-';
|
||||
const name = (suffix: string) => `${PREFIX}${suffix}`;
|
||||
|
||||
afterEach(async () => {
|
||||
await db.delete(roles).where(like(roles.name, `${PREFIX}%`));
|
||||
});
|
||||
|
||||
describe('grants round trip through create and read', () => {
|
||||
test('a role created with both flags true reads back with both true', async () => {
|
||||
const created = await createRole({ name: name('both'), grantsPostgres: true, grantsDocker: true });
|
||||
expect(created.ok).toBe(true);
|
||||
if (!created.ok) return;
|
||||
expect(created.role.grantsPostgres).toBe(true);
|
||||
expect(created.role.grantsDocker).toBe(true);
|
||||
|
||||
const read = await getRole(name('both'));
|
||||
expect(read?.grantsPostgres).toBe(true);
|
||||
expect(read?.grantsDocker).toBe(true);
|
||||
});
|
||||
|
||||
test('omitted flags default to false, matching the column default', async () => {
|
||||
const created = await createRole({ name: name('bare') });
|
||||
expect(created.ok).toBe(true);
|
||||
if (!created.ok) return;
|
||||
expect(created.role.grantsPostgres).toBe(false);
|
||||
expect(created.role.grantsDocker).toBe(false);
|
||||
});
|
||||
|
||||
test('a role with grantsDocker false does not carry it after a Postgres-only grant', async () => {
|
||||
const created = await createRole({ name: name('pg-only'), grantsPostgres: true });
|
||||
expect(created.ok).toBe(true);
|
||||
if (!created.ok) return;
|
||||
expect(created.role.grantsPostgres).toBe(true);
|
||||
expect(created.role.grantsDocker).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setRoleGrants', () => {
|
||||
test('changes only the flags supplied, leaving the other as it was', async () => {
|
||||
const created = await createRole({ name: name('partial'), grantsPostgres: true, grantsDocker: true });
|
||||
expect(created.ok).toBe(true);
|
||||
|
||||
const updated = await setRoleGrants(name('partial'), { grantsDocker: false });
|
||||
expect(updated).toEqual({
|
||||
ok: true,
|
||||
role: expect.objectContaining({ grantsPostgres: true, grantsDocker: false }),
|
||||
});
|
||||
});
|
||||
|
||||
test('refuses a system role, so an edit cannot look like it worked and then revert on the next deploy', async () => {
|
||||
// isSystem is not settable through createRole, so insert the row directly — this is standing in for
|
||||
// one of the four seeded rows, which this function must never touch.
|
||||
await db.insert(roles).values({ name: name('system'), isSystem: true, grantsPostgres: false });
|
||||
|
||||
const result = await setRoleGrants(name('system'), { grantsPostgres: true });
|
||||
expect(result).toEqual({ ok: false, reason: 'system' });
|
||||
|
||||
// And the value is genuinely unchanged, not just the result code.
|
||||
expect((await getRole(name('system')))?.grantsPostgres).toBe(false);
|
||||
});
|
||||
|
||||
test('reports not-found for a role that does not exist', async () => {
|
||||
expect(await setRoleGrants(name('missing'), { grantsPostgres: true })).toEqual({
|
||||
ok: false,
|
||||
reason: 'not-found',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('seedRole', () => {
|
||||
test('is idempotent: seeding the same system role twice does not duplicate or error', async () => {
|
||||
const first = await seedRole({ name: name('seed'), isSystem: true, grantsPostgres: true });
|
||||
const second = await seedRole({ name: name('seed'), isSystem: true, grantsPostgres: true });
|
||||
expect(first).toBe(true); // created
|
||||
expect(second).toBe(false); // already present
|
||||
|
||||
const rows = await db.select().from(roles).where(eq(roles.name, name('seed')));
|
||||
expect(rows).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('corrects a system role whose grants were wrong, without touching a non-system role the same way', async () => {
|
||||
await seedRole({ name: name('correct-sys'), isSystem: true, grantsPostgres: true, grantsDocker: true });
|
||||
// Simulate the migration case: a `db:push` column-add left the row at the boolean default.
|
||||
await db.update(roles).set({ grantsPostgres: false, grantsDocker: false }).where(eq(roles.name, name('correct-sys')));
|
||||
expect(await getRole(name('correct-sys'))).toMatchObject({ grantsPostgres: false, grantsDocker: false });
|
||||
|
||||
// Re-running the same seed call is the fix — no separate migration script.
|
||||
await seedRole({ name: name('correct-sys'), isSystem: true, grantsPostgres: true, grantsDocker: true });
|
||||
expect(await getRole(name('correct-sys'))).toMatchObject({ grantsPostgres: true, grantsDocker: true });
|
||||
});
|
||||
|
||||
test('never rewrites description once a row exists, only grants', async () => {
|
||||
await seedRole({ name: name('desc'), description: 'original', isSystem: true, grantsPostgres: false });
|
||||
// An operator's edit, which the next deploy's seed call must not revert.
|
||||
await db.update(roles).set({ description: 'edited by an operator' }).where(eq(roles.name, name('desc')));
|
||||
|
||||
await seedRole({ name: name('desc'), description: 'original', isSystem: true, grantsPostgres: true });
|
||||
|
||||
const row = await getRole(name('desc'));
|
||||
expect(row?.description).toBe('edited by an operator');
|
||||
expect(row?.grantsPostgres).toBe(true);
|
||||
});
|
||||
|
||||
test('does not touch a non-system role at all, even if seeded once as one', async () => {
|
||||
// A non-system row's grants belong to whoever created it; seedRole's correction pass only applies
|
||||
// when `isSystem` is true in the call.
|
||||
const created = await createRole({ name: name('custom'), grantsPostgres: true });
|
||||
expect(created.ok).toBe(true);
|
||||
|
||||
await seedRole({ name: name('custom'), isSystem: false, grantsPostgres: false });
|
||||
|
||||
expect((await getRole(name('custom')))?.grantsPostgres).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { asc, eq, sql } from 'drizzle-orm';
|
||||
import { and, asc, eq, sql } from 'drizzle-orm';
|
||||
import { db } from '../db';
|
||||
import { users } from '../auth/schema';
|
||||
import { roles } from './schema';
|
||||
@@ -7,7 +7,8 @@ export type RoleRow = {
|
||||
name: string;
|
||||
description: string | null;
|
||||
isSystem: boolean;
|
||||
provisioningScript: string | null;
|
||||
grantsPostgres: boolean;
|
||||
grantsDocker: boolean;
|
||||
createdAt: Date;
|
||||
};
|
||||
|
||||
@@ -24,13 +25,14 @@ export async function listRoles(): Promise<(RoleRow & { userCount: number })[]>
|
||||
name: roles.name,
|
||||
description: roles.description,
|
||||
isSystem: roles.isSystem,
|
||||
provisioningScript: roles.provisioningScript,
|
||||
grantsPostgres: roles.grantsPostgres,
|
||||
grantsDocker: roles.grantsDocker,
|
||||
createdAt: roles.createdAt,
|
||||
userCount: sql<number>`count(${users.id})::int`,
|
||||
})
|
||||
.from(roles)
|
||||
.leftJoin(users, eq(users.role, roles.name))
|
||||
.groupBy(roles.name, roles.description, roles.isSystem, roles.provisioningScript, roles.createdAt)
|
||||
.groupBy(roles.name, roles.description, roles.isSystem, roles.grantsPostgres, roles.grantsDocker, roles.createdAt)
|
||||
.orderBy(asc(roles.name));
|
||||
return rows;
|
||||
}
|
||||
@@ -41,21 +43,216 @@ export async function getRole(name: string): Promise<RoleRow | undefined> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed a role if it is not there. Idempotent, and it does NOT overwrite an existing row.
|
||||
* Seed a role if it is not there. Idempotent, but NOT symmetric across columns.
|
||||
*
|
||||
* Called from `postdb:push` on every machine, so it runs again on every schema push for the rest of this
|
||||
* platform's life. An upsert would mean a description edited on one server is silently reverted by the next
|
||||
* deploy — so the seed establishes a row and never touches it again.
|
||||
* platform's life. `description` is never rewritten once a row exists: an upsert there would mean a
|
||||
* description edited on one server is silently reverted by the next deploy — so that column is set on
|
||||
* insert and never touched again.
|
||||
*
|
||||
* `grantsPostgres`/`grantsDocker` are the opposite, and only for a role seeded with `isSystem: true`: they
|
||||
* ARE re-asserted on every run, because for a system role they are not an operator's data, they are the
|
||||
* platform's own declaration of what the role means — the same standing as its name. Two things need that:
|
||||
*
|
||||
* - A row can predate these columns. `db:push` adds them with `default false`, so an existing `Developer`
|
||||
* row gets `grantsPostgres: false, grantsDocker: false` the moment the column lands — an insert-only
|
||||
* seed would leave it there forever and silently take away dev tooling nobody asked to revoke.
|
||||
* - It is also what makes the guard in `setRoleGrants` (below) honest. That function refuses to touch a
|
||||
* system role's grants specifically so an edit cannot LOOK like it worked and then vanish on the next
|
||||
* deploy; this is the other half of that promise — the seed is the only writer, and it always writes
|
||||
* the same thing.
|
||||
*
|
||||
* A non-system role's grants are never touched here — those belong to whoever created the role, through
|
||||
* `setRoleGrants`, and this function has no opinion about a row it did not seed.
|
||||
*/
|
||||
export async function seedRole(row: {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
isSystem?: boolean;
|
||||
grantsPostgres?: boolean;
|
||||
grantsDocker?: boolean;
|
||||
}): Promise<boolean> {
|
||||
const isSystem = row.isSystem ?? false;
|
||||
const grantsPostgres = row.grantsPostgres ?? false;
|
||||
const grantsDocker = row.grantsDocker ?? false;
|
||||
|
||||
const inserted = await db
|
||||
.insert(roles)
|
||||
.values({ name: row.name, description: row.description ?? null, isSystem: row.isSystem ?? false })
|
||||
.values({ name: row.name, description: row.description ?? null, isSystem, grantsPostgres, grantsDocker })
|
||||
.onConflictDoNothing({ target: roles.name })
|
||||
.returning({ name: roles.name });
|
||||
|
||||
if (inserted.length === 0 && isSystem) {
|
||||
await db.update(roles).set({ grantsPostgres, grantsDocker }).where(eq(roles.name, row.name));
|
||||
}
|
||||
return inserted.length > 0;
|
||||
}
|
||||
|
||||
// ── Write half: role CRUD ─────────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Every function here returns a discriminated result instead of throwing, because the router has to give
|
||||
// a person a reason they can act on ("Developer is a system role" vs "3 accounts still hold it" are two
|
||||
// different screens), and a caught exception only carries a string to parse. The one thing each guard
|
||||
// shares: it re-checks its own invariant inside the write, not just before it, so a race between the
|
||||
// check and the statement fails closed rather than silently succeeding.
|
||||
|
||||
/** How many accounts hold this role. Used by `deleteRole` — D4 refuses to delete a role anyone holds. */
|
||||
async function roleHolderCount(name: string): Promise<number> {
|
||||
const [row] = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(users)
|
||||
.where(eq(users.role, name));
|
||||
return row?.count ?? 0;
|
||||
}
|
||||
|
||||
export type CreateRoleResult = { ok: true; role: RoleRow } | { ok: false; reason: 'invalid-name' | 'duplicate' };
|
||||
|
||||
/**
|
||||
* Create a role at runtime. `isSystem` is always false here — a role the platform's own code names by
|
||||
* string (Developer, Super Admin, Member; see the schema comment) is one seeded at startup, never one
|
||||
* typed into a form, and nothing reachable through this function may claim otherwise.
|
||||
*
|
||||
* `grantsPostgres`/`grantsDocker` default to false, same as the column defaults — a role created through a
|
||||
* form should not hand out a Postgres login and a Docker daemon because nobody unchecked a box. There is
|
||||
* no `isSystem` guard needed on the write itself: a role created here is never system, so nothing later
|
||||
* pins these two values the way `seedRole` pins Developer's.
|
||||
*/
|
||||
export async function createRole(input: {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
grantsPostgres?: boolean;
|
||||
grantsDocker?: boolean;
|
||||
}): Promise<CreateRoleResult> {
|
||||
const name = input.name.trim();
|
||||
if (!name) return { ok: false, reason: 'invalid-name' };
|
||||
|
||||
const inserted = await db
|
||||
.insert(roles)
|
||||
.values({
|
||||
name,
|
||||
description: input.description?.trim() || null,
|
||||
isSystem: false,
|
||||
grantsPostgres: input.grantsPostgres ?? false,
|
||||
grantsDocker: input.grantsDocker ?? false,
|
||||
})
|
||||
.onConflictDoNothing({ target: roles.name })
|
||||
.returning();
|
||||
const role = inserted[0];
|
||||
if (!role) return { ok: false, reason: 'duplicate' };
|
||||
return { ok: true, role };
|
||||
}
|
||||
|
||||
export type SetRoleGrantsResult = { ok: true; role: RoleRow } | { ok: false; reason: 'not-found' | 'system' };
|
||||
|
||||
/**
|
||||
* Change a role's provisioning flags after it has been created.
|
||||
*
|
||||
* Refuses on `isSystem`, and for a different reason than `renameRole`'s refusal: renaming protects the
|
||||
* NAME code depends on, this protects a PROMISE. `seedRole` re-asserts a system role's grants on every
|
||||
* `postdb:push`, so writing here would look like it worked and then silently revert on the next deploy —
|
||||
* exactly the "says something untrue" trap `manage-users.ts` calls out for the Postgres-follows-role case.
|
||||
* A custom role has no seed pinning it, so this is the only place its grants are ever set after creation.
|
||||
*
|
||||
* Only the two flags supplied are changed; the other keeps its current value, so a caller adjusting just
|
||||
* `grantsDocker` cannot accidentally reset `grantsPostgres` to false.
|
||||
*/
|
||||
export async function setRoleGrants(
|
||||
name: string,
|
||||
grants: { grantsPostgres?: boolean; grantsDocker?: boolean },
|
||||
): Promise<SetRoleGrantsResult> {
|
||||
const existing = await getRole(name);
|
||||
if (!existing) return { ok: false, reason: 'not-found' };
|
||||
if (existing.isSystem) return { ok: false, reason: 'system' };
|
||||
|
||||
// Same re-check as renameRole/deleteRole, same reason: the row must not have become system between the
|
||||
// read above and this write.
|
||||
const [role] = await db
|
||||
.update(roles)
|
||||
.set({
|
||||
grantsPostgres: grants.grantsPostgres ?? existing.grantsPostgres,
|
||||
grantsDocker: grants.grantsDocker ?? existing.grantsDocker,
|
||||
})
|
||||
.where(and(eq(roles.name, name), eq(roles.isSystem, false)))
|
||||
.returning();
|
||||
if (!role) return { ok: false, reason: 'system' };
|
||||
return { ok: true, role };
|
||||
}
|
||||
|
||||
export type RenameRoleResult =
|
||||
| { ok: true; role: RoleRow }
|
||||
| { ok: false; reason: 'not-found' | 'system' | 'invalid-name' | 'duplicate' };
|
||||
|
||||
/**
|
||||
* Rename a role. Refuses outright on `isSystem` (D4 in docs/roles-and-teams.md): `scripts/seed-roles.ts`
|
||||
* matches its four rows by name on every deploy, `bootstrap.ts` writes `'Super Admin'`, and `users.role`
|
||||
* defaults to `'Member'`. Renaming one of those four is a code change across those call sites that has not
|
||||
* happened, not a data edit — a rename would leave the seed re-creating the old name from under it rather
|
||||
* than correcting the row that moved.
|
||||
*
|
||||
* `users.role` and `role_permissions.role` are foreign keys onto this table with `ON UPDATE CASCADE`
|
||||
* (phase 2 part 2), so the `UPDATE` below moves every holder and every grant automatically, in the same
|
||||
* statement, as Postgres's own job rather than this function's. There used to be a guard here refusing the
|
||||
* rename while anyone held the role, because before the FK existed a rename would have orphaned them —
|
||||
* silently pointing every holder at a name that no longer existed. That guard is gone now that the
|
||||
* database itself does the moving.
|
||||
*/
|
||||
export async function renameRole(from: string, to: string): Promise<RenameRoleResult> {
|
||||
const name = to.trim();
|
||||
if (!name) return { ok: false, reason: 'invalid-name' };
|
||||
|
||||
const existing = await getRole(from);
|
||||
if (!existing) return { ok: false, reason: 'not-found' };
|
||||
if (existing.isSystem) return { ok: false, reason: 'system' };
|
||||
if (name === from) return { ok: true, role: existing };
|
||||
|
||||
const clash = await getRole(name);
|
||||
if (clash) return { ok: false, reason: 'duplicate' };
|
||||
|
||||
// `isSystem: false` in the WHERE is the re-check: belt and suspenders against a role becoming system
|
||||
// between the read above and this write.
|
||||
const [role] = await db
|
||||
.update(roles)
|
||||
.set({ name })
|
||||
.where(and(eq(roles.name, from), eq(roles.isSystem, false)))
|
||||
.returning();
|
||||
if (!role) return { ok: false, reason: 'system' };
|
||||
return { ok: true, role };
|
||||
}
|
||||
|
||||
export type DeleteRoleResult =
|
||||
| { ok: true }
|
||||
| { ok: false; reason: 'not-found' | 'system' | 'in-use'; holderCount?: number };
|
||||
|
||||
/**
|
||||
* Delete a role. Refuses if it is system-named (D4), and refuses if any account currently holds it.
|
||||
*
|
||||
* Never reassigns holders to a fallback — silently moving accounts onto a different role's grants is a
|
||||
* permission change nobody asked for, and "absence denies" is the posture everywhere else in this system.
|
||||
* The holder count and the delete run in one transaction so a role cannot be handed to someone in the gap
|
||||
* between counting and deleting.
|
||||
*/
|
||||
export async function deleteRole(name: string): Promise<DeleteRoleResult> {
|
||||
const existing = await getRole(name);
|
||||
if (!existing) return { ok: false, reason: 'not-found' };
|
||||
if (existing.isSystem) return { ok: false, reason: 'system' };
|
||||
|
||||
return db.transaction(async (tx) => {
|
||||
// Inline rather than a call to `roleHolderCount`: this one has to run inside the same transaction as
|
||||
// the delete below, so a role cannot be handed to someone in the gap between counting and deleting.
|
||||
const [holders] = await tx
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(users)
|
||||
.where(eq(users.role, name));
|
||||
const count = holders?.count ?? 0;
|
||||
if (count > 0) return { ok: false, reason: 'in-use', holderCount: count };
|
||||
|
||||
// Same re-check as renameRole, same reason: the row must not vanish out from under a role that
|
||||
// became system between the read above and this statement.
|
||||
const deleted = await tx
|
||||
.delete(roles)
|
||||
.where(and(eq(roles.name, name), eq(roles.isSystem, false)))
|
||||
.returning({ name: roles.name });
|
||||
if (!deleted.length) return { ok: false, reason: 'system' };
|
||||
return { ok: true };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import { pgTable, text, boolean, timestamp } from 'drizzle-orm/pg-core';
|
||||
* changes.
|
||||
*
|
||||
* The cost is that a rename is a data migration rather than a label edit. That is honest — a role name
|
||||
* appears in `rolePermitsDevTools`, in report text and in whatever a plugin has written down.
|
||||
* appears in `scripts/seed-roles.ts`'s seed table, in report text and in whatever a plugin has written down.
|
||||
*
|
||||
* ── What is NOT here ──
|
||||
*
|
||||
@@ -33,20 +33,30 @@ export const roles = pgTable('roles', {
|
||||
/**
|
||||
* A role the platform's own code names, so it cannot be deleted or renamed.
|
||||
*
|
||||
* `rolePermitsDevTools()` tests for `'Developer'` by name, `bootstrap.ts` sets `'Super Admin'`, and the
|
||||
* users table defaults to `'Member'`. Deleting or renaming one of those is not a data change, it is a
|
||||
* code change that has not happened yet — so the delete and rename paths refuse on this flag rather than
|
||||
* discovering it as a runtime failure somewhere else.
|
||||
* `scripts/seed-roles.ts` matches `'Developer'`, `'Admin'` and `'Member'` by name on every deploy,
|
||||
* `bootstrap.ts` sets `'Super Admin'`, and the users table defaults to `'Member'`. Deleting or renaming
|
||||
* one of those is not a data change, it is a code change that has not happened yet — so the delete and
|
||||
* rename paths refuse on this flag rather than discovering it as a runtime failure somewhere else.
|
||||
*/
|
||||
isSystem: boolean('is_system').notNull().default(false),
|
||||
/**
|
||||
* Reserved for the per-role provisioning that comes next: a script run when an account is given this
|
||||
* role, so "Developer gets rootless Docker" stops being a special case in `os-user-postgres.ts` and
|
||||
* becomes a row. Null today, and nothing reads it.
|
||||
* Per-role provisioning, as a closed vocabulary rather than a script.
|
||||
*
|
||||
* Declared now rather than later on purpose — it is the reason the owner wanted a table at all, and a
|
||||
* column that arrives with the feature that uses it is a second migration for no gain.
|
||||
* D8 in docs/roles-and-teams.md proposed a `provisioningScript` column: a shell script stored on the row
|
||||
* and run when an account is given the role. That was rejected before it was built — it is arbitrary
|
||||
* root-level code living in a database row, and D8's own warning explains why that is worse than it
|
||||
* looks: `usermod -aG docker` is root by a longer route
|
||||
* (`docker run -v /:/host -it alpine chroot /host` reads `.env`, every member's home and the wallet
|
||||
* seed). A role editable at runtime must not be a way to grant root by writing a string into a form.
|
||||
*
|
||||
* So the row CHOOSES from options the platform code already knows how to carry out safely, the same
|
||||
* shape as `multiarch` on a plugin's `osDependencies` (`src/servers/plugins/manifest.ts`): a fixed list
|
||||
* instead of a free string, so the manifest picks rather than supplies. `os-user-postgres.ts` and
|
||||
* `os-user-docker.ts` still own how each is provisioned; these columns only say whether a given role's
|
||||
* accounts get it.
|
||||
*/
|
||||
provisioningScript: text('provisioning_script'),
|
||||
grantsPostgres: boolean('grants_postgres').notNull().default(false),
|
||||
/** A rootless Docker daemon. See `grantsPostgres` above and `os-user-docker.ts` for what it costs per member. */
|
||||
grantsDocker: boolean('grants_docker').notNull().default(false),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
|
||||
+16
-5
@@ -16,7 +16,6 @@ import { resolveAuthToken } from './servers/auth-token';
|
||||
import { isWsProviderAllowed } from './servers/permissions/authorize';
|
||||
import { isTokenBlacklisted, getUserById } from 'officerdb';
|
||||
import { terminalWebsocket } from './servers/api/terminal/websocket';
|
||||
import { minesweeperWebsocket } from './servers/api/minesweeper/websocket';
|
||||
import { chatWebsocket } from './servers/api/chat/websocket';
|
||||
import { chatNotifyWebsocket } from './servers/api/chat/notify-delivery';
|
||||
import { registerChatPushNotifications } from './servers/api/chat/notify-push';
|
||||
@@ -30,6 +29,7 @@ import './servers/api/chat/opencode/sidecar-server'; // subscribe to the opencod
|
||||
import type { SidecarRegistration } from './servers/sidecar/registration-protocol';
|
||||
import { toShellUsername } from './servers/data-path';
|
||||
import { devProxyWebsocket, upgradeDevProxyWs } from './servers/api/dev-proxy/websocket';
|
||||
import { pluginWsWebsocket, upgradePluginWs } from './servers/api/plugin-ws/websocket';
|
||||
|
||||
// Build static file routes from public/
|
||||
// A snapshot of ./public, taken once at boot: one exact route per file, each holding a `Bun.file`
|
||||
@@ -156,11 +156,11 @@ async function handleSidecarQueueCommand(ws: ServerWebSocket<WSData>, msg: Recor
|
||||
const handlers: Record<string, any> = {
|
||||
terminal: terminalWebsocket,
|
||||
'dev-proxy': devProxyWebsocket,
|
||||
'plugin-ws': pluginWsWebsocket,
|
||||
chat: chatWebsocket,
|
||||
'chat-notify': chatNotifyWebsocket,
|
||||
'task-runner': taskRunnerWebsocket,
|
||||
pipeline: pipelineWebsocket,
|
||||
minesweeper: minesweeperWebsocket,
|
||||
sidecar: sidecarWebsocket,
|
||||
};
|
||||
|
||||
@@ -187,7 +187,6 @@ const WS_ROUTES: Record<string, UpgradableProvider> = {
|
||||
'/api/tasks/run/ws': 'task-runner',
|
||||
'/api/tasks/pipeline/ws': 'pipeline',
|
||||
'/api/terminal/ws': 'terminal',
|
||||
'/api/minesweeper/ws': 'minesweeper',
|
||||
'/api/chat/ws': 'chat',
|
||||
'/api/chat/notify/ws': 'chat-notify',
|
||||
};
|
||||
@@ -195,7 +194,7 @@ const WS_ROUTES: Record<string, UpgradableProvider> = {
|
||||
// Every provider named by a served route, plus the two that upgrade through their own handlers rather than
|
||||
// `upgradeWs`: `sidecar` (loopback process-to-process, its own route below) and `dev-proxy` (upgraded from
|
||||
// inside the proxy router). Named explicitly so their absence from WS_ROUTES cannot read as an oversight.
|
||||
const SERVED_WS_PROVIDERS = [...new Set([...Object.values(WS_ROUTES), 'sidecar', 'dev-proxy'])];
|
||||
const SERVED_WS_PROVIDERS = [...new Set([...Object.values(WS_ROUTES), 'sidecar', 'dev-proxy', 'plugin-ws'])];
|
||||
|
||||
/*
|
||||
Both doors into the platform, checked against the permission registry before either opens.
|
||||
@@ -366,7 +365,7 @@ console.log(
|
||||
* cannot serve is now a typecheck failure instead of a runtime one. `sidecar` and `dev-proxy` are
|
||||
* deliberately absent — they upgrade through their own handlers, not through here.
|
||||
*/
|
||||
type UpgradableProvider = 'terminal' | 'chat' | 'chat-notify' | 'task-runner' | 'pipeline' | 'minesweeper';
|
||||
type UpgradableProvider = 'terminal' | 'chat' | 'chat-notify' | 'task-runner' | 'pipeline';
|
||||
|
||||
async function upgradeWs(req: Request, server: any, provider: UpgradableProvider) {
|
||||
const token = new URL(req.url).searchParams.get('token');
|
||||
@@ -487,6 +486,18 @@ const server = serve({
|
||||
if (req.headers.get('upgrade') === 'websocket') return upgradeDevProxyWs(req, server);
|
||||
return honoServer.fetch(req, server);
|
||||
},
|
||||
/*
|
||||
Every plugin's socket, through one wildcard declared at boot.
|
||||
|
||||
This is the route that made a plugin websocket possible at all. `WS_ROUTES` is read once before
|
||||
`serve()`, so nothing installed later can add an entry to it — but a wildcard does not need to,
|
||||
because the plugin name is a path segment rather than a route. The same trick `dev-proxy` above has
|
||||
been using all along; it simply had no second consumer until now.
|
||||
*/
|
||||
'/api/plugin-ws/*': (req: Request, server: any) => {
|
||||
if (req.headers.get('upgrade') === 'websocket') return upgradePluginWs(req, server);
|
||||
return honoServer.fetch(req, server);
|
||||
},
|
||||
'/api/sidecar/register': (req: Request, server: any) => {
|
||||
const ok = server.upgrade(req, {
|
||||
data: { provider: 'sidecar', userId: 0, email: '', username: '' },
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { OFFICER_ITEMS_DIR } from '../data-path';
|
||||
import { capabilityRoots } from '../capability-sources';
|
||||
|
||||
// What the platform tells an agent about the surface it is speaking through.
|
||||
//
|
||||
@@ -107,6 +108,21 @@ export function compileAffordances(affordances: Affordance[]): string | null {
|
||||
}
|
||||
|
||||
/** Read and compile in one step, for callers that only want the string. */
|
||||
export function affordanceBlock(root: string = AFFORDANCES_DIR): string | null {
|
||||
return compileAffordances(loadAffordances(root));
|
||||
/**
|
||||
* Every affordance in force: the owner's own, plus each installed and enabled plugin's.
|
||||
*
|
||||
* Read in place rather than copied in — see `capability-sources.ts`. Unlike skills these are NOT
|
||||
* de-duplicated by name: an affordance is a paragraph of guidance, several can be true at once, and two
|
||||
* plugins describing the same surface differently is information rather than a collision.
|
||||
*/
|
||||
export function loadAffordancesAcross(roots: string[]): Affordance[] {
|
||||
return roots.flatMap((root) => loadAffordances(root));
|
||||
}
|
||||
|
||||
/**
|
||||
* Read and compile in one step. Async because resolving which plugins are enabled reads the database;
|
||||
* called once per session, which is what makes that affordable.
|
||||
*/
|
||||
export async function affordanceBlock(): Promise<string | null> {
|
||||
return compileAffordances(loadAffordancesAcross(await capabilityRoots('affordances')));
|
||||
}
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
import { describe, expect, test, afterEach } from 'bun:test';
|
||||
import { mkdirSync, rmSync, writeFileSync, existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { affordanceDirFor, affordanceSourceFor, publishAffordances, unpublishAffordances } from './publish';
|
||||
import { AFFORDANCES_DIR } from './load';
|
||||
import type { DiscoveredPlugin } from '../plugins/manifest';
|
||||
|
||||
// The failure that matters is not a copy going wrong — it is uninstall taking somebody else's files with
|
||||
// it. Every first-party plugin publishes under one `officerdev/` directory, so removing the wrong level
|
||||
// would strip them all and nobody would notice until an agent stopped knowing something.
|
||||
|
||||
const root = join(tmpdir(), `aff-pub-${process.pid}`);
|
||||
|
||||
const plugin = (appName: string, publisher = 'officerdev'): DiscoveredPlugin =>
|
||||
({ appName, dir: join(root, appName), manifest: { publisher } }) as DiscoveredPlugin;
|
||||
|
||||
const withAffordance = (appName: string, publisher = 'officerdev') => {
|
||||
const p = plugin(appName, publisher);
|
||||
mkdirSync(affordanceSourceFor(p), { recursive: true });
|
||||
writeFileSync(join(affordanceSourceFor(p), `${appName}.md`), `---\nname: ${appName}\n---\n\nbody\n`);
|
||||
return p;
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
for (const pub of ['officerdev', 'alice']) rmSync(join(AFFORDANCES_DIR, pub), { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('publishing', () => {
|
||||
test('lands under <publisher>/<appName>, mirroring mountPrefix', () => {
|
||||
const p = withAffordance('test-gitea');
|
||||
expect(affordanceDirFor(p)).toBe(join(AFFORDANCES_DIR, 'officerdev', 'test-gitea'));
|
||||
});
|
||||
|
||||
test('copies what the plugin ships and counts it', async () => {
|
||||
expect(await publishAffordances(withAffordance('test-gitea'))).toBe(1);
|
||||
});
|
||||
|
||||
test('a plugin shipping none is not a failure — most ship none', async () => {
|
||||
const bare = plugin('test-bare');
|
||||
mkdirSync(bare.dir, { recursive: true });
|
||||
expect(await publishAffordances(bare)).toBe(0);
|
||||
});
|
||||
|
||||
test('re-publishing overwrites rather than erroring, so re-install works', async () => {
|
||||
const p = withAffordance('test-gitea');
|
||||
expect(await publishAffordances(p)).toBe(1);
|
||||
expect(await publishAffordances(p)).toBe(1);
|
||||
});
|
||||
|
||||
test('two publishers with the same plugin name do not collide', async () => {
|
||||
await publishAffordances(withAffordance('test-gitea', 'officerdev'));
|
||||
await publishAffordances(withAffordance('test-gitea', 'alice'));
|
||||
expect(existsSync(join(AFFORDANCES_DIR, 'officerdev', 'test-gitea', 'test-gitea.md'))).toBe(true);
|
||||
expect(existsSync(join(AFFORDANCES_DIR, 'alice', 'test-gitea', 'test-gitea.md'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('unpublishing', () => {
|
||||
test('removes the plugin', async () => {
|
||||
const p = withAffordance('test-gitea');
|
||||
await publishAffordances(p);
|
||||
await unpublishAffordances(p);
|
||||
expect(existsSync(affordanceDirFor(p))).toBe(false);
|
||||
});
|
||||
|
||||
test('leaves the PUBLISHER alone — the thing that would silently strip every sibling', async () => {
|
||||
const a = withAffordance('test-gitea');
|
||||
const b = withAffordance('test-music');
|
||||
await publishAffordances(a);
|
||||
await publishAffordances(b);
|
||||
await unpublishAffordances(a);
|
||||
expect(existsSync(affordanceDirFor(b))).toBe(true);
|
||||
});
|
||||
|
||||
test('is safe when nothing was ever published', async () => {
|
||||
await unpublishAffordances(plugin('test-never'));
|
||||
});
|
||||
});
|
||||
@@ -1,62 +0,0 @@
|
||||
import { cp, readdir, rm } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { AFFORDANCES_DIR } from './load';
|
||||
import type { DiscoveredPlugin } from '../plugins/manifest';
|
||||
|
||||
// Copying a plugin's affordances into the store the agent reads.
|
||||
//
|
||||
// A plugin is a new SURFACE arriving at runtime, which is the case hand-written prose could never cover:
|
||||
// install gitea and no agent learns that `tea` is authenticated, or that `git` is the better tool for
|
||||
// what git already does. The plugin knows; nothing was carrying that across.
|
||||
//
|
||||
// Same shape as `publishAssets` — a directory in the plugin repository, copied to a platform-managed
|
||||
// place on install, removed on uninstall. Deliberately so: two mechanisms for "the plugin ships some
|
||||
// files" would drift, and this one is already understood.
|
||||
//
|
||||
// ── Why the destination is namespaced ──
|
||||
//
|
||||
// `<publisher>/<appName>/`, mirroring `mountPrefix`. Two plugins called `gitea` from different publishers
|
||||
// must not overwrite each other's affordances, and an affordance in the tree should say whose it is
|
||||
// without opening it. `core` occupies the appName slot for the platform's own.
|
||||
|
||||
/** Where a plugin's affordances land. `officerdev/gitea/`, beside `officer/core/`. */
|
||||
export const affordanceDirFor = (plugin: DiscoveredPlugin): string =>
|
||||
join(AFFORDANCES_DIR, plugin.manifest.publisher, plugin.appName);
|
||||
|
||||
/** What the plugin ships, if anything. */
|
||||
export const affordanceSourceFor = (plugin: DiscoveredPlugin): string => join(plugin.dir, 'affordances');
|
||||
|
||||
/**
|
||||
* Publish a plugin's affordances. Returns the number of files, or 0 when it ships none — which is the
|
||||
* ordinary case and not a failure.
|
||||
*
|
||||
* Idempotent by overwriting, so re-running an install republishes rather than erroring. It does NOT
|
||||
* remove files the plugin has since dropped; that is what uninstall is for, and deleting on every
|
||||
* install would make a half-finished copy destructive.
|
||||
*/
|
||||
export async function publishAffordances(plugin: DiscoveredPlugin): Promise<number> {
|
||||
const source = affordanceSourceFor(plugin);
|
||||
if (!existsSync(source)) return 0;
|
||||
|
||||
const dest = affordanceDirFor(plugin);
|
||||
await cp(source, dest, { recursive: true, force: true });
|
||||
|
||||
try {
|
||||
return ((await readdir(dest, { recursive: true })) as string[]).filter((f) => f.endsWith('.md')).length;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop a plugin's affordances.
|
||||
*
|
||||
* Removes the plugin's own directory, never the publisher's: `officerdev/` holds every first-party
|
||||
* plugin's, and taking it out because one was uninstalled would silently strip the rest.
|
||||
*
|
||||
* Safe to call when nothing was ever published.
|
||||
*/
|
||||
export async function unpublishAffordances(plugin: DiscoveredPlugin): Promise<void> {
|
||||
await rm(affordanceDirFor(plugin), { recursive: true, force: true });
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Handler } from 'hono';
|
||||
import { getUserCount, createUser, replaceRoleGrants, USER_ROLES } from 'officerdb';
|
||||
import { getUserCount, createUser, replaceRoleGrants, listRoles } from 'officerdb';
|
||||
import { DEFAULT_ROLE_PERMISSIONS } from '@@/permissions/registry';
|
||||
import argon2 from 'argon2';
|
||||
import * as errors from '@@/custom-errors';
|
||||
@@ -49,12 +49,21 @@ export const bootstrapHandler: Handler = async function (ctx) {
|
||||
// bootstrap is the one moment that happens exactly once per install, so seeding cannot fight a later
|
||||
// revocation — take one of these away and nothing puts it back.
|
||||
//
|
||||
// Read from the `roles` table rather than the old USER_ROLES list (docs/roles-and-teams.md phase 2 part
|
||||
// 4) — `scripts/seed-roles.ts` runs as part of `postdb:push`, before the server ever serves a request,
|
||||
// so by the time this handler can run the four built-in rows already exist. Filtered to `isSystem`
|
||||
// deliberately: this is seeding the platform's own baseline roles, not every role that happens to exist
|
||||
// — a role created at runtime through /api/roles before anyone has bootstrapped (which `roles` being
|
||||
// owner-gated makes unreachable today, but nothing here should assume that stays true) gets no grants
|
||||
// from this loop, same as it would get none from any other seed step.
|
||||
//
|
||||
// Non-fatal. An owner who exists but whose roles hold nothing is a working server with a one-click fix;
|
||||
// failing bootstrap over it would leave a platform with no account at all.
|
||||
try {
|
||||
for (const role of USER_ROLES.filter((r) => r !== 'Super Admin')) {
|
||||
const builtIn = (await listRoles()).filter((r) => r.isSystem && r.name !== 'Super Admin');
|
||||
for (const role of builtIn) {
|
||||
await replaceRoleGrants(
|
||||
role,
|
||||
role.name,
|
||||
DEFAULT_ROLE_PERMISSIONS.map((permission) => ({ permission, level: 'write' as const })),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { basename, dirname, join, resolve } from 'node:path';
|
||||
import { DATA_PATH, getCommandersDir } from '../../data-path';
|
||||
import { basename, dirname, join } from 'node:path';
|
||||
import { DATA_PATH } from '../../data-path';
|
||||
import {
|
||||
appendTextAs,
|
||||
findPathAs,
|
||||
@@ -120,10 +120,13 @@ export type ClaudeSessionSummary = {
|
||||
* it has to be able to filter commanders out of that gesture itself. A flag only the web knew about
|
||||
* would fix one surface and leave the other wrong.
|
||||
*
|
||||
* Derived from the CWD rather than from a database join. Every commander a user has shares one
|
||||
* directory — `getCommandersDir(email)`, never per-commander and never client-chosen — and this list
|
||||
* already reads each session's `cwd`. So it costs a string comparison, works for a commander whose
|
||||
* `command_hq_sessions` row was lost, and adds no query.
|
||||
* Optional and never set by this file — every commander now has its OWN directory
|
||||
* (`docs/commander-directory.md`), so there is no single path this list could compare a `cwd` against
|
||||
* the way it once did when every commander shared one. The real answer is a `command_hq_sessions` row
|
||||
* lookup, which needs a database call this module does not make; `chat.ts` computes it that way and
|
||||
* spreads it onto the rows it returns — see its own comment on why `isCommander` is deliberately absent
|
||||
* from THIS module's output. The field stays on the type because other callers read it optionally off
|
||||
* whatever `ClaudeSessionSummary` they were handed, undefined being the honest answer when nobody set it.
|
||||
*/
|
||||
isCommander?: boolean;
|
||||
};
|
||||
@@ -1019,26 +1022,6 @@ export function listClaudeSessions(who: ChatIdentity, cwd: string): ClaudeSessio
|
||||
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
||||
}
|
||||
|
||||
/**
|
||||
* Is this the default commanders directory?
|
||||
*
|
||||
* **No longer the test for whether something is a commander** — that is the `command_hq_sessions` row, since
|
||||
* a commander now chooses its own working directory. This remains only for the places that legitimately care
|
||||
* about that specific path: creating it for a new OS account, and as the historical location of commanders
|
||||
* created before the cwd was choosable.
|
||||
*
|
||||
* Compared after `resolve`, so a trailing slash or a `..` cannot make the same directory look like a
|
||||
* different one — the cwd here comes from a transcript rather than from code, and transcripts record
|
||||
* whatever the process was actually in.
|
||||
*/
|
||||
export function isCommandersDir(email: string, cwd: string): boolean {
|
||||
try {
|
||||
return resolve(cwd) === resolve(getCommandersDir(email));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One session's list-shaped summary, found by id wherever it lives.
|
||||
*
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { resolve } from 'node:path';
|
||||
import { isCommandersDir } from './claude-sessions';
|
||||
import { getCommandersDir } from '../../data-path';
|
||||
|
||||
const EMAIL = 'pastilhas@officer.dev';
|
||||
|
||||
// The live payload's `isCommander` is derived from a cwd read out of a transcript, so it is whatever the
|
||||
// process was actually in — not a path this code composed. These are the shapes that arrive.
|
||||
describe('isCommandersDir', () => {
|
||||
const dir = getCommandersDir(EMAIL);
|
||||
|
||||
it('recognises the commanders directory', () => {
|
||||
expect(isCommandersDir(EMAIL, dir)).toBe(true);
|
||||
});
|
||||
|
||||
it('survives a trailing slash and a .. hop', () => {
|
||||
expect(isCommandersDir(EMAIL, `${dir}/`)).toBe(true);
|
||||
expect(isCommandersDir(EMAIL, `${dir}/sub/..`)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a sibling and a child', () => {
|
||||
expect(isCommandersDir(EMAIL, resolve(dir, '..'))).toBe(false);
|
||||
expect(isCommandersDir(EMAIL, resolve(dir, 'nested'))).toBe(false);
|
||||
});
|
||||
|
||||
// A commander's cwd belongs to the account that owns it. Reading one user's directory as another's
|
||||
// would file their commanders under the wrong person and, in the iPhone swipe order, show them.
|
||||
it('is per account', () => {
|
||||
expect(isCommandersDir('someone-else@officer.dev', dir)).toBe(false);
|
||||
});
|
||||
|
||||
it('never throws on rubbish', () => {
|
||||
expect(isCommandersDir(EMAIL, '')).toBe(false);
|
||||
expect(isCommandersDir(EMAIL, '\0')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -77,19 +77,14 @@ export type ClientMessage =
|
||||
*/
|
||||
planMode?: boolean;
|
||||
/**
|
||||
* The name the owner chose for a COMMANDER before its first message.
|
||||
*
|
||||
* Claude Code will not accept a name for a new session — the harness names its own from the first
|
||||
* prompt — so the platform renames the transcript the moment it exists. Carried on the turn rather
|
||||
* than sent afterwards because it must not depend on a browser still being open: the name was chosen
|
||||
* before anything was said, and losing it to a closed tab would leave a commander called after
|
||||
* whatever it happened to say first.
|
||||
*
|
||||
* Applied once, by `fileAsCommander`, via `renameClaudeSession` — which writes a `summary` record and
|
||||
* therefore an EXPLICIT title, and an explicit title is never overwritten by `ai-title` or by the
|
||||
* first-message fallback.
|
||||
* The COMMANDER row this turn belongs to — `command_hq_sessions.id`. Set by `POST /command-hq/
|
||||
* commanders`, which creates the row (with its name, directory and `CLAUDE.md`) BEFORE this turn ever
|
||||
* starts — see `docs/commander-directory.md`. Carried on the turn so the server can point that row at
|
||||
* the transcript id the moment one exists (`fileAsCommander`, `websocket.ts`) and so `resolveChatCwd`
|
||||
* can resolve this turn's `cwd` to the commander's own directory without trusting a client-chosen
|
||||
* `cwd` for it.
|
||||
*/
|
||||
commanderName?: string;
|
||||
commanderId?: number;
|
||||
/**
|
||||
* This turn is arming a Command HQ watcher — see `docs/command-hq.md` → "The watcher loop". Set only
|
||||
* by `BackgroundJobDialog` submitting a `handoff` job inside a commander (`isCommander` true); every
|
||||
|
||||
@@ -15,7 +15,12 @@ import { sendClaudeCodeStreaming } from '@@/channels/send-claude-code';
|
||||
import { sendOpenCodeStreaming } from '@@/channels/send-opencode';
|
||||
import * as sidecar from '@@/sidecar-registry';
|
||||
import { join } from 'path';
|
||||
import { getOwnerHomeDir, getEmailAccountsDir, findEmailAccountName } from '../../../servers/data-path';
|
||||
import {
|
||||
getOwnerHomeDir,
|
||||
getEmailAccountsDir,
|
||||
findEmailAccountName,
|
||||
getCommanderDir,
|
||||
} from '../../../servers/data-path';
|
||||
import { attachedFilePaths } from './attached-files';
|
||||
import {
|
||||
getUserSettings,
|
||||
@@ -24,16 +29,15 @@ import {
|
||||
getUserById,
|
||||
setChatSessionModel,
|
||||
setChatSessionPlanMode,
|
||||
createCommandHqSession,
|
||||
setCommanderSessionId,
|
||||
getCommandHqSession,
|
||||
getCommandHqSessionById,
|
||||
armCommandHqWatcher,
|
||||
recordCommandHqWatcherFired,
|
||||
recordCommandHqTurn,
|
||||
} from 'officerdb';
|
||||
import { resolveHomeDir } from '@@/user-home';
|
||||
import { renameClaudeSession, type ChatIdentity } from './claude-sessions';
|
||||
import { chatIdentity } from './chat';
|
||||
import { commanderBlock } from '../command-hq/commander-block';
|
||||
import { claudeLoginState } from '@@/os-user-claude';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { logger } from './logger';
|
||||
@@ -146,30 +150,44 @@ async function resolveEmailCwd(ownerEmail: string, accountEmail?: string): Promi
|
||||
// The working directory a chat turn runs in, by context: email → the account dir; /chat → a chosen
|
||||
// pwd or the caller's own home; everything else (browser/project/dashboard) → the given cwd.
|
||||
async function resolveChatCwd(
|
||||
msg: { context?: string; contextId?: string; cwd?: string },
|
||||
msg: { context?: string; contextId?: string; cwd?: string; resumeSessionId?: string; commanderId?: number },
|
||||
email: string,
|
||||
userId: number,
|
||||
home: string,
|
||||
): Promise<string> {
|
||||
/*
|
||||
A commander runs where the owner chose, and the choice is required.
|
||||
A commander runs in its OWN directory — resolved here, server-side, never trusted from `msg.cwd`.
|
||||
|
||||
This used to force `getCommandersDir(email)` and ignore `msg.cwd` entirely — every commander shared one
|
||||
empty directory whose only job was to BE a cwd, because the cwd was how a commander was recognised.
|
||||
Identity is the `command_hq_sessions` row now, so the directory is free to be useful: a commander that
|
||||
works on the platform can live in the platform checkout, where its relative paths mean something and its
|
||||
transcripts sit beside the work they are about.
|
||||
This honoured a client-chosen `msg.cwd` from 2026-08-22 until `docs/commander-directory.md` reversed
|
||||
that call two days later, and the reversal is not just "a crafted message could misdirect a commander" —
|
||||
the CONTAINMENT reasoning that made a client-chosen path safe (`resolveCwd(home, …)`, so "client-chosen"
|
||||
never meant "anywhere") still holds, it just no longer applies, because the client has nothing left to
|
||||
choose. `POST /command-hq/commanders` creates the row, ITS directory and `CLAUDE.md` before any turn
|
||||
spawns, so this only ever has to look the directory back up: by `commanderId` on the turn that just
|
||||
created the row (no transcript id yet to look up by), or by the transcript id (`resumeSessionId`, or the
|
||||
officer session's own resume id on a later turn of the same conversation) once one exists. Either lookup
|
||||
lands on the same row, and `getCommanderDir(email, row.dirName)` is the one true answer either way.
|
||||
|
||||
The chosen path is resolved through `resolveCwd(home, …)`, the same containment the ordinary chat branch
|
||||
below uses — so "client-chosen" still does not mean "anywhere". A commander cannot be pointed outside
|
||||
the account's own tree by a crafted message.
|
||||
|
||||
No fallback to the old shared directory when nothing is chosen: the UI requires a directory before the
|
||||
first message can be sent, and a silent default would be a commander running somewhere nobody picked.
|
||||
Falling back to the account's home is the honest last resort for a caller that predates the requirement.
|
||||
Falls back to the account's home only when NEITHER is given, which should not happen once every path
|
||||
pre-creates the row — the honest last resort for a caller that predates it, not a real branch anybody
|
||||
expects to take.
|
||||
*/
|
||||
if (msg.context === 'command-hq') {
|
||||
const dir = msg.cwd?.trim() ? resolveCwd(home, msg.cwd) : home;
|
||||
const row =
|
||||
msg.commanderId !== undefined
|
||||
? await getCommandHqSessionById(userId, msg.commanderId)
|
||||
: msg.resumeSessionId
|
||||
? await getCommandHqSession(userId, msg.resumeSessionId)
|
||||
: null;
|
||||
if (!row) {
|
||||
// Loud, because the degradation is otherwise invisible: a commander running in the account's home has
|
||||
// no CLAUDE.md, so it knows nothing about itself and asks the owner what its job is on every turn —
|
||||
// which reads as the agent being forgetful rather than as a lookup that missed.
|
||||
console.warn(
|
||||
`[command-hq] no commander row for ${msg.commanderId !== undefined ? `id ${msg.commanderId}` : `session ${msg.resumeSessionId ?? '(none given)'}`} — falling back to the account home`,
|
||||
);
|
||||
}
|
||||
const dir = row ? getCommanderDir(email, row.dirName) : home;
|
||||
mkdirSync(dir, { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
@@ -358,14 +376,12 @@ function createMessageHandler(
|
||||
planMode?: boolean,
|
||||
commandHq?: boolean,
|
||||
/**
|
||||
* The name the owner chose before the conversation started, for a commander.
|
||||
*
|
||||
* Claude Code will not accept a name for a NEW session — the harness names its own — so the platform
|
||||
* renames it the moment the transcript exists. Done here rather than by the client because it must not
|
||||
* depend on a browser still being open: the name was chosen before the first message, and losing it to a
|
||||
* closed tab would leave a commander called after whatever it happened to say first.
|
||||
* The commander row THIS turn belongs to — `command_hq_sessions.id`, created by `POST /commanders`
|
||||
* before this turn ever started (`docs/commander-directory.md`). Used to point that row at the transcript
|
||||
* id the moment one exists, so a commander's own row and directory (name, `CLAUDE.md`, `dirName`) are
|
||||
* never something this handler creates — only something it finishes wiring up.
|
||||
*/
|
||||
commanderName?: string,
|
||||
commanderId?: number,
|
||||
) {
|
||||
/*
|
||||
The model messages are ATTRIBUTED to, which is not always the model the turn asked for.
|
||||
@@ -451,68 +467,33 @@ function createMessageHandler(
|
||||
};
|
||||
|
||||
/*
|
||||
File this session as a commander, once the transcript id is known — the same wait `record` above is
|
||||
built around, and for the same reason: there is nothing to key the row on before the harness announces
|
||||
it. Gated on `commandHq`, which the caller sets only when `msg.context === 'command-hq'`, so an
|
||||
ordinary `/chat` turn never touches this table.
|
||||
Point this commander's row at its transcript id, once the transcript id is known — the same wait
|
||||
`record` above is built around, and for the same reason: there is nothing to key the write on before the
|
||||
harness announces it. Gated on `commandHq && commanderId`, so an ordinary `/chat` turn never touches
|
||||
this table, and a commander turn with no id (should not happen once every caller pre-creates the row —
|
||||
see `docs/commander-directory.md`) is a no-op rather than a guess at creating one.
|
||||
|
||||
Idempotent by construction (`createCommandHqSession` upserts on `(user_id, session_id)`), so calling it
|
||||
again on every later turn of the same commander — which happens, because the client keeps sending
|
||||
`context: 'command-hq'` for `resolveChatCwd` to keep resolving the right directory — costs one no-op
|
||||
upsert rather than a duplicate row. That repetition is also a cheap self-heal: if the row were ever lost,
|
||||
the next turn puts it back.
|
||||
`setCommanderSessionId` is a plain UPDATE, not an upsert: the row, its directory and its name all exist
|
||||
already, written by `POST /commanders` before this turn started. There is nothing left to create here —
|
||||
only a pointer to set, which is why this is so much smaller than the `fileAsCommander` it replaces used
|
||||
to be. Idempotent by construction (an UPDATE by id), so calling it again on every later turn of the same
|
||||
commander costs one no-op write, and is a cheap self-heal if the pointer were ever lost.
|
||||
|
||||
Fire-and-forget, swallowed failure, same posture as `record`: a database hiccup here must not be a
|
||||
reason a chat turn fails.
|
||||
*/
|
||||
// Whether the chosen name has actually reached the transcript. False until a rename RETURNS true — see
|
||||
// the retry note inside `fileAsCommander`.
|
||||
let commanderNamed = false;
|
||||
|
||||
const fileAsCommander = (): void => {
|
||||
if (!commandHq || !transcriptId) return;
|
||||
if (!commandHq || !transcriptId || commanderId === undefined) return;
|
||||
const userId = sessionManager.getSession(sessionId)?.userId;
|
||||
if (!userId) return;
|
||||
const filedId = transcriptId;
|
||||
// The name goes in with the row, not only to the transcript: the list reads it from here and is
|
||||
// right immediately, rather than waiting on a summary record the harness cannot write until the
|
||||
// first turn has finished.
|
||||
void createCommandHqSession(userId, filedId, null, commanderName?.trim() || null)
|
||||
.then(async () => {
|
||||
/*
|
||||
The chosen name, applied once — and RETRIED, because the first attempt usually fails.
|
||||
|
||||
`fileAsCommander` runs on `session:claude`, which arrives at the START of a turn: the transcript
|
||||
file may not be on disk yet, and `renameClaudeSession` returns false when it cannot find one. It
|
||||
returns a boolean that nothing was checking, so the name was silently dropped and the conversation
|
||||
kept the harness's own `ai-title` — which is exactly what the first live test showed.
|
||||
|
||||
So the attempt is recorded and retried at the end of the turn, by which point the file certainly
|
||||
exists. Idempotent: once it succeeds, `commanderNamed` stops it repeating.
|
||||
|
||||
It still writes a `summary` record, which sets `hasExplicitTitle` — and an explicit title is never
|
||||
overwritten by `ai-title` or by the first-message fallback. So it wins whenever it lands.
|
||||
*/
|
||||
if (!commanderName?.trim() || commanderNamed) return;
|
||||
// The same identity resolution every chat route uses, rather than a second copy of it: it is what
|
||||
// decides the home and the OS user, and a rename writes a file as that user.
|
||||
const user = await getUserById(userId).catch(() => null);
|
||||
if (!user?.email) return;
|
||||
const who = await chatIdentity({ id: userId, email: user.email }).catch(() => null);
|
||||
if (!who) return;
|
||||
// `who.home` as the hint, not the old shared commanders directory: a commander chooses its own
|
||||
// working directory now, so that path is usually wrong. `findTranscript` treats the cwd as a HINT
|
||||
// and locates the transcript wherever it actually is — so this works either way, and passing a
|
||||
// path we know is probably wrong would be the kind of thing that reads as intent later.
|
||||
commanderNamed = renameClaudeSession(who, who.home, filedId, commanderName.trim());
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
logger.warn('Could not file this session as a commander', {
|
||||
sessionId,
|
||||
transcriptId: filedId,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
void setCommanderSessionId(userId, commanderId, transcriptId).catch((err: unknown) => {
|
||||
logger.warn('Could not point this commander at its transcript', {
|
||||
sessionId,
|
||||
transcriptId,
|
||||
commanderId,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return (msg: TurnMessage, seq?: number): void => {
|
||||
@@ -573,9 +554,6 @@ function createMessageHandler(
|
||||
record();
|
||||
fileAsCommander();
|
||||
}
|
||||
// And retried on every result until the name lands. The first attempt runs before the transcript file
|
||||
// exists, so the end of the first turn is the earliest a rename can succeed.
|
||||
if (msg.type === 'result' && commandHq && !commanderNamed) fileAsCommander();
|
||||
|
||||
/*
|
||||
The watcher loop — see `docs/command-hq.md` → "The watcher loop" and `handoffContract` in
|
||||
@@ -791,11 +769,6 @@ async function handleChat(
|
||||
msg: {
|
||||
prompt: string;
|
||||
displayText?: string;
|
||||
/**
|
||||
* The owner pressed Start instead of typing an opener — see `commanderBlock`. Carried on the message
|
||||
* rather than derived, because the only thing that can know it is the client that rendered the button.
|
||||
*/
|
||||
selfStarted?: boolean;
|
||||
sessionId?: string;
|
||||
model?: string;
|
||||
cwd?: string;
|
||||
@@ -931,14 +904,6 @@ export function sessionKeyForTranscript(transcriptId: string): string | undefine
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sessions already told what they are and where to record it.
|
||||
*
|
||||
* In memory, keyed on officer's session id: a restart re-briefs, which costs a few hundred tokens on one
|
||||
* turn and is the safe direction — a commander that was never told is one that cannot be stopped.
|
||||
*/
|
||||
const briefedSessions = new Set<string>();
|
||||
|
||||
export async function startClaudeTurn(params: {
|
||||
email: string;
|
||||
username: string;
|
||||
@@ -949,11 +914,6 @@ export async function startClaudeTurn(params: {
|
||||
msg: {
|
||||
prompt: string;
|
||||
displayText?: string;
|
||||
/**
|
||||
* The owner pressed Start instead of typing an opener — see `commanderBlock`. Carried on the message
|
||||
* rather than derived, because the only thing that can know it is the client that rendered the button.
|
||||
*/
|
||||
selfStarted?: boolean;
|
||||
groupSlug?: string;
|
||||
context?: string;
|
||||
contextId?: string;
|
||||
@@ -962,8 +922,8 @@ export async function startClaudeTurn(params: {
|
||||
images?: PromptImage[];
|
||||
resumeSessionId?: string;
|
||||
planMode?: boolean;
|
||||
/** See `commanderName` on the wire type — the name chosen before a commander's first message. */
|
||||
commanderName?: string;
|
||||
/** See `commanderId` on the wire type — the row `POST /commanders` already created for this turn. */
|
||||
commanderId?: number;
|
||||
armWatcher?: boolean;
|
||||
};
|
||||
effectivePrompt: string;
|
||||
@@ -972,34 +932,14 @@ export async function startClaudeTurn(params: {
|
||||
const { email, username, userId, ws, sessionId, model, msg, onOutcome } = params;
|
||||
|
||||
/*
|
||||
A commander is told its record paths ON A TURN, not in the system preamble.
|
||||
|
||||
The preamble is built inside `createSession`, which runs ONCE — on a conversation's first turn. For a
|
||||
commander that is precisely the turn where the block cannot exist: it has no `command_hq_sessions` row
|
||||
and no transcript id yet, so `commanderBlock` returns null, and every later turn reuses the same session
|
||||
and never rebuilds the preamble. So a commander never received the block at all, and its loop had no
|
||||
paths to record itself at. Its own test caught it: it armed a perfectly good watcher and wrote no
|
||||
`loop-pid`.
|
||||
|
||||
Riding `effectivePrompt` instead. That is the string the model sees; `displayText` is what the transcript
|
||||
shows — the same seam the background-job dialog already uses to send a hidden preamble — so this reaches
|
||||
the agent without appearing in the conversation.
|
||||
|
||||
Once per session, on the first turn where the block resolves, which is the first turn after the commander
|
||||
is filed. It stays available afterwards because it is in the conversation's own history from then on.
|
||||
Unlike before `docs/commander-directory.md`, nothing is injected into the prompt here for a commander.
|
||||
A commander's own `CLAUDE.md` — written by `POST /commanders`, before this turn ever started — covers
|
||||
what a per-turn block used to have to compile on the fly: its name, its reference path, and the paths
|
||||
it must record itself at. Claude Code reads a `CLAUDE.md` from the working directory at session start,
|
||||
so a commander told about itself in the FILE needs no per-turn injection, no `effectivePrompt` splice,
|
||||
and no first-turn special case — see the deleted `commander-block.ts` for what this replaced and why.
|
||||
*/
|
||||
let effectivePrompt = params.effectivePrompt;
|
||||
if (!briefedSessions.has(sessionId)) {
|
||||
const block = await commanderBlock({
|
||||
userId,
|
||||
sessionId: transcriptIdBySessionKey.get(sessionId) ?? msg.resumeSessionId ?? null,
|
||||
selfStarted: msg.selfStarted === true,
|
||||
});
|
||||
if (block) {
|
||||
briefedSessions.add(sessionId);
|
||||
effectivePrompt = `${block}\n\n---\n\n${effectivePrompt}`;
|
||||
}
|
||||
}
|
||||
const effectivePrompt = params.effectivePrompt;
|
||||
const fail = (error: string) => {
|
||||
sendToClient(ws, { type: 'error', message: error });
|
||||
onOutcome?.({ ok: false, error });
|
||||
@@ -1083,7 +1023,7 @@ export async function startClaudeTurn(params: {
|
||||
msg.resumeSessionId,
|
||||
msg.planMode,
|
||||
msg.context === 'command-hq',
|
||||
msg.commanderName,
|
||||
msg.commanderId,
|
||||
);
|
||||
// Wrapped rather than passed straight to the spawn call, so `onOutcome` sees the transcript id at the
|
||||
// exact moment `fileAsCommander` (inside `createMessageHandler`) does — both react to the same
|
||||
@@ -1161,11 +1101,6 @@ async function handleClaudeCodeChat(
|
||||
msg: {
|
||||
prompt: string;
|
||||
displayText?: string;
|
||||
/**
|
||||
* The owner pressed Start instead of typing an opener — see `commanderBlock`. Carried on the message
|
||||
* rather than derived, because the only thing that can know it is the client that rendered the button.
|
||||
*/
|
||||
selfStarted?: boolean;
|
||||
groupSlug?: string;
|
||||
context?: string;
|
||||
contextId?: string;
|
||||
@@ -1189,11 +1124,6 @@ async function handleOpenCodeChat(
|
||||
msg: {
|
||||
prompt: string;
|
||||
displayText?: string;
|
||||
/**
|
||||
* The owner pressed Start instead of typing an opener — see `commanderBlock`. Carried on the message
|
||||
* rather than derived, because the only thing that can know it is the client that rendered the button.
|
||||
*/
|
||||
selfStarted?: boolean;
|
||||
groupSlug?: string;
|
||||
context?: string;
|
||||
contextId?: string;
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
import { getCommandHqSession, getUserById } from 'officerdb';
|
||||
import { loopPidPath, spawnedRecordPath } from './spawned';
|
||||
|
||||
/*
|
||||
What a commander is told about ITSELF, once per session.
|
||||
|
||||
── Why this exists ──
|
||||
|
||||
A commander must record its own loop's pid and every agent that loop spawns, or it cannot be stopped and
|
||||
cannot be fully deleted. The instruction for that used to live in ONE place: the preamble the
|
||||
background-job dialog composes. Which was fine while the only way to arm a commander was through that
|
||||
dialog, and wrong the moment a commander arms itself from the conversation — the intended flow. An agent
|
||||
starting its own background job never sees that preamble, so it would record nothing, and delete would go
|
||||
back to leaving orphaned loops without anybody noticing it had.
|
||||
|
||||
── Why the paths come from here and the method comes from the skill ──
|
||||
|
||||
The split is not arbitrary. The METHOD — how to write the record, when, what to append — is reference
|
||||
material that belongs with everything else about commanders, in the skill, where it can be as long as it
|
||||
needs to be and is read only when relevant. The PATHS are a fact about THIS session that nothing else can
|
||||
supply: they are rooted in DATA_PATH and keyed on the commander's own transcript id, and an agent knows
|
||||
neither. So they are compiled in, always, like an affordance — because a fact the agent cannot derive is
|
||||
exactly what an always-loaded block is for.
|
||||
|
||||
Short for the same reason every affordance is short: it is charged to every turn of every commander.
|
||||
*/
|
||||
|
||||
type CommanderBlockParams = {
|
||||
/** The Officer account, for the row lookup and for the paths. */
|
||||
userId: number;
|
||||
/** Claude's transcript id — what a commander is keyed on everywhere. */
|
||||
sessionId: string | null;
|
||||
/**
|
||||
* The owner pressed Start rather than typing an opening message.
|
||||
*
|
||||
* They supplied a name and a working directory and nothing else, so there is no request to act on and
|
||||
* the commander has to run the interview itself. Only ever true on the turn the button sends — a
|
||||
* commander asked something later is an ordinary conversation and must not restart its intake.
|
||||
*/
|
||||
selfStarted?: boolean;
|
||||
};
|
||||
|
||||
export async function commanderBlock(params: CommanderBlockParams): Promise<string | null> {
|
||||
if (!params.sessionId) return null;
|
||||
|
||||
// The ROW decides, not the working directory. A commander chooses its own cwd, so the directory answers
|
||||
// nothing — and a session in the old shared directory is not a commander unless it was filed as one.
|
||||
const row = await getCommandHqSession(params.userId, params.sessionId).catch(() => null);
|
||||
if (!row) return null;
|
||||
|
||||
// The paths need the account's email and the row carries a user id, so this is a second read. Once per
|
||||
// session spawn, alongside the affordances and the skills index, which are also read here rather than
|
||||
// cached — the same trade for the same reason.
|
||||
const user = await getUserById(params.userId).catch(() => null);
|
||||
if (!user?.email) return null;
|
||||
const email = user.email;
|
||||
|
||||
return [
|
||||
'# You are a commander',
|
||||
'',
|
||||
'This conversation is a commander: an agent that keeps working when nobody is watching. Open the',
|
||||
'**Commanders** skill before starting or changing a watcher — it carries how the pieces fit, what to',
|
||||
'establish with the owner first, and the mistakes that are expensive.',
|
||||
'',
|
||||
'Two paths only this session can be told. **Write to them or you cannot be stopped:**',
|
||||
'',
|
||||
`- your loop's own pid: \`${loopPidPath(email, params.sessionId)}\``,
|
||||
`- one session id per line, for every agent your loop spawns: \`${spawnedRecordPath(email, params.sessionId)}\``,
|
||||
'',
|
||||
'The platform cannot find your process and cannot recover a spawned agent id after it exits, so this',
|
||||
'record is the only one. Without it, deleting this commander leaves your loop running.',
|
||||
'',
|
||||
'**Never arm a watcher on your first turn.** The first turn is for settling what to watch and what to do',
|
||||
'about it — arming before that is arming a guess, and a wrong condition either never fires or fires',
|
||||
'constantly and is found out days later. If you were given no paths above, you ARE on your first turn:',
|
||||
'that is what their absence means. Ask, and arm once it is agreed.',
|
||||
/*
|
||||
The button case. A pointer, not the interview itself: the questions live in the skill, which is
|
||||
re-read from disk on every run, so rewording one costs nothing. The same words here would cost a
|
||||
`pm2 restart officer` every time — and this file is the wrong home for doctrine that will be
|
||||
edited far more often than the mechanism around it.
|
||||
*/
|
||||
...(params.selfStarted
|
||||
? [
|
||||
'',
|
||||
'**Nobody has told you what they want yet.** They gave a name and a working directory and',
|
||||
'pressed Start, which is a request to run the interview — not a request to do anything else.',
|
||||
'Read the **Commanders** skill now and follow "When you started yourself": orient in this',
|
||||
'directory first, then open with what you found and ONE question.',
|
||||
]
|
||||
: []),
|
||||
].join('\n');
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { getUserByEmail } from 'officerdb';
|
||||
import { lookupOsUser } from '@@/os-user';
|
||||
import { join } from 'node:path';
|
||||
import { getCommanderDir, getCommanderStateDir } from '../../data-path';
|
||||
import { loopPidPath, spawnedRecordPath } from './spawned';
|
||||
|
||||
/*
|
||||
A commander's own directory, created once, up front — `docs/commander-directory.md`.
|
||||
|
||||
$DATA_PATH/<email>/commanders/<dirName>/
|
||||
CLAUDE.md written here, extended by the commander as the job is agreed
|
||||
state/ INSTANCE data: what it spawned, its loop's pid. Empty at creation.
|
||||
|
||||
Both exist before the first turn spawns, which is the whole point: Claude Code reads a CLAUDE.md from the
|
||||
working directory at session start, so a commander told about itself here needs no per-turn injection —
|
||||
see the deleted `commander-block.ts` for what this replaced.
|
||||
*/
|
||||
|
||||
export type CommanderClaudeMdParams = {
|
||||
name: string;
|
||||
referencePath: string;
|
||||
loopPidPath: string;
|
||||
spawnedPath: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* The file itself. Prose, not a config dump — this is a document the commander reads once and then keeps
|
||||
* extending, and the owner may open it directly.
|
||||
*
|
||||
* Deliberately does NOT carry the button-specific framing `commander-block.ts` used to add on a
|
||||
* `selfStarted` turn ("nobody has told you what they want yet — they pressed Start"). That framing is
|
||||
* conditional on how THIS conversation happened to begin, which a file written once and read on every
|
||||
* future session cannot express — and it would be actively wrong the day a fresh conversation replaces a
|
||||
* dead one in this same directory, or if the opening message was typed rather than a button press. The
|
||||
* button case is still covered: `CommandHqDetail.tsx`'s `startBrief` composes that framing into the
|
||||
* opening message itself, which is the right place for something true only of one specific turn.
|
||||
*/
|
||||
export function commanderClaudeMd(params: CommanderClaudeMdParams): string {
|
||||
return [
|
||||
`# ${params.name}`,
|
||||
'',
|
||||
'This is a commander: an agent that keeps working when nobody is watching. This file is its own —',
|
||||
'extend it as the job gets settled, and again whenever what it watches or what it does about that',
|
||||
'changes. Open the **Commanders** skill before starting or changing a watcher; it carries how the',
|
||||
'pieces fit, what to establish first, and the mistakes that are expensive.',
|
||||
'',
|
||||
'## What this watches',
|
||||
'',
|
||||
`Reference: \`${params.referencePath}\``,
|
||||
'',
|
||||
'That path is what this commander operates ON, not where it lives — this directory is its own, kept',
|
||||
'separate from the thing it watches. Read it before asking what the job is: it was named before',
|
||||
'anything else was said, so treat it as the subject rather than decoration. More references can be',
|
||||
'added here as they come up in conversation.',
|
||||
'',
|
||||
'## Recording itself',
|
||||
'',
|
||||
'Two paths only this commander can be told, and it must write to them or it cannot be stopped:',
|
||||
'',
|
||||
`- its loop's own pid: \`${params.loopPidPath}\``,
|
||||
`- one session id per line, for every agent its loop spawns: \`${params.spawnedPath}\``,
|
||||
'',
|
||||
'The platform cannot find this process and cannot recover a spawned agent id after it exits, so this',
|
||||
'record is the only one. Without it, deleting this commander leaves its loop running.',
|
||||
'',
|
||||
'## Before arming anything',
|
||||
'',
|
||||
'Never arm a watcher on the first turn. The first turn is for settling what to watch and what to do',
|
||||
'about it — arming before that is arming a guess, and a wrong condition either never fires or fires',
|
||||
'constantly and is found out days later. Ask, and arm once it is agreed.',
|
||||
'',
|
||||
'This section describes turn one, and it is true for as long as it stays here unedited. Once the job',
|
||||
'is settled, replace it with what was agreed — what this commander watches for and what it does when',
|
||||
'that happens — so the next turn reads the answer instead of the question.',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a commander's directory and write its `CLAUDE.md`. Called once, by `POST /command-hq/commanders`,
|
||||
* before any turn spawns — that ordering is the entire fix `docs/commander-directory.md` describes.
|
||||
*
|
||||
* `state/` is created empty rather than left to be created lazily on first write (as `getCommanderStateDir`
|
||||
* callers already do defensively): creating it here means the directory listing a commander's first turn
|
||||
* sees is complete from the start, definition and state both present, rather than state/ appearing later
|
||||
* as an unexplained addition.
|
||||
*/
|
||||
/**
|
||||
* Give the new directory to the account that will work in it.
|
||||
*
|
||||
* `confineUserTree` chowns `commanders/` to the member ONCE, at provisioning. Anything created after that
|
||||
* belongs to the service user — so a member's commander directory would be one their own `claude` cannot
|
||||
* write into, and the failure surfaces as an agent that dies on its first command in a directory it can
|
||||
* see but not use. Exactly the `general_chat_sessions` mistake `resolveChatCwd` already tells the story
|
||||
* about, one level deeper.
|
||||
*
|
||||
* A no-op for the owner, whose account has no `osUser` and who already owns everything under `DATA_PATH`
|
||||
* — which is why this was invisible: it works perfectly for the person most likely to test it.
|
||||
*
|
||||
* Best effort. A commander whose directory could not be handed over still exists and still works for the
|
||||
* owner; refusing to create it would be the worse trade. The failure is returned so the caller can say so.
|
||||
*/
|
||||
async function giveToAccount(email: string, dir: string): Promise<string | null> {
|
||||
const user = await getUserByEmail(email).catch(() => null);
|
||||
if (!user?.osUser) return null;
|
||||
|
||||
const ids = await lookupOsUser(user.osUser);
|
||||
if (!ids) return `no Linux account named ${user.osUser}`;
|
||||
|
||||
const proc = Bun.spawn(['sudo', '-n', 'chown', '-R', `${ids.uid}:${ids.gid}`, dir], {
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
});
|
||||
const err = await new Response(proc.stderr).text();
|
||||
return (await proc.exited) === 0 ? null : err.trim() || 'chown failed';
|
||||
}
|
||||
|
||||
export async function createCommanderDirectory(
|
||||
email: string,
|
||||
dirName: string,
|
||||
params: { name: string; referencePath: string },
|
||||
): Promise<{ dir: string; ownershipError: string | null }> {
|
||||
const dir = getCommanderDir(email, dirName);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
mkdirSync(getCommanderStateDir(email, dirName), { recursive: true });
|
||||
writeFileSync(
|
||||
join(dir, 'CLAUDE.md'),
|
||||
commanderClaudeMd({
|
||||
name: params.name,
|
||||
referencePath: params.referencePath,
|
||||
loopPidPath: loopPidPath(email, dirName),
|
||||
spawnedPath: spawnedRecordPath(email, dirName),
|
||||
}),
|
||||
);
|
||||
|
||||
// Last, so it covers CLAUDE.md and state/ as well as the directory itself.
|
||||
const ownershipError = await giveToAccount(email, dir);
|
||||
return { dir, ownershipError };
|
||||
}
|
||||
@@ -1,25 +1,19 @@
|
||||
import type { Context } from 'hono';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { rmSync } from 'node:fs';
|
||||
import { createRouter } from '../../create-router';
|
||||
import { checkBudget } from '../sub-agents/budget';
|
||||
import * as errors from '@@/custom-errors';
|
||||
import { resolveHomeDir } from '@@/user-home';
|
||||
import { getCommandersDir, toShellUsername } from '@@/data-path';
|
||||
import { toShellUsername, getCommanderDir } from '@@/data-path';
|
||||
import { forgetChatSessions, getChatEventsSince, ingestSpawnedChildren, sessionKeyForTranscriptId } from 'officerdb';
|
||||
import * as sidecar from '@@/sidecar-registry';
|
||||
import { findSessionKeyByClaudeSession } from '@@/sidecar/claude/state';
|
||||
import { sessionKeyForTranscript } from '../chat/websocket';
|
||||
import {
|
||||
clearSpawnedRecord,
|
||||
listSpawnedSessions,
|
||||
loopPidPath,
|
||||
spawnedRecordPath,
|
||||
stopCommanderProcesses,
|
||||
liveLoopPid,
|
||||
} from './spawned';
|
||||
import { listSpawnedSessions, loopPidPath, spawnedRecordPath, stopCommanderProcesses, liveLoopPid } from './spawned';
|
||||
import { createCommanderDirectory } from './directory';
|
||||
import type { ServerMessage } from '../chat/types';
|
||||
import {
|
||||
listClaudeSessions,
|
||||
loadClaudeSessionById,
|
||||
loadClaudeSessionSummaryById,
|
||||
renameClaudeSession,
|
||||
@@ -33,7 +27,8 @@ import {
|
||||
createCommandHqCategory,
|
||||
renameCommandHqCategory,
|
||||
deleteCommandHqCategory,
|
||||
createCommandHqSession,
|
||||
createCommander,
|
||||
setCommanderSessionId,
|
||||
listCommandHqSessions,
|
||||
getCommandHqSession,
|
||||
setCommandHqSessionCategory,
|
||||
@@ -67,17 +62,26 @@ import {
|
||||
*
|
||||
* ── Where commanders live ──
|
||||
*
|
||||
* **Wherever the owner chose.** Each commander names its own working directory when it is created, and that
|
||||
* directory is where its relative paths resolve and where its transcripts are grouped.
|
||||
* **Its own directory, created by this router before any turn spawns.** `POST /commanders` creates the row
|
||||
* AND `$DATA_PATH/<email>/commanders/<dirName>/` together, writes `CLAUDE.md`, and only then does the
|
||||
* caller start a turn with `cwd` set to that directory — see `docs/commander-directory.md` for why the
|
||||
* ordering is the entire fix.
|
||||
*
|
||||
* It was one shared directory per user until 2026-08-22, because the directory WAS the identity — the only
|
||||
* way to tell a commander from an ordinary chat. Identity is the `command_hq_sessions` row now, so the
|
||||
* directory became free to be useful, and a commander that works on the platform can live in the platform.
|
||||
* Two earlier designs are worth knowing so this one is not mistaken for a third try at the same idea. One
|
||||
* shared directory per user, until 2026-08-22, because the directory WAS the identity — the only way to
|
||||
* tell a commander from an ordinary chat, which is also what made this router's own listing impossible to
|
||||
* do any other way than a directory scan. Then, briefly, a commander chose an arbitrary directory anywhere
|
||||
* in its owner's tree, once identity moved to the `command_hq_sessions` row — free to be useful, but free
|
||||
* of a directory a commander could depend on existing, or write anything durable into before its first
|
||||
* turn even started. This router owning creation is what fixes both: identity is still the row, and the
|
||||
* directory is still guaranteed to exist and be this commander's own before anything runs in it.
|
||||
*
|
||||
* The consequence for this router is that listing is no longer a directory scan. It reads its rows and loads
|
||||
* each transcript BY ID (`loadClaudeSessionSummaryById`) — a handful of lookups rather than one walk. A row
|
||||
* whose transcript cannot be found anywhere is treated as absent, exactly as a missing file in the old scan
|
||||
* was, and `POST /purge` is what tidies those.
|
||||
* The consequence for this router is that listing is not a directory scan either way. It reads its rows and
|
||||
* loads each transcript BY ID (`loadClaudeSessionSummaryById`) — a handful of lookups rather than one walk.
|
||||
* A row whose transcript cannot be found anywhere is treated as absent, exactly as a missing file in a scan
|
||||
* would have been, and `POST /purge` is what tidies those. A row with no transcript YET (freshly created,
|
||||
* before its first turn) is treated the same way — absent from the grid until it has one — which is fine:
|
||||
* the composer that just created it already has everything it needs to keep going without a grid entry.
|
||||
*/
|
||||
|
||||
async function commandHqIdentity(user: { id: number; email: string }): Promise<ChatIdentity> {
|
||||
@@ -213,29 +217,29 @@ commandHqRouter.get('/commanders', async (ctx) => {
|
||||
const categoryId = categoryParam === undefined ? undefined : categoryParam === 'none' ? null : Number(categoryParam);
|
||||
|
||||
/*
|
||||
Each commander's transcript is loaded BY ID, not found by scanning one directory.
|
||||
Each commander's transcript is loaded BY ID, not found by scanning one directory — a commander's own
|
||||
directory holds `CLAUDE.md` and `state/`, never a transcript, so there is nothing to scan there any more
|
||||
either. One `locateTranscript` per row instead. Commanders are few — this is a handful of stats, not a
|
||||
directory walk per row — and a row whose transcript cannot be found anywhere is treated as absent,
|
||||
which is what `purgeCommandHqSessions` exists to tidy.
|
||||
|
||||
It used to be `listClaudeSessions(who, getCommandersDir(...))` — every commander lived in one place, so
|
||||
one scan found them all and a row without a match there was a row whose transcript had been deleted. A
|
||||
commander chooses its own working directory now, so that scan finds only the ones that happen to still
|
||||
live in the old shared folder and would silently hide every other.
|
||||
|
||||
One `locateTranscript` per row instead of one scan for all of them. Commanders are few — this is a
|
||||
handful of stats, not a directory walk per row — and a row whose transcript cannot be found anywhere is
|
||||
still treated exactly as before: absent, which is what `purgeCommandHqSessions` exists to tidy.
|
||||
A row with `sessionId: null` — created but never yet turned — is absent from this grid for the same
|
||||
reason: there is no transcript to summarise. Filtered out before the lookup rather than after, so
|
||||
`loadClaudeSessionSummaryById` is never asked about an id that cannot exist.
|
||||
*/
|
||||
const rows = await listCommandHqSessions(user.id, categoryId);
|
||||
const summaries = rows
|
||||
.map((row) => loadClaudeSessionSummaryById(who, row.sessionId))
|
||||
const withTranscript = rows.filter((row) => row.sessionId !== null);
|
||||
const summaries = withTranscript
|
||||
.map((row) => loadClaudeSessionSummaryById(who, row.sessionId!))
|
||||
.filter((s): s is NonNullable<typeof s> => !!s);
|
||||
|
||||
const liveById = new Map(summaries.map((s) => [s.id, s]));
|
||||
const filed = rows.filter((row) => liveById.has(row.sessionId));
|
||||
const filed = withTranscript.filter((row) => liveById.has(row.sessionId!));
|
||||
|
||||
const [tagsByRow, lastFiredByRow, runningById, costByRow] = await Promise.all([
|
||||
listCommandHqSessionTags(filed.map((row) => row.id)),
|
||||
getLastFiredAtBySessionRowIds(filed.map((row) => row.id)),
|
||||
Promise.all(filed.map(async (row) => [row.sessionId, await isRunning(row.sessionId)] as const)).then(
|
||||
Promise.all(filed.map(async (row) => [row.sessionId!, await isRunning(row.sessionId!)] as const)).then(
|
||||
(pairs) => new Map(pairs),
|
||||
),
|
||||
// One aggregate query per commander, the same cost class `isRunning` above already accepts for this
|
||||
@@ -248,18 +252,21 @@ commandHqRouter.get('/commanders', async (ctx) => {
|
||||
|
||||
const commanders: CommanderSummary[] = filed
|
||||
.map((row) => ({
|
||||
...liveById.get(row.sessionId)!,
|
||||
// The row's name wins over the transcript's. It is written when the commander is filed, while the
|
||||
// summary record cannot exist until the first turn ends — so until then the transcript still reports
|
||||
// the opening brief, and the row is the only source that is right from the start.
|
||||
...liveById.get(row.sessionId!)!,
|
||||
// The row's name wins over the transcript's. It is written when the commander is CREATED, before any
|
||||
// turn runs, while the transcript's own summary record cannot exist until the first turn ends — so
|
||||
// until then the transcript still reports the opening brief, and the row is the only source that is
|
||||
// right from the start.
|
||||
...(row.name ? { title: row.name } : {}),
|
||||
categoryId: row.categoryId,
|
||||
tagIds: tagsByRow.get(row.id) ?? [],
|
||||
running: runningById.get(row.sessionId) ?? false,
|
||||
running: runningById.get(row.sessionId!) ?? false,
|
||||
// The pid file is the FACT; `watcher_armed` is a claim only one path sets. A commander that started
|
||||
// its loop as a plain background job recorded its pid correctly and still reported unarmed, so the
|
||||
// grid offered nothing to stop — which is the one thing this column exists to make possible.
|
||||
watcherArmed: row.watcherArmed || liveLoopPid(who.email, row.sessionId) !== null,
|
||||
// grid offered nothing to stop — which is the one thing this column exists to make possible. Keyed
|
||||
// on `dirName`, not the transcript id: the pid file lives in this commander's OWN `state/`, not
|
||||
// beside the transcript.
|
||||
watcherArmed: row.watcherArmed || liveLoopPid(who.email, row.dirName) !== null,
|
||||
watcherArmedAt: row.watcherArmedAt,
|
||||
watcherStoppedAt: row.watcherStoppedAt,
|
||||
watcherLastFiredAt: lastFiredByRow.get(row.id) ?? null,
|
||||
@@ -271,26 +278,31 @@ commandHqRouter.get('/commanders', async (ctx) => {
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /command-hq/commanders/new { prompt: string, displayText?: string, categoryId?: number | null }
|
||||
* POST /command-hq/commanders/new { prompt: string, name: string, referencePath: string, displayText?:
|
||||
* string, categoryId?: number | null }
|
||||
*
|
||||
* Creates a commander for a client that cannot open a websocket with the right parameters — mobile,
|
||||
* notably. The web composer never calls this: it sends an ordinary `/chat` turn with `context:
|
||||
* 'command-hq'`, and the row is written the moment the harness reports a transcript id (see
|
||||
* `websocket.ts` → `fileAsCommander`). This route drives the SAME path — `startClaudeTurn`, the function
|
||||
* that logic was extracted into — with no live socket, and waits for that same id before answering.
|
||||
* notably. The web composer never calls this: it starts a chat turn directly, in the directory
|
||||
* `POST /commanders` (below) already created for it. This route drives the identical two-step order for a
|
||||
* caller with no live socket — create the row and its directory FIRST, via the same `createCommander` +
|
||||
* `createCommanderDirectory` this router's bare `POST /commanders` uses, THEN start the turn with `cwd` set
|
||||
* to that directory — rather than a second copy that could drift from it. See
|
||||
* `docs/commander-directory.md` for why the ordering is the fix and not an implementation detail.
|
||||
*
|
||||
* **This changed shape 2026-08-24.** `name` and `referencePath` are now required, and `cwd` is no longer
|
||||
* accepted as the actual working directory — a commander runs in its OWN directory now, never one a caller
|
||||
* names. Neither client had been exercised against a running server yet when this changed (`docs/command-
|
||||
* hq.md`), so there is no live caller to keep compatible with the old shape.
|
||||
*
|
||||
* `prompt` is required, deliberately. A commander begins as an ordinary conversation — sometimes a real
|
||||
* back-and-forth before a background job is ever armed, sometimes just "you know what to do" once that
|
||||
* has already happened elsewhere — but it always begins with something, the same as any `/chat`
|
||||
* conversation does. There is no "empty commander" to create and later fill in.
|
||||
*
|
||||
* `displayText`, separately, is what TITLES the commander — found missing here by the mobile side after
|
||||
* this route first shipped without it. A commander created to become a background job sends the full
|
||||
* `buildBackgroundJobPrompt` mechanics as `prompt` (a page of preamble) with the owner's actual sentence
|
||||
* only at the end, and `command_hq_sessions` has no name column — the title comes from
|
||||
* `session.meta.title = (displayText ?? prompt).slice(0, 100)`, same precedence the websocket path
|
||||
* already uses. Without this field every commander created this way titles itself with the mechanics
|
||||
* text, and the grid becomes a column of identical, unreadable cards.
|
||||
* `displayText`, separately, is what the TRANSCRIPT shows for the opening message — the row's own `name`
|
||||
* already titles the commander everywhere Command HQ reads it, but the harness still names its own summary
|
||||
* record from the first prompt, and `prompt` here is often the full `buildBackgroundJobPrompt` mechanics
|
||||
* rather than something a person would want to read back.
|
||||
*
|
||||
* The response's `sessionId` is Claude's own transcript id — not a placeholder, not Officer's — so the
|
||||
* client has something it can immediately resume, attach to, or list among its commanders. Getting it
|
||||
@@ -301,13 +313,31 @@ commandHqRouter.get('/commanders', async (ctx) => {
|
||||
*/
|
||||
commandHqRouter.post('/commanders/new', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const who = await commandHqIdentity(user);
|
||||
const body = await ctx.req
|
||||
.json<{ prompt?: string; displayText?: string; categoryId?: number | null; cwd?: string; name?: string }>()
|
||||
.json<{
|
||||
prompt?: string;
|
||||
displayText?: string;
|
||||
categoryId?: number | null;
|
||||
name?: string;
|
||||
referencePath?: string;
|
||||
}>()
|
||||
.catch(
|
||||
() => ({}) as { prompt?: string; displayText?: string; categoryId?: number | null; cwd?: string; name?: string },
|
||||
() =>
|
||||
({}) as {
|
||||
prompt?: string;
|
||||
displayText?: string;
|
||||
categoryId?: number | null;
|
||||
name?: string;
|
||||
referencePath?: string;
|
||||
},
|
||||
);
|
||||
const prompt = body.prompt?.trim();
|
||||
const name = body.name?.trim();
|
||||
const referencePath = body.referencePath?.trim();
|
||||
if (!prompt) throw errors.BAD_REQUEST('prompt is required');
|
||||
if (!name) throw errors.BAD_REQUEST('name is required');
|
||||
if (!referencePath) throw errors.BAD_REQUEST('referencePath is required');
|
||||
|
||||
// ── Room to run, before starting work nobody is watching ──
|
||||
//
|
||||
@@ -320,6 +350,10 @@ commandHqRouter.post('/commanders/new', async (ctx) => {
|
||||
const budget = await checkBudget();
|
||||
if (!budget.ok) throw errors.TOO_MANY_REQUESTS(budget.reason ?? 'not enough usage headroom to start an agent');
|
||||
|
||||
// Row and directory first — see `POST /commanders` below for the same two calls with the full reasoning.
|
||||
const row = await createCommander({ userId: user.id, name, referencePath, categoryId: body.categoryId ?? null });
|
||||
const dir = createCommanderDirectory(who.email, row.dirName, { name, referencePath });
|
||||
|
||||
const sessionId = randomUUID();
|
||||
const model = (await getUserDefaultModel(user.id)) || DEFAULT_MODEL;
|
||||
|
||||
@@ -345,12 +379,10 @@ commandHqRouter.post('/commanders/new', async (ctx) => {
|
||||
prompt,
|
||||
displayText: body.displayText?.trim() || undefined,
|
||||
context: 'command-hq',
|
||||
// Where this commander runs, and its name. Both are chosen before the first message — the UI makes
|
||||
// them mandatory — so this programmatic path takes them too rather than inventing a default. An
|
||||
// absent cwd falls back to the account's home in `resolveChatCwd`, which is the honest last resort
|
||||
// for a caller that predates the requirement.
|
||||
...(body.cwd?.trim() ? { cwd: body.cwd.trim() } : {}),
|
||||
...(body.name?.trim() ? { commanderName: body.name.trim() } : {}),
|
||||
// No `cwd` here: `resolveChatCwd` resolves the commander's directory itself from `commanderId`
|
||||
// (`docs/commander-directory.md`), the same as the web composer's turn does. `dir`, computed above,
|
||||
// is used only for the JSON response below.
|
||||
commanderId: row.id,
|
||||
},
|
||||
effectivePrompt: prompt,
|
||||
onOutcome: settle,
|
||||
@@ -360,20 +392,15 @@ commandHqRouter.post('/commanders/new', async (ctx) => {
|
||||
if (!outcome.ok) throw errors.INTERNAL_SERVER_ERROR(outcome.error);
|
||||
|
||||
/*
|
||||
The row already exists — `fileAsCommander` wrote it, off the same `session:claude` event this promise
|
||||
resolved on. What might not have landed yet is the CATEGORY: that write is fire-and-forget and always
|
||||
files with none, so a race between it and this request is possible in either order. `createCommandHqSession`
|
||||
is an upsert-on-conflict-do-nothing (safe either way), and the `setCommandHqSessionCategory` UPDATE below
|
||||
is unconditional and runs after both, which is what makes the outcome deterministic regardless of which
|
||||
of the two insert attempts actually won: whichever category this request asked for is the one that is
|
||||
left standing.
|
||||
Belt and braces: `fileAsCommander` (`websocket.ts`) already points this row at the transcript id off the
|
||||
same `session:claude` event this promise resolved on, but that write is fire-and-forget so it is not
|
||||
guaranteed to have landed by the time this handler resumes. `setCommanderSessionId` is a plain,
|
||||
idempotent UPDATE — calling it again here costs nothing and guarantees the row is correct before this
|
||||
response goes out, rather than trusting a race this handler has no way to observe.
|
||||
*/
|
||||
await createCommandHqSession(user.id, outcome.transcriptId, body.categoryId ?? null);
|
||||
await setCommandHqSessionCategory(user.id, outcome.transcriptId, body.categoryId ?? null);
|
||||
await setCommanderSessionId(user.id, row.id, outcome.transcriptId);
|
||||
|
||||
// The cwd it ACTUALLY ran in, from the spawn outcome — not a guess at the old shared directory, which is
|
||||
// no longer where a commander necessarily lives.
|
||||
return ctx.json({ sessionId: outcome.transcriptId, cwd: body.cwd?.trim() ?? null });
|
||||
return ctx.json({ sessionId: outcome.transcriptId, cwd: dir, id: row.id, dirName: row.dirName });
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -402,10 +429,11 @@ commandHqRouter.get('/commanders/:sessionId', async (ctx) => {
|
||||
return ctx.json({
|
||||
sessionId: row.sessionId,
|
||||
name: row.name,
|
||||
referencePaths: row.referencePaths,
|
||||
categoryId: row.categoryId,
|
||||
tagIds: tagsByRow.get(row.id) ?? [],
|
||||
running,
|
||||
watcherArmed: row.watcherArmed || liveLoopPid(who.email, row.sessionId) !== null,
|
||||
watcherArmed: row.watcherArmed || liveLoopPid(who.email, row.dirName) !== null,
|
||||
watcherArmedAt: row.watcherArmedAt,
|
||||
watcherStoppedAt: row.watcherStoppedAt,
|
||||
watcherLastFiredAt: lastFiredByRow.get(row.id) ?? null,
|
||||
@@ -446,36 +474,47 @@ commandHqRouter.get('/commanders/:sessionId/fires', async (ctx) => {
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /command-hq/commanders { sessionId: string, categoryId?: number }
|
||||
* POST /command-hq/commanders { name: string, referencePath: string, categoryId?: number | null }
|
||||
*
|
||||
* Files an EXISTING session as a commander. Idempotent — filing an already-filed session is a no-op, and
|
||||
* does not move it to a different category (use PATCH for that).
|
||||
* Creates a commander. **This is the creation path now, and it changed shape 2026-08-24** — it used to file
|
||||
* an EXISTING session (`{ sessionId, categoryId? }`), which is the wrong order per
|
||||
* `docs/commander-directory.md`: a chat session existed first and was filed as a commander second, so the
|
||||
* working directory was fixed before any commander id existed and a commander could never start in a
|
||||
* directory named after itself. There is no live caller of the old shape to keep compatible with — see the
|
||||
* design doc's "Order of work".
|
||||
*
|
||||
* This is a low-level primitive, not the normal creation path. The normal path is starting a new commander
|
||||
* through Command HQ's own composer, which writes this row itself the moment the harness reports a real
|
||||
* transcript id — see `docs/command-hq.md` → "How a commander is created". This route exists for that
|
||||
* wiring to call, and for anything else (a purge-and-refile flow, a script) that needs to file a session
|
||||
* without going through a live chat turn.
|
||||
*
|
||||
* The session must actually exist in the commanders directory, or this refuses — filing a `/chat` session
|
||||
* makes no sense given commanders and `/chat` never share a working directory.
|
||||
* Creates, in order: the row (`createCommander`, no transcript — `sessionId` stays null until a turn
|
||||
* reports one), THEN the directory and its `CLAUDE.md` (`createCommanderDirectory`) — the row has to exist
|
||||
* first because `dirName` is derived from its own id. Returns the row, including `cwd`: the caller (the
|
||||
* web composer, `CommandHqDetail.tsx`) starts its chat turn with `cwd` set to exactly this path, and
|
||||
* threads `commanderId: row.id` on that turn so `fileAsCommander` (`websocket.ts`) can point this same row
|
||||
* at the transcript id the moment one exists — the same server-driven, don't-depend-on-a-live-browser
|
||||
* pattern this router already used for the commander's name, now doing the same job for its `sessionId`.
|
||||
* No separate `PATCH … { sessionId }` route exists for this: it would only reintroduce the thing this
|
||||
* design removes — a client that has to remember to follow through, on a value the server already knows
|
||||
* first.
|
||||
*/
|
||||
commandHqRouter.post('/commanders', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const who = await commandHqIdentity(user);
|
||||
const body = await ctx.req
|
||||
.json<{ sessionId?: string; categoryId?: number }>()
|
||||
.catch(() => ({}) as { sessionId?: string; categoryId?: number });
|
||||
const sessionId = body.sessionId?.trim();
|
||||
if (!sessionId) throw errors.BAD_REQUEST('sessionId is required');
|
||||
.json<{ name?: string; referencePath?: string; categoryId?: number | null }>()
|
||||
.catch(() => ({}) as { name?: string; referencePath?: string; categoryId?: number | null });
|
||||
const name = body.name?.trim();
|
||||
const referencePath = body.referencePath?.trim();
|
||||
if (!name) throw errors.BAD_REQUEST('name is required');
|
||||
if (!referencePath) throw errors.BAD_REQUEST('referencePath is required');
|
||||
|
||||
// Located by id wherever it lives, not required to be in the old shared directory. A commander chooses
|
||||
// its own working directory, so "is it in the commanders folder" stopped being a meaningful check — and
|
||||
// as a GUARD it only ever established that the transcript exists, which this still does.
|
||||
if (!loadClaudeSessionSummaryById(who, sessionId)) throw errors.NOT_FOUND('no such conversation');
|
||||
const row = await createCommander({ userId: user.id, name, referencePath, categoryId: body.categoryId ?? null });
|
||||
const { dir, ownershipError } = await createCommanderDirectory(who.email, row.dirName, { name, referencePath });
|
||||
if (ownershipError) {
|
||||
console.warn(`[command-hq] ${row.dirName}: directory not handed to the account — ${ownershipError}`);
|
||||
}
|
||||
|
||||
const row = await createCommandHqSession(user.id, sessionId, body.categoryId ?? null);
|
||||
return ctx.json(row);
|
||||
// `ownershipError` is returned as well as logged. A member whose commander directory could not be handed
|
||||
// over gets an agent that dies on its first command, and "the platform knew and only wrote it to a log"
|
||||
// is the worst version of that.
|
||||
return ctx.json({ ...row, cwd: dir, ...(ownershipError ? { ownershipError } : {}) });
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -592,13 +631,13 @@ commandHqRouter.delete('/commanders/:sessionId', async (ctx) => {
|
||||
is exactly how a deleted commander came back as an untitled ordinary chat.
|
||||
*/
|
||||
const sessionKilled = sessionKey ? await sidecar.killClaudeAndWait(sessionKey, user.id) : false;
|
||||
const stopped = stopCommanderProcesses(who.email, sessionId);
|
||||
const stopped = stopCommanderProcesses(who.email, row.dirName);
|
||||
|
||||
// 2. Everything it spawned. Best-effort per child: one transcript that has already been cleaned up must
|
||||
// not stop the rest from going, and a child is often gone before its parent.
|
||||
// Ingest the inbox before reading it, so a child appended since the last read is still deleted. The file
|
||||
// is lossy by design and the rows are not — but a row that has never been ingested does not exist yet.
|
||||
const children = (await ingestSpawnedChildren(row.id, listSpawnedSessions(who.email, sessionId))).map(
|
||||
const children = (await ingestSpawnedChildren(row.id, listSpawnedSessions(who.email, row.dirName))).map(
|
||||
(c) => c.spawnedSessionId,
|
||||
);
|
||||
let childrenDeleted = 0;
|
||||
@@ -623,7 +662,17 @@ commandHqRouter.delete('/commanders/:sessionId', async (ctx) => {
|
||||
// Already gone, or never written. Counted as not-deleted rather than failing the request.
|
||||
}
|
||||
}
|
||||
clearSpawnedRecord(who.email, sessionId);
|
||||
/*
|
||||
The commander's OWN directory — `CLAUDE.md`, `state/`, and anything else it wrote for itself. Not just
|
||||
`state/` (which `clearSpawnedRecord` alone would remove): per `docs/commander-directory.md`, deleting a
|
||||
commander is deleting its directory, whole, which is also the only way to be sure nothing is left
|
||||
behind. Best-effort, same posture as the children above — a directory already gone is not a failure.
|
||||
*/
|
||||
try {
|
||||
rmSync(getCommanderDir(who.email, row.dirName), { recursive: true, force: true });
|
||||
} catch {
|
||||
// Already gone, or never fully created. The row is still deleted below regardless.
|
||||
}
|
||||
|
||||
// 3. The commander itself.
|
||||
forgotten.push(...deleteClaudeSession(who, who.home, sessionId));
|
||||
@@ -675,10 +724,16 @@ commandHqRouter.post('/purge', async (ctx) => {
|
||||
|
||||
Scanning one directory would have deleted every commander that chose a different one — a housekeeping
|
||||
route quietly unfiling live commanders is the worst possible version of this bug, because the rows are
|
||||
what identity depends on now.
|
||||
what identity depends on now. A row with no transcript yet (`sessionId: null`, mid-creation) is never a
|
||||
purge candidate at all — see `purgeCommandHqSessions`'s own guard — so it needs no entry in this set.
|
||||
*/
|
||||
const rows = await listCommandHqSessions(user.id);
|
||||
const stillThere = new Set(rows.map((row) => row.sessionId).filter((id) => !!loadClaudeSessionSummaryById(who, id)));
|
||||
const stillThere = new Set(
|
||||
rows
|
||||
.map((row) => row.sessionId)
|
||||
.filter((id): id is string => id !== null)
|
||||
.filter((id) => !!loadClaudeSessionSummaryById(who, id)),
|
||||
);
|
||||
const removed = await purgeCommandHqSessions(user.id, stillThere);
|
||||
return ctx.json({ removed });
|
||||
});
|
||||
@@ -716,7 +771,7 @@ commandHqRouter.get('/commanders/:sessionId/children', async (ctx) => {
|
||||
const row = await getCommandHqSession(user.id, sessionId);
|
||||
if (!row) throw errors.NOT_FOUND('not a commander');
|
||||
|
||||
const children = await ingestSpawnedChildren(row.id, listSpawnedSessions(who.email, sessionId));
|
||||
const children = await ingestSpawnedChildren(row.id, listSpawnedSessions(who.email, row.dirName));
|
||||
// `unknownCost` said out loud rather than summed as zero: a commander whose children's costs are unknown
|
||||
// must not display a confident total that happens to be too low. That understatement is the whole reason
|
||||
// this exists.
|
||||
@@ -731,23 +786,25 @@ commandHqRouter.get('/commanders/:sessionId/children', async (ctx) => {
|
||||
/**
|
||||
* GET /command-hq/commanders/:sessionId/record-paths
|
||||
*
|
||||
* Where this commander's loop must write its pid and the session ids it spawns.
|
||||
* Where this commander's loop must write its pid and the session ids it spawns — the same two paths
|
||||
* `CLAUDE.md` already states at creation (`docs/commander-directory.md`). This route still exists for
|
||||
* `BackgroundJobDialog`, which needs them again at ARM time, well after creation, to build the watch loop's
|
||||
* own prompt (`background-job-prompt.ts`) — a live turn's `CLAUDE.md` is not something this route re-reads.
|
||||
*
|
||||
* The composer needs these to build the background-job prompt, and cannot derive them: they are rooted in
|
||||
* `DATA_PATH`, which is a server fact. Returning paths rather than accepting an upload is deliberate — the
|
||||
* loop writes them itself with a shell redirect, which needs no token and works from inside a background
|
||||
* job, where an authenticated POST would need a credential the loop has no business holding.
|
||||
*
|
||||
* Not gated on the commander existing as a ROW: a brand-new commander asks for these before its first turn
|
||||
* has filed it, and refusing then would mean the very first loop is the one that cannot record itself.
|
||||
* Rooted in `state/` inside this commander's own directory, keyed by `dirName` — which means it now needs
|
||||
* the ROW (to learn `dirName`), unlike before, when the path was a pure function of the transcript id and
|
||||
* nothing else. A commander always has a row by the time any turn exists, so this is not a new restriction
|
||||
* in practice — it only makes explicit what used to be implicit.
|
||||
*/
|
||||
commandHqRouter.get('/commanders/:sessionId/record-paths', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const who = await commandHqIdentity(user);
|
||||
const sessionId = ctx.req.param('sessionId');
|
||||
const row = await getCommandHqSession(user.id, sessionId);
|
||||
if (!row) throw errors.NOT_FOUND('not a commander');
|
||||
return ctx.json({
|
||||
loopPidPath: loopPidPath(who.email, sessionId),
|
||||
spawnedPath: spawnedRecordPath(who.email, sessionId),
|
||||
loopPidPath: loopPidPath(who.email, row.dirName),
|
||||
spawnedPath: spawnedRecordPath(who.email, row.dirName),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -22,11 +22,12 @@ import { descendantPids } from '../tasks/process-tree';
|
||||
|
||||
So the loop records it, and the platform reads it. One newline-delimited file per commander:
|
||||
|
||||
<DATA_PATH>/<email>/commander-state/<commander session id>/spawned
|
||||
<DATA_PATH>/<email>/commanders/<dirName>/state/spawned
|
||||
|
||||
The loop can only do that if it knows which commander it belongs to, which it does not by default — the
|
||||
id is injected into the background-job prompt by `background-job-prompt.ts`. That is the whole chain, and
|
||||
every link is required.
|
||||
path is written into `CLAUDE.md` at creation (`docs/commander-directory.md`) and also carried into the
|
||||
background-job prompt by `background-job-prompt.ts` for the dialog-driven case. That is the whole chain,
|
||||
and every link is required.
|
||||
*/
|
||||
|
||||
const FILE = 'spawned';
|
||||
@@ -36,8 +37,8 @@ const PID_FILE = 'loop-pid';
|
||||
const SESSION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
/** Where the loop should append. Handed to the agent in its prompt; nothing guesses it. */
|
||||
export function spawnedRecordPath(email: string, commanderSessionId: string): string {
|
||||
return join(getCommanderStateDir(email, commanderSessionId), FILE);
|
||||
export function spawnedRecordPath(email: string, dirName: string): string {
|
||||
return join(getCommanderStateDir(email, dirName), FILE);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -47,8 +48,8 @@ export function spawnedRecordPath(email: string, commanderSessionId: string): st
|
||||
* truncated write, a blank line or a stray log line must not break a delete — they are dropped and the rest
|
||||
* is used. A record that fails closed here would make a commander undeletable because of a bad line.
|
||||
*/
|
||||
export function listSpawnedSessions(email: string, commanderSessionId: string): string[] {
|
||||
const path = spawnedRecordPath(email, commanderSessionId);
|
||||
export function listSpawnedSessions(email: string, dirName: string): string[] {
|
||||
const path = spawnedRecordPath(email, dirName);
|
||||
if (!existsSync(path)) return [];
|
||||
try {
|
||||
const seen = new Set<string>();
|
||||
@@ -68,18 +69,26 @@ export function listSpawnedSessions(email: string, commanderSessionId: string):
|
||||
* Kept because the platform spawning an agent on a commander's behalf is the obvious next shape, and when
|
||||
* that arrives it must write the same file rather than a second record.
|
||||
*/
|
||||
export function recordSpawnedSession(email: string, commanderSessionId: string, spawnedId: string): void {
|
||||
export function recordSpawnedSession(email: string, dirName: string, spawnedId: string): void {
|
||||
if (!SESSION_ID.test(spawnedId)) return;
|
||||
if (listSpawnedSessions(email, commanderSessionId).includes(spawnedId.toLowerCase())) return;
|
||||
const dir = getCommanderStateDir(email, commanderSessionId);
|
||||
if (listSpawnedSessions(email, dirName).includes(spawnedId.toLowerCase())) return;
|
||||
const dir = getCommanderStateDir(email, dirName);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
appendFileSync(join(dir, FILE), `${spawnedId.toLowerCase()}\n`, { mode: 0o600 });
|
||||
}
|
||||
|
||||
/** Drop the whole record. Called when the commander is deleted, after its children have been dealt with. */
|
||||
export function clearSpawnedRecord(email: string, commanderSessionId: string): void {
|
||||
/**
|
||||
* Drop `state/` alone, leaving `CLAUDE.md` and anything else the commander wrote in place.
|
||||
*
|
||||
* Not called from the delete route any more — deleting a commander now removes its WHOLE directory
|
||||
* (`docs/commander-directory.md`: `state/` is one part of it, not the only thing that needs to go), which
|
||||
* subsumes this. Kept as the narrower primitive for the shape of operation it names: clearing what a
|
||||
* commander has HANDLED without deleting the commander itself, which nothing calls today but is exactly
|
||||
* the kind of reset a commander asked to "start over" might need.
|
||||
*/
|
||||
export function clearSpawnedRecord(email: string, dirName: string): void {
|
||||
try {
|
||||
rmSync(getCommanderStateDir(email, commanderSessionId), { recursive: true, force: true });
|
||||
rmSync(getCommanderStateDir(email, dirName), { recursive: true, force: true });
|
||||
} catch {
|
||||
// A record we cannot remove is untidy, not incorrect: the commander is gone and nothing reads this
|
||||
// without a commander to read it for.
|
||||
@@ -103,13 +112,13 @@ export function clearSpawnedRecord(email: string, commanderSessionId: string): v
|
||||
*/
|
||||
|
||||
/** Where the loop should write its own pid. Handed to it in its prompt, like the spawned record. */
|
||||
export function loopPidPath(email: string, commanderSessionId: string): string {
|
||||
return join(getCommanderStateDir(email, commanderSessionId), PID_FILE);
|
||||
export function loopPidPath(email: string, dirName: string): string {
|
||||
return join(getCommanderStateDir(email, dirName), PID_FILE);
|
||||
}
|
||||
|
||||
/** The loop's pid, if it recorded one and the process is still alive. */
|
||||
export function liveLoopPid(email: string, commanderSessionId: string): number | null {
|
||||
const path = loopPidPath(email, commanderSessionId);
|
||||
export function liveLoopPid(email: string, dirName: string): number | null {
|
||||
const path = loopPidPath(email, dirName);
|
||||
if (!existsSync(path)) return null;
|
||||
const pid = Number(readFileSync(path, 'utf-8').trim());
|
||||
if (!Number.isInteger(pid) || pid <= 1) return null;
|
||||
@@ -128,8 +137,8 @@ export function liveLoopPid(email: string, commanderSessionId: string): number |
|
||||
*
|
||||
* Children first, then the loop, so the loop cannot spawn a replacement between the two.
|
||||
*/
|
||||
export function stopCommanderProcesses(email: string, commanderSessionId: string): number {
|
||||
const pid = liveLoopPid(email, commanderSessionId);
|
||||
export function stopCommanderProcesses(email: string, dirName: string): number {
|
||||
const pid = liveLoopPid(email, dirName);
|
||||
if (!pid) return 0;
|
||||
const tree = [...descendantPids(pid), pid];
|
||||
let signalled = 0;
|
||||
|
||||
@@ -284,12 +284,40 @@ router.post('/upload', async (ctx) => {
|
||||
const raw = body['file'];
|
||||
const files = Array.isArray(raw) ? raw : raw ? [raw] : [];
|
||||
|
||||
/*
|
||||
WRITE ASIDE, THEN RENAME. Never straight to the final path.
|
||||
|
||||
`Bun.write(filePath, file)` streams to the name the file will end up with, so for as long as the
|
||||
upload takes, that name exists holding a partial file — and anything watching the folder cannot
|
||||
tell. The failure is silent rather than loud: a truncated m4a decodes fine, it is simply short. A
|
||||
watcher transcribing voice memos as they land would produce a clean transcript of the first few
|
||||
seconds of a two-minute recording and report success.
|
||||
|
||||
A rename within one directory is atomic, so afterwards the final name either does not exist or is
|
||||
a complete file. Every consumer gets that for free — the phone's own `uploadVerified`, a watcher,
|
||||
an agent reading the folder — instead of each inventing a heuristic like "the size stopped
|
||||
changing", which cannot tell a finished upload from a stalled one.
|
||||
|
||||
`.part` rather than a dotfile, deliberately: a leftover should be VISIBLE, named for what it was
|
||||
going to be, next to where it belongs. Nothing sweeps them up — a job deleting files it did not
|
||||
create is a worse trade than a fragment somebody can see.
|
||||
*/
|
||||
for (const file of files) {
|
||||
if (!(file instanceof File)) continue;
|
||||
const filePath = resolve(targetDir, file.name);
|
||||
if (!isInside(rootDir, filePath)) continue;
|
||||
await mkdir(dirname(filePath), { recursive: true });
|
||||
await Bun.write(filePath, file);
|
||||
|
||||
const partPath = `${filePath}.part`;
|
||||
try {
|
||||
await Bun.write(partPath, file);
|
||||
await rename(partPath, filePath);
|
||||
} catch (err) {
|
||||
// Leave nothing half-named. Only an interrupted PROCESS leaves a fragment behind, which is
|
||||
// the case worth being able to see.
|
||||
await rm(partPath, { force: true }).catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
return ctx.json({ ok: true });
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import { createRouter } from '../../create-router';
|
||||
import { sessionStatus, startSession, stopSession } from './session';
|
||||
|
||||
/**
|
||||
* Session control. The pixels do not come through here — they come through the websocket relay; this
|
||||
* is only "is it up, bring it up, take it down", plus the window size the panel needs to size its
|
||||
* canvas.
|
||||
*/
|
||||
export const minesweeperRouter = createRouter();
|
||||
|
||||
minesweeperRouter.get('/status', async (c) => c.json(await sessionStatus()));
|
||||
|
||||
minesweeperRouter.post('/start', async (c) => {
|
||||
const status = await sessionStatus();
|
||||
if (!status.installed) {
|
||||
return c.json({ error: 'Minesweeper is not installed on this server (wine-minesweeper is missing)' }, 501);
|
||||
}
|
||||
if (status.running) return c.json(status);
|
||||
|
||||
const result = await startSession();
|
||||
if (!result.ok) return c.json({ error: `Could not start the session: ${result.out.trim()}` }, 500);
|
||||
return c.json(await sessionStatus());
|
||||
});
|
||||
|
||||
minesweeperRouter.post('/stop', async (c) => {
|
||||
await stopSession();
|
||||
return c.json(await sessionStatus());
|
||||
});
|
||||
@@ -1,111 +0,0 @@
|
||||
import { join } from 'node:path';
|
||||
import { OFFICER_ROOT } from '../../data-path';
|
||||
|
||||
/**
|
||||
* The Minesweeper X session: a virtual display with one Windows game on it, and x11vnc serving that
|
||||
* display on loopback.
|
||||
*
|
||||
* The platform does not implement any of that — it drives `bin/session.sh`, which is the same script
|
||||
* used by hand. One implementation, so a session started from the browser and one started from a
|
||||
* terminal are the same session with the same lifecycle.
|
||||
*/
|
||||
|
||||
export const MINESWEEPER_DIR = join(OFFICER_ROOT, 'wine-minesweeper');
|
||||
const SESSION_SCRIPT = join(MINESWEEPER_DIR, 'bin', 'session.sh');
|
||||
|
||||
export const VNC_HOST = '127.0.0.1';
|
||||
export const VNC_PORT = Number(process.env.MINESWEEPER_VNC_PORT ?? 5907);
|
||||
const DISPLAY = process.env.MINESWEEPER_DISPLAY ?? ':7';
|
||||
|
||||
export type WindowGeometry = { width: number; height: number; x: number; y: number };
|
||||
export type SessionStatus = {
|
||||
installed: boolean;
|
||||
running: boolean;
|
||||
/** Absent until the game has drawn its window. */
|
||||
window: WindowGeometry | null;
|
||||
};
|
||||
|
||||
const run = async (cmd: string[], env: Record<string, string> = {}): Promise<{ ok: boolean; out: string }> => {
|
||||
const proc = Bun.spawn(cmd, {
|
||||
cwd: MINESWEEPER_DIR,
|
||||
env: { ...process.env, ...env },
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
});
|
||||
const [out, err] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
|
||||
const code = await proc.exited;
|
||||
return { ok: code === 0, out: `${out}${err}` };
|
||||
};
|
||||
|
||||
export const isInstalled = (): boolean => Bun.file(SESSION_SCRIPT).size > 0;
|
||||
|
||||
/**
|
||||
* Whether x11vnc is listening, decided by reading the kernel's socket table rather than by connecting.
|
||||
*
|
||||
* This used to open a TCP connection and drop it. That wedged the VNC server: libvncserver peeks at a
|
||||
* new connection to decide whether it is a WebSocket handshake, and that peek blocks its main loop
|
||||
* until the client speaks or the peek times out. A health check that connects and says nothing is
|
||||
* therefore a small stall, and the panel polls status on a timer — so the stalls overlapped, the accept
|
||||
* backlog filled (Recv-Q 32/32), and the server stopped accepting the real viewer too.
|
||||
*
|
||||
* The rule it cost to learn: a liveness check must not be indistinguishable from a client. Reading
|
||||
* /proc/net/tcp is free, cannot block, and cannot be mistaken for a viewer.
|
||||
*/
|
||||
async function isServing(): Promise<boolean> {
|
||||
const wanted = VNC_PORT.toString(16).toUpperCase().padStart(4, '0');
|
||||
const LISTEN = '0A';
|
||||
|
||||
for (const table of ['/proc/net/tcp', '/proc/net/tcp6']) {
|
||||
const text = await Bun.file(table)
|
||||
.text()
|
||||
.catch(() => '');
|
||||
|
||||
for (const line of text.split('\n').slice(1)) {
|
||||
const columns = line.trim().split(/\s+/);
|
||||
const local = columns[1];
|
||||
const state = columns[3];
|
||||
if (!local || state !== LISTEN) continue;
|
||||
if (local.split(':')[1] === wanted) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* The game window's size, so the browser can size its canvas to the board rather than to the whole
|
||||
* framebuffer. `session.sh` parks the window at 0,0, so the offset is expected to be zero and is
|
||||
* reported anyway — a non-zero one means something moved it and the canvas would be misaligned.
|
||||
*
|
||||
* The board changes size when the difficulty changes, which is why this is polled rather than read
|
||||
* once at connect.
|
||||
*/
|
||||
export async function windowGeometry(): Promise<WindowGeometry | null> {
|
||||
const search = await run(['xdotool', 'search', '--name', 'Minesweeper X'], { DISPLAY });
|
||||
if (!search.ok) return null;
|
||||
|
||||
let best: WindowGeometry | null = null;
|
||||
for (const id of search.out.trim().split('\n').filter(Boolean)) {
|
||||
const geom = await run(['xdotool', 'getwindowgeometry', '--shell', id], { DISPLAY });
|
||||
if (!geom.ok) continue;
|
||||
|
||||
const read = (key: string): number => Number(new RegExp(`^${key}=(\\d+)$`, 'm').exec(geom.out)?.[1] ?? NaN);
|
||||
const candidate = { width: read('WIDTH'), height: read('HEIGHT'), x: read('X'), y: read('Y') };
|
||||
if (Object.values(candidate).some(Number.isNaN)) continue;
|
||||
|
||||
// wine creates a 1x1 owner stub alongside the real window; the larger one is the game.
|
||||
if (!best || candidate.width * candidate.height > best.width * best.height) best = candidate;
|
||||
}
|
||||
return best && best.width > 1 ? best : null;
|
||||
}
|
||||
|
||||
export async function sessionStatus(): Promise<SessionStatus> {
|
||||
const installed = isInstalled();
|
||||
if (!installed) return { installed, running: false, window: null };
|
||||
|
||||
const running = await isServing();
|
||||
return { installed, running, window: running ? await windowGeometry() : null };
|
||||
}
|
||||
|
||||
export const startSession = (): Promise<{ ok: boolean; out: string }> => run([SESSION_SCRIPT, 'start']);
|
||||
export const stopSession = (): Promise<{ ok: boolean; out: string }> => run([SESSION_SCRIPT, 'stop']);
|
||||
@@ -1,95 +0,0 @@
|
||||
import type { ServerWebSocket, Socket } from 'bun';
|
||||
import { VNC_HOST, VNC_PORT } from './session';
|
||||
|
||||
/**
|
||||
* A byte relay between the browser and x11vnc, in the same spirit as the terminal's relay onto the pty
|
||||
* sidecar — this file authenticates the upgrade (upgradeWs, in server.tsx) and moves bytes without
|
||||
* reading them.
|
||||
*
|
||||
* One difference decides the shape: x11vnc serves raw TCP, not a WebSocket. So this is the job
|
||||
* websockify would otherwise do, done in-process with `Bun.connect` rather than by adding a second
|
||||
* daemon and a second port to protect. RFB is a stream protocol, so frame boundaries carry no meaning
|
||||
* and chunks are forwarded exactly as they arrive.
|
||||
*/
|
||||
|
||||
export type MinesweeperWSData = { provider: 'minesweeper'; search: string };
|
||||
|
||||
type Relay = { socket: Socket | null; queue: Uint8Array[]; done: boolean };
|
||||
|
||||
const relays = new Map<ServerWebSocket<MinesweeperWSData>, Relay>();
|
||||
|
||||
const closeClient = (ws: ServerWebSocket<MinesweeperWSData>, code: number, reason: string) => {
|
||||
const relay = relays.get(ws);
|
||||
relays.delete(ws);
|
||||
if (relay) relay.done = true;
|
||||
try {
|
||||
relay?.socket?.end();
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
try {
|
||||
ws.close(code, reason);
|
||||
} catch {
|
||||
/* already closed */
|
||||
}
|
||||
};
|
||||
|
||||
export const minesweeperWebsocket = {
|
||||
open(ws: ServerWebSocket<MinesweeperWSData>) {
|
||||
// Registered synchronously, so bytes arriving before the TCP connection is up are queued rather
|
||||
// than dropped. The RFB handshake starts with the SERVER talking, but a client that pipelines its
|
||||
// version string would otherwise lose it.
|
||||
const relay: Relay = { socket: null, queue: [], done: false };
|
||||
relays.set(ws, relay);
|
||||
|
||||
void Bun.connect({
|
||||
hostname: VNC_HOST,
|
||||
port: VNC_PORT,
|
||||
socket: {
|
||||
open(socket) {
|
||||
if (relay.done) {
|
||||
socket.end();
|
||||
return;
|
||||
}
|
||||
relay.socket = socket;
|
||||
for (const chunk of relay.queue) socket.write(chunk);
|
||||
relay.queue.length = 0;
|
||||
},
|
||||
data(_socket, chunk) {
|
||||
try {
|
||||
ws.send(chunk);
|
||||
} catch {
|
||||
/* client gone; the close handler tears down the socket */
|
||||
}
|
||||
},
|
||||
close: () => closeClient(ws, 1000, 'vnc closed'),
|
||||
error: () => closeClient(ws, 1011, 'vnc error'),
|
||||
},
|
||||
}).catch(() => {
|
||||
// No x11vnc listening — the session is not running. 1011 rather than a silent close, so the
|
||||
// panel can say so instead of showing an empty canvas.
|
||||
closeClient(ws, 1011, 'minesweeper session is not running');
|
||||
});
|
||||
},
|
||||
|
||||
message(ws: ServerWebSocket<MinesweeperWSData>, raw: string | Buffer) {
|
||||
const relay = relays.get(ws);
|
||||
if (!relay) return;
|
||||
|
||||
// RFB is binary in both directions; a text frame is a client that is not speaking it.
|
||||
const bytes = typeof raw === 'string' ? new TextEncoder().encode(raw) : new Uint8Array(raw);
|
||||
if (relay.socket) relay.socket.write(bytes);
|
||||
else relay.queue.push(bytes);
|
||||
},
|
||||
|
||||
close(ws: ServerWebSocket<MinesweeperWSData>) {
|
||||
const relay = relays.get(ws);
|
||||
relays.delete(ws);
|
||||
if (relay) relay.done = true;
|
||||
try {
|
||||
relay?.socket?.end();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import { sidecarPort } from '@@/sidecar-registry';
|
||||
import { snapshotPlugins } from '@@/plugins/mount';
|
||||
|
||||
/*
|
||||
Where a plugin's socket actually goes.
|
||||
|
||||
A target is an ADDRESS — a host and a port — never a URL a manifest wrote. The relay turns it into
|
||||
`ws://<host>:<port>/ws`, and `/ws` is a convention rather than a setting for the same reason the host is
|
||||
not configurable: every value here has to be one the platform derived, not one a plugin asserted.
|
||||
|
||||
The target is always a SIDECAR. A fixed-port form was written and removed the same day: nothing stops a
|
||||
manifest naming a port it does not own, so `{ port: 5432 }` plus a grant would be a raw TCP relay onto
|
||||
Postgres for anyone that plugin reaches. Verifying the host is loopback is not verifying the port belongs
|
||||
to the plugin, and only the second question matters.
|
||||
|
||||
A sidecar's port is different in kind, not degree: the platform watched it arrive at registration, so the
|
||||
association is observed rather than asserted. It is also the SAME port the sidecar serves its HTTP on —
|
||||
one announcement, one listener, `/ws` alongside the routes. That is what the relay's move to
|
||||
websocket-to-websocket bought, and why a plugin wanting a socket no longer has to choose between having
|
||||
one and having ordinary routes.
|
||||
|
||||
**Host is always loopback and is not configurable.** A plugin socket that could name an arbitrary host
|
||||
would be a request-forgery primitive handed to every plugin author and reachable by anyone the plugin is
|
||||
granted to. There is no case for it that a sidecar does not already serve.
|
||||
*/
|
||||
|
||||
export type PluginWsTarget = { host: string; port: number };
|
||||
|
||||
export async function pluginWsTargetFor(appName: string): Promise<PluginWsTarget | null> {
|
||||
const { states } = await snapshotPlugins();
|
||||
// Installed, not merely present on disk. A plugin directory that exists but is not installed has no
|
||||
// routes and no permission, so it must have no socket either.
|
||||
const state = states.find((st) => st.plugin.appName === appName && st.install);
|
||||
const socket = state?.plugin.manifest.websocket;
|
||||
if (!socket) return null;
|
||||
|
||||
// A sidecar that has not announced its port yet is indistinguishable from one that never will, and both
|
||||
// are correctly a refusal — there is nothing to relay onto. The caller says "not accepting connections",
|
||||
// which is true either way.
|
||||
const port = sidecarPort(socket.sidecar);
|
||||
return port ? { host: '127.0.0.1', port } : null;
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import type { ServerWebSocket } from 'bun';
|
||||
import { resolveAuthToken } from '@@/auth-token';
|
||||
import { isTokenBlacklisted } from 'officerdb';
|
||||
import { isApiRequestAllowed } from '@@/permissions/authorize';
|
||||
import { pluginWsTargetFor } from './target';
|
||||
|
||||
/*
|
||||
One websocket door for every plugin, so a plugin can have a socket at all.
|
||||
|
||||
── Why this exists ──
|
||||
|
||||
Bun's route table is fixed at `serve()`, and `WS_ROUTES` is a static object read at boot. So a plugin
|
||||
installed afterwards had no way to add a socket route, and "a websocket provider is a platform concern"
|
||||
became a hard ceiling rather than a preference: `/minesweeper` had to live in the platform and
|
||||
`plugins/desktop` could not do the same thing at all.
|
||||
|
||||
The way out was already in the tree. `dev-proxy` serves a socket without a `WS_ROUTES` entry, through a
|
||||
WILDCARD route whose handler calls `server.upgrade` itself. Nothing about the route table needs to become
|
||||
dynamic — one wildcard declared at boot can serve every plugin that will ever be installed, because the
|
||||
plugin name is a path segment rather than a route.
|
||||
|
||||
── The upstream is a WEBSOCKET, not a raw TCP socket ──
|
||||
|
||||
This relayed onto a raw TCP address for about a day. It was changed while extracting Minesweeper, and the
|
||||
reason is worth keeping because it is not a preference.
|
||||
|
||||
**A sidecar announces exactly one port.** That is the whole provenance argument for `target.ts` — the
|
||||
platform watched that port arrive at registration, which is the only thing separating it from a number a
|
||||
manifest asserted. But if this relay needs raw TCP, that one port has to BE a raw TCP listener, so the
|
||||
sidecar cannot also serve HTTP on it, and the plugin's ordinary routes have to fall back to the typed
|
||||
command vocabulary over the registration socket — the mechanism the fleet has spent months retiring.
|
||||
|
||||
Relaying websocket-to-websocket dissolves that. The sidecar serves HTTP and `/ws` on the single port it
|
||||
announced, exactly as the pty sidecar already does; `createSidecarProxy` handles the HTTP half; and the
|
||||
conversion from frames to whatever the plugin actually speaks happens INSIDE the plugin, which is the only
|
||||
process that knows what that is. Minesweeper's x11vnc is the worked example: the sidecar owns the RFB
|
||||
stream and the platform never learns that RFB exists.
|
||||
|
||||
This file is still ignorant of the payload — it moves frames without reading them, and a stream protocol's
|
||||
boundaries carry no meaning either way. The ignorance simply sits one layer up.
|
||||
*/
|
||||
|
||||
export type PluginWSData = {
|
||||
provider: 'plugin-ws';
|
||||
plugin: string;
|
||||
/** `ws://127.0.0.1:<announced port>/ws`, with the client's query minus its credential. */
|
||||
upstream: string;
|
||||
userId: number;
|
||||
};
|
||||
|
||||
type Frame = string | Uint8Array<ArrayBuffer>;
|
||||
type Relay = { ws: WebSocket | null; queue: Frame[]; done: boolean };
|
||||
const relays = new Map<ServerWebSocket<PluginWSData>, Relay>();
|
||||
|
||||
const closeClient = (ws: ServerWebSocket<PluginWSData>, code: number, reason: string) => {
|
||||
const relay = relays.get(ws);
|
||||
relays.delete(ws);
|
||||
if (relay) relay.done = true;
|
||||
try {
|
||||
relay?.ws?.close();
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
try {
|
||||
ws.close(code, reason);
|
||||
} catch {
|
||||
/* already closing */
|
||||
}
|
||||
};
|
||||
|
||||
export const pluginWsWebsocket = {
|
||||
open(ws: ServerWebSocket<PluginWSData>) {
|
||||
/*
|
||||
The queue is not an optimisation. Bun requires `server.upgrade` to be synchronous, so the client is
|
||||
already connected and may already be talking before the upstream is open — and an RFB client sends its
|
||||
protocol version immediately. Dropping those frames desynchronises the stream in a way that looks like
|
||||
a corrupt display rather than a lost packet. Learned by dev-proxy first.
|
||||
*/
|
||||
const relay: Relay = { ws: null, queue: [], done: false };
|
||||
relays.set(ws, relay);
|
||||
|
||||
let upstream: WebSocket;
|
||||
try {
|
||||
// The sidecar binds loopback and is told who is calling, the same signal every HTTP sidecar trusts:
|
||||
// a request without this header did not come through the platform.
|
||||
upstream = new WebSocket(ws.data.upstream, { headers: { 'X-Officer-User': String(ws.data.userId) } } as never);
|
||||
} catch {
|
||||
closeClient(ws, 1011, `${ws.data.plugin} is not accepting connections`);
|
||||
return;
|
||||
}
|
||||
relay.ws = upstream;
|
||||
upstream.binaryType = 'arraybuffer';
|
||||
|
||||
upstream.onopen = () => {
|
||||
// Between the upgrade and here the client may have gone. Sending into a socket nobody reads would hold
|
||||
// the upstream connection open for as long as the plugin's process lives.
|
||||
if (relay.done) {
|
||||
try {
|
||||
upstream.close();
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
return;
|
||||
}
|
||||
for (const frame of relay.queue) upstream.send(frame);
|
||||
relay.queue.length = 0;
|
||||
};
|
||||
|
||||
upstream.onmessage = (ev) => {
|
||||
try {
|
||||
ws.send(typeof ev.data === 'string' ? ev.data : new Uint8Array(ev.data as ArrayBuffer));
|
||||
} catch {
|
||||
/* client gone; the close handler tidies up */
|
||||
}
|
||||
};
|
||||
|
||||
// A plugin whose process is not listening is the ordinary case rather than an exception: a sidecar that
|
||||
// has not booted, or a service the owner stopped. The client is told, and told which plugin.
|
||||
upstream.onerror = () => closeClient(ws, 1011, `${ws.data.plugin} is not accepting connections`);
|
||||
upstream.onclose = () => closeClient(ws, 1011, 'upstream closed');
|
||||
},
|
||||
|
||||
message(ws: ServerWebSocket<PluginWSData>, message: string | Buffer) {
|
||||
const relay = relays.get(ws);
|
||||
if (!relay) return;
|
||||
const frame: Frame = typeof message === 'string' ? message : (message as unknown as Uint8Array<ArrayBuffer>);
|
||||
if (relay.ws && relay.ws.readyState === WebSocket.OPEN) relay.ws.send(frame);
|
||||
else relay.queue.push(frame);
|
||||
},
|
||||
|
||||
close(ws: ServerWebSocket<PluginWSData>) {
|
||||
const relay = relays.get(ws);
|
||||
relays.delete(ws);
|
||||
if (relay) relay.done = true;
|
||||
try {
|
||||
relay?.ws?.close();
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* `/api/plugin-ws/<plugin>` with an upgrade header.
|
||||
*
|
||||
* Authorisation is PER PLUGIN, which is why `plugin-ws` is exempt from the boot-time totality check rather
|
||||
* than carrying a registry entry of its own. One entry covering every plugin's socket would be a hole:
|
||||
* granting one plugin would grant them all.
|
||||
*
|
||||
* So the decision is delegated to the plugin's OWN capability, by asking the same question its HTTP routes
|
||||
* are asked — `isApiRequestAllowed(user, 'GET', '/api/<plugin>')`. That is deliberate and it is the whole
|
||||
* safety argument: there is no second permission to grant, to forget, or to drift out of step. Granting a
|
||||
* plugin grants its socket, and revoking it closes it.
|
||||
*
|
||||
* Async, like `upgradeWs`. `server.upgrade` must be called synchronously with respect to the request, but
|
||||
* Bun accepts a route handler that returns a promise — which is what lets the token resolve, the blacklist
|
||||
* check and the permission decision all happen before anything is upgraded.
|
||||
*/
|
||||
export async function upgradePluginWs(req: Request, server: any): Promise<Response | undefined> {
|
||||
const url = new URL(req.url);
|
||||
const match = url.pathname.match(/^\/api\/plugin-ws\/([a-z0-9-]{1,64})\/?$/);
|
||||
if (!match) return undefined;
|
||||
const plugin = match[1]!;
|
||||
|
||||
// A browser cannot set headers on a WebSocket upgrade, so the token arrives as a query parameter — the
|
||||
// concession every other socket here already makes. Verified through the same resolver as the HTTP doors,
|
||||
// so an API key that works against /api works here too.
|
||||
const token = url.searchParams.get('token');
|
||||
if (!token) return new Response('Unauthorized', { status: 401 });
|
||||
|
||||
try {
|
||||
const user = await resolveAuthToken(token);
|
||||
if (!user) return new Response('Unauthorized', { status: 401 });
|
||||
if (user.jti && (await isTokenBlacklisted(user.jti))) return new Response('Unauthorized', { status: 401 });
|
||||
|
||||
// The plugin's own gate, asked as if this were a GET against its HTTP prefix.
|
||||
if (!(await isApiRequestAllowed(user.id, 'GET', `/api/${plugin}`)).allowed) {
|
||||
return new Response('Forbidden', { status: 403 });
|
||||
}
|
||||
|
||||
const target = await pluginWsTargetFor(plugin);
|
||||
// 404 for both "no such plugin" and "no socket declared", identically. A caller must not be able to
|
||||
// discover which plugins are installed by which refusal it gets — the same reasoning as SendOff's
|
||||
// recipient lookup.
|
||||
if (!target) return new Response('Not found', { status: 404 });
|
||||
|
||||
// The rest of the query is the plugin's to interpret and is passed through untouched. The CREDENTIAL is
|
||||
// not: a sidecar has no use for the caller's JWT, and forwarding one puts a bearer token into whatever
|
||||
// that process logs. `X-Officer-User` carries the only part it needs.
|
||||
const forwarded = new URLSearchParams(url.search);
|
||||
forwarded.delete('token');
|
||||
const query = forwarded.toString();
|
||||
|
||||
const ok = server.upgrade(req, {
|
||||
data: {
|
||||
provider: 'plugin-ws',
|
||||
plugin,
|
||||
upstream: `ws://${target.host}:${target.port}/ws${query ? `?${query}` : ''}`,
|
||||
userId: user.id,
|
||||
} satisfies PluginWSData,
|
||||
});
|
||||
return ok ? undefined : new Response('Upgrade failed', { status: 400 });
|
||||
} catch {
|
||||
// Any failure to establish WHO is calling denies. Never open a socket on a maybe.
|
||||
return new Response('Unauthorized', { status: 401 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { createRouter } from '../../create-router';
|
||||
import * as errors from '@@/custom-errors';
|
||||
import { isSuperAdmin } from '../../super-admin';
|
||||
import { listRoles, createRole, renameRole, deleteRole, setRoleGrants } from 'officerdb';
|
||||
import { seedGrantsForNewRole } from '@@/plugins/grant-on-install';
|
||||
|
||||
/*
|
||||
/api/roles — role CRUD (phase 2 part 3 of docs/roles-and-teams.md), plus grants (phase 3).
|
||||
|
||||
This is the roles TABLE — a name, a description, the `isSystem` flag, and since phase 3 the two
|
||||
provisioning flags (`grantsPostgres`, `grantsDocker`). It is not the PERMISSION grants a role holds;
|
||||
those stay `role_permissions`, administered at `/api/users/permissions` by `permissionAdminRouter`. The
|
||||
two are separate screens for a reason: this router answers "which roles exist, and what does holding one
|
||||
provision on the machine", that one answers "what may each reach in the app" — and deleting a role here
|
||||
has to know the second question's table too, because the guard below is "does anyone hold it", not "what
|
||||
does it grant".
|
||||
|
||||
── Owner only, explicitly ──
|
||||
|
||||
`roles` is `kind: 'admin'` in the permission registry, so the global gate already refuses a non-owner
|
||||
before a request reaches here. This is the same belt-and-braces every other admin router in this
|
||||
directory adds on top of that (app-store, server-admin): a mistake in the registry must not turn into
|
||||
the ability to rename or delete a role.
|
||||
|
||||
── Why delete refuses instead of reassigning ──
|
||||
|
||||
D4 in the design doc: never move a role's holders onto a fallback. That is a permission change nobody
|
||||
asked for, and "absence denies" — the posture everywhere else in this system — means a role with
|
||||
accounts still on it stays exactly as reachable as it was until every one of them is moved off by
|
||||
someone who chose to.
|
||||
*/
|
||||
|
||||
export const rolesRouter = createRouter();
|
||||
|
||||
rolesRouter.use(async (ctx, next) => {
|
||||
if (!(await isSuperAdmin(ctx.get('user')))) throw errors.FORBIDDEN('Role management is owner-only');
|
||||
return next();
|
||||
});
|
||||
|
||||
/** GET /api/roles — every role, with how many accounts hold each. */
|
||||
rolesRouter.get('/', async (ctx) => ctx.json({ roles: await listRoles() }));
|
||||
|
||||
/**
|
||||
* POST /api/roles — create a role. Always non-system; see the schema comment on `isSystem`.
|
||||
*
|
||||
* `grantsPostgres`/`grantsDocker` default to false when omitted, matching the column defaults — creating
|
||||
* a role must not hand out a Postgres login and a Docker daemon because a form field was left out of the
|
||||
* request body.
|
||||
*/
|
||||
rolesRouter.post('/', async (ctx) => {
|
||||
const body = ctx.get('body') as {
|
||||
name?: unknown;
|
||||
description?: unknown;
|
||||
grantsPostgres?: unknown;
|
||||
grantsDocker?: unknown;
|
||||
};
|
||||
if (typeof body?.name !== 'string') throw errors.BAD_REQUEST('name is required');
|
||||
const description = typeof body.description === 'string' ? body.description : null;
|
||||
if (body.grantsPostgres !== undefined && typeof body.grantsPostgres !== 'boolean') {
|
||||
throw errors.BAD_REQUEST('grantsPostgres must be a boolean');
|
||||
}
|
||||
if (body.grantsDocker !== undefined && typeof body.grantsDocker !== 'boolean') {
|
||||
throw errors.BAD_REQUEST('grantsDocker must be a boolean');
|
||||
}
|
||||
const grantsPostgres = typeof body.grantsPostgres === 'boolean' ? body.grantsPostgres : undefined;
|
||||
const grantsDocker = typeof body.grantsDocker === 'boolean' ? body.grantsDocker : undefined;
|
||||
|
||||
const result = await createRole({ name: body.name, description, grantsPostgres, grantsDocker });
|
||||
if (!result.ok) {
|
||||
throw result.reason === 'invalid-name'
|
||||
? errors.BAD_REQUEST('a role name cannot be empty')
|
||||
: errors.CONFLICT(`a role named '${body.name.trim()}' already exists`);
|
||||
}
|
||||
/*
|
||||
A new role starts where Member does: the confined baseline plus every installed plugin.
|
||||
|
||||
Not doing this is how the policy ends up half-applied — granted to roles that existed when a plugin was
|
||||
installed, and not to any created afterwards. It also fixes something older: DEFAULT_ROLE_PERMISSIONS
|
||||
was only ever applied at bootstrap, so a role created here used to begin with nothing at all, not even
|
||||
chat or files, and nothing said why.
|
||||
|
||||
Not fatal. The role exists either way and its grants can be set on the permissions screen; failing the
|
||||
creation over a grant would be the worse trade.
|
||||
*/
|
||||
const seeded = await seedGrantsForNewRole(result.role.name).catch(() => 0);
|
||||
|
||||
return ctx.json({ role: result.role, seededGrants: seeded });
|
||||
});
|
||||
|
||||
/**
|
||||
* PATCH /api/roles/:name — rename. Refuses a system role outright: `Developer`, `Super Admin` and
|
||||
* `Member` are tested by string in code that has not been updated to read this table (see D4), so
|
||||
* renaming one of those would silently detach the code's own idea of who holds what.
|
||||
*/
|
||||
rolesRouter.patch('/:name', async (ctx) => {
|
||||
const from = ctx.req.param('name');
|
||||
const body = ctx.get('body') as { name?: unknown };
|
||||
if (typeof body?.name !== 'string') throw errors.BAD_REQUEST('name is required');
|
||||
|
||||
const result = await renameRole(from, body.name);
|
||||
if (!result.ok) {
|
||||
switch (result.reason) {
|
||||
case 'not-found':
|
||||
throw errors.NOT_FOUND(`no such role '${from}'`);
|
||||
case 'system':
|
||||
throw errors.FORBIDDEN(
|
||||
`'${from}' is a system role and the platform's own code names it — it cannot be renamed`,
|
||||
);
|
||||
case 'invalid-name':
|
||||
throw errors.BAD_REQUEST('a role name cannot be empty');
|
||||
case 'duplicate':
|
||||
throw errors.CONFLICT(`a role named '${body.name.trim()}' already exists`);
|
||||
}
|
||||
}
|
||||
return ctx.json({ role: result.role });
|
||||
});
|
||||
|
||||
/**
|
||||
* PATCH /api/roles/:name/grants — set `grantsPostgres`/`grantsDocker`. Refuses a system role, for a
|
||||
* different reason than the rename route above: `scripts/seed-roles.ts` re-asserts a system role's grants
|
||||
* on every `postdb:push`, so a write here would look like it worked and then silently revert on the next
|
||||
* deploy. A custom role has no such seed pinning it, so this is where its grants actually get set once it
|
||||
* exists — `createRole` only takes an initial value.
|
||||
*
|
||||
* Either field may be omitted; only the ones present are changed (see `setRoleGrants`).
|
||||
*/
|
||||
rolesRouter.patch('/:name/grants', async (ctx) => {
|
||||
const name = ctx.req.param('name');
|
||||
const body = ctx.get('body') as { grantsPostgres?: unknown; grantsDocker?: unknown };
|
||||
if (body?.grantsPostgres === undefined && body?.grantsDocker === undefined) {
|
||||
throw errors.BAD_REQUEST('grantsPostgres and/or grantsDocker is required');
|
||||
}
|
||||
if (body.grantsPostgres !== undefined && typeof body.grantsPostgres !== 'boolean') {
|
||||
throw errors.BAD_REQUEST('grantsPostgres must be a boolean');
|
||||
}
|
||||
if (body.grantsDocker !== undefined && typeof body.grantsDocker !== 'boolean') {
|
||||
throw errors.BAD_REQUEST('grantsDocker must be a boolean');
|
||||
}
|
||||
const grantsPostgres = typeof body.grantsPostgres === 'boolean' ? body.grantsPostgres : undefined;
|
||||
const grantsDocker = typeof body.grantsDocker === 'boolean' ? body.grantsDocker : undefined;
|
||||
|
||||
const result = await setRoleGrants(name, { grantsPostgres, grantsDocker });
|
||||
if (!result.ok) {
|
||||
throw result.reason === 'not-found'
|
||||
? errors.NOT_FOUND(`no such role '${name}'`)
|
||||
: errors.FORBIDDEN(
|
||||
`'${name}' is a system role — its grants are declared in scripts/seed-roles.ts, not edited here`,
|
||||
);
|
||||
}
|
||||
return ctx.json({ role: result.role });
|
||||
});
|
||||
|
||||
/** DELETE /api/roles/:name — refuses a system role, and refuses one any account still holds. */
|
||||
rolesRouter.delete('/:name', async (ctx) => {
|
||||
const name = ctx.req.param('name');
|
||||
const result = await deleteRole(name);
|
||||
if (!result.ok) {
|
||||
switch (result.reason) {
|
||||
case 'not-found':
|
||||
throw errors.NOT_FOUND(`no such role '${name}'`);
|
||||
case 'system':
|
||||
throw errors.FORBIDDEN(
|
||||
`'${name}' is a system role and the platform's own code names it — it cannot be deleted`,
|
||||
);
|
||||
case 'in-use':
|
||||
throw errors.CONFLICT(
|
||||
`${result.holderCount} account(s) still hold '${name}' — move them to another role first`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return ctx.json({ ok: true });
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdir } from 'node:fs/promises';
|
||||
import { mkdir, rename, rm } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { extname, join } from 'node:path';
|
||||
import { createRouter } from '../../create-router';
|
||||
@@ -149,9 +149,19 @@ sendoffRouter.post('/send', async (ctx) => {
|
||||
await mkdir(dir, { recursive: true });
|
||||
|
||||
const written: { name: string; size: number }[] = [];
|
||||
// Aside, then rename — the same reasoning as the file browser upload, and it matters more here
|
||||
// because this is a folder things ARRIVE in unattended. Neither a recipient watching it nor the
|
||||
// notification below may point at a name that is still being written.
|
||||
for (const file of files) {
|
||||
const path = freePath(dir, safeFilename(file.name));
|
||||
await Bun.write(path, await file.arrayBuffer());
|
||||
const partPath = `${path}.part`;
|
||||
try {
|
||||
await Bun.write(partPath, await file.arrayBuffer());
|
||||
await rename(partPath, path);
|
||||
} catch (err) {
|
||||
await rm(partPath, { force: true }).catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
written.push({ name: path.split('/').pop() ?? file.name, size: file.size });
|
||||
}
|
||||
|
||||
|
||||
@@ -75,6 +75,10 @@ const PROMPT_CONTEXT_TOKENS = 96;
|
||||
// load-bearing. Overflow is trimmed from the front, so nothing depends on the opening words.
|
||||
//
|
||||
// Languages without an entry fall through to no prompt at all, which is handled explicitly below.
|
||||
//
|
||||
// Every prompt uses its language's full diacritic set (and, for a non-Latin script, its own
|
||||
// punctuation — Japanese 。/、, Greek ; as a question mark, Arabic ، and ؟) in ordinary words, never
|
||||
// forced in. Omitting one, e.g. writing Swedish without å ä ö, teaches Whisper to drop it too.
|
||||
const TRANSCRIPT_PROMPTS: Record<string, string> = {
|
||||
en:
|
||||
'Hey, sorry for the late reply — I was stuck in a meeting all morning and only just got out. ' +
|
||||
@@ -90,6 +94,153 @@ const TRANSCRIPT_PROMPTS: Record<string, string> = {
|
||||
'escrita. Diz-me quando tiveres uns minutos e falamos com calma. Estou por aqui toda a tarde, ' +
|
||||
'por isso quando te der jeito está bem para mim. Obrigado outra vez por teres tratado disto, ' +
|
||||
'sei que deu trabalho.',
|
||||
es:
|
||||
'Hola, perdona la tardanza en responder — he estado toda la mañana en una reunión y recién ahora ' +
|
||||
'he podido salir. Ya vi el documento que me enviaste y, la verdad, creo que está bastante bien. ' +
|
||||
'Hay un par de cosas que cambiaría en la segunda mitad, pero nada grave. La introducción está muy ' +
|
||||
'bien escrita. Avísame cuando tengas unos minutos y lo hablamos con calma. Estoy disponible toda ' +
|
||||
'la tarde, así que cuando te venga bien está perfecto para mí. Gracias otra vez por armarlo, sé ' +
|
||||
'que llevó tiempo.',
|
||||
de:
|
||||
'Hallo, entschuldige die späte Antwort — ich saß den ganzen Vormittag in einer Besprechung und ' +
|
||||
'bin gerade erst rausgekommen. Ich habe mir das Dokument angeschaut, das du geschickt hast, und ' +
|
||||
'ehrlich gesagt finde ich es größtenteils gut. In der zweiten Hälfte würde ich ein paar ' +
|
||||
'Kleinigkeiten ändern, aber nichts Ernstes. Die Einleitung liest sich wirklich gut. Sag mir ' +
|
||||
'Bescheid, wenn du später ein paar Minuten hast, dann können wir in Ruhe darüber reden. Ich bin ' +
|
||||
'den ganzen Nachmittag über da, also wann immer es dir passt, ist mir recht. Danke nochmal, dass ' +
|
||||
'du dir die Mühe gemacht hast, ich weiß, das hat Zeit gekostet.',
|
||||
fr:
|
||||
"Salut, désolé pour la réponse tardive — j'étais coincé en réunion toute la matinée et je viens " +
|
||||
"tout juste d'en sortir. J'ai regardé le document que tu as envoyé, et honnêtement, je trouve que " +
|
||||
"c'est plutôt bien. Il y a deux ou trois choses que je changerais dans la deuxième moitié, mais " +
|
||||
"rien de grave. L'introduction est vraiment bien écrite. Dis-moi quand tu as quelques minutes et " +
|
||||
"on en parle tranquillement. Je suis disponible tout l'après-midi, donc quand ça t'arrange, ça me " +
|
||||
"va. Merci encore de l'avoir préparé, je sais que ça a pris du temps.",
|
||||
sv:
|
||||
'Hej, förlåt att jag inte svarat tidigare — jag satt i möte hela förmiddagen och kom precis loss. ' +
|
||||
'Jag har tittat på dokumentet du skickade, och ärligt talat tycker jag att det mesta är bra. Det ' +
|
||||
'är några saker jag skulle ändra i andra halvan, men inget allvarligt. Inledningen är verkligen ' +
|
||||
'bra skriven. Säg till när du har några minuter så pratar vi igenom det ordentligt. Jag är ledig ' +
|
||||
'hela eftermiddagen, så när det passar dig passar det mig. Tack igen för att du satte ihop det, ' +
|
||||
'jag vet att det tog ett tag.',
|
||||
it:
|
||||
'Ciao, scusa il ritardo nella risposta — sono stato in riunione tutta la mattina e sono appena ' +
|
||||
"uscito. Ho dato un'occhiata al documento che mi hai mandato e, sinceramente, penso che vada bene " +
|
||||
'quasi tutto. Ci sono un paio di cose che cambierei nella seconda metà, ma niente di grave. ' +
|
||||
"L'introduzione è scritta davvero bene. Fammi sapere quando hai qualche minuto così ne parliamo " +
|
||||
'con calma. Sono libero tutto il pomeriggio, quindi va bene quando ti fa comodo. Grazie ancora ' +
|
||||
'per averlo messo insieme, so che ci è voluto tempo.',
|
||||
nl:
|
||||
'Hoi, sorry voor het late antwoord — ik zat de hele ochtend in een vergadering en ben er net pas ' +
|
||||
'uit. Ik heb naar het document gekeken dat je hebt gestuurd, en eerlijk gezegd vind ik het ' +
|
||||
'grotendeels goed. Er zijn een paar dingen die ik in de tweede helft zou veranderen, maar niets ' +
|
||||
'ergs. De inleiding leest heel prettig. Laat het me weten wanneer je een paar minuten hebt, dan ' +
|
||||
'bespreken we het rustig. Ik ben de hele middag beschikbaar, dus wanneer het jou uitkomt is voor ' +
|
||||
'mij prima. Bedankt nogmaals dat je het hebt samengesteld, ik weet dat het tijd heeft gekost.',
|
||||
pl:
|
||||
'Cześć, przepraszam za późną odpowiedź — siedziałem cały ranek na spotkaniu i w końcu udało mi ' +
|
||||
'się z niego wyrwać. Zerknąłem na dokument, który przesłałeś, i szczerze mówiąc, myślę, że ' +
|
||||
'większość jest w porządku. Jest kilka rzeczy, które zmieniłbym w drugiej połowie, ale nic ' +
|
||||
'poważnego. Wstęp jest naprawdę dobrze napisany. Daj znać, kiedy będziesz mieć chwilę, możemy to ' +
|
||||
'spokojnie omówić. Jestem wolny całe popołudnie, więc kiedy ci pasuje, mi też pasuje. Dzięki ' +
|
||||
'jeszcze raz za to, że to zebrałeś, wiem, że zajęło to trochę czasu.',
|
||||
da:
|
||||
'Hej, undskyld det sene svar — jeg sad i møde hele formiddagen og er lige kommet ud. Jeg har ' +
|
||||
'kigget på dokumentet, du sendte, og ærligt talt synes jeg det meste er fint. Der er et par ting, ' +
|
||||
'jeg ville ændre i anden halvdel, men ikke noget alvorligt. Indledningen er skrevet rigtig godt. ' +
|
||||
'Sig til, når du har et par minutter, så snakker vi ordentligt om det. Jeg er ledig hele ' +
|
||||
'eftermiddagen, så det passer mig, når det passer dig. Tak igen fordi du satte det sammen, jeg ' +
|
||||
'ved det tog tid.',
|
||||
no:
|
||||
'Hei, beklager det sene svaret — jeg satt i møte hele formiddagen og er akkurat ferdig. Jeg har ' +
|
||||
'sett på dokumentet du sendte, og ærlig talt synes jeg det meste er bra. Det er et par ting jeg ' +
|
||||
'ville endret i andre halvdel, men ingenting alvorlig. Innledningen er skrevet veldig bra. Si ' +
|
||||
'ifra når du har noen minutter, så tar vi en skikkelig prat om det. Jeg er ledig hele ' +
|
||||
'ettermiddagen, så det som passer deg passer meg. Takk igjen for at du satte det sammen, jeg vet ' +
|
||||
'det tok tid.',
|
||||
fi:
|
||||
'Hei, anteeksi myöhäinen vastaus — istuin koko aamupäivän palaverissa ja pääsin juuri äsken pois. ' +
|
||||
'Katsoin dokumentin, jonka lähetit, ja rehellisesti sanottuna suurin osa siitä on ihan hyvä. ' +
|
||||
'Loppupuoliskolla on pari asiaa, jotka muuttaisin, mutta ei mitään vakavaa. Johdanto on ' +
|
||||
'kirjoitettu tosi hyvin. Sano, kun sinulla on hetki aikaa, niin jutellaan siitä kunnolla. Olen ' +
|
||||
'vapaana koko iltapäivän, joten mikä tahansa sopii sinulle, sopii minulle. Kiitos vielä kerran, ' +
|
||||
'kun kokosit sen, tiedän että siihen meni aikaa.',
|
||||
tr:
|
||||
'Selam, geç cevap verdiğim için kusura bakma — bütün sabah toplantıdaydım ve az önce çıkabildim. ' +
|
||||
'Gönderdiğin dokümana baktım ve açıkçası çoğunlukla iyi olduğunu düşünüyorum. İkinci yarıda ' +
|
||||
'değiştireceğim birkaç şey var ama ciddi bir şey değil. Giriş kısmı gerçekten iyi yazılmış. ' +
|
||||
'Birkaç dakikan olduğunda haber ver, düzgünce konuşuruz. Bütün öğleden sonra müsaitim, yani sana ' +
|
||||
'uyan zaman bana da uyar. Bunu hazırladığın için tekrar teşekkürler, biliyorum zaman almıştır.',
|
||||
cs:
|
||||
'Ahoj, promiň za pozdní odpověď — celé dopoledne jsem byl na poradě a teprve teď jsem se dostal ' +
|
||||
'ven. Podíval jsem se na dokument, cos poslal, a upřímně řečeno, myslím, že většina je v pořádku. ' +
|
||||
'V druhé polovině bych pár věcí změnil, ale nic vážného. Úvod je napsaný fakt dobře. Dej vědět, ' +
|
||||
'až budeš mít pár minut, můžeme to pořádně probrat. Celé odpoledne mám volno, takže kdy se ti to ' +
|
||||
'bude hodit, mně to vyhovuje. Díky ještě jednou, žes to dal dohromady, vím, že to zabralo čas.',
|
||||
el:
|
||||
'Γεια σου, συγγνώμη για την καθυστερημένη απάντηση — ήμουν σε συνάντηση όλο το πρωί και μόλις ' +
|
||||
'τώρα βγήκα. Είδα το έγγραφο που έστειλες και, ειλικρινά, νομίζω πως είναι σχεδόν όλο εντάξει. ' +
|
||||
'Υπάρχουν δύο τρία πράγματα που θα άλλαζα στο δεύτερο μισό, αλλά τίποτα σοβαρό. Η εισαγωγή είναι ' +
|
||||
'γραμμένη πολύ καλά. Έχεις λίγα λεπτά να τα πούμε με την ησυχία μας; Είμαι ελεύθερος όλο το ' +
|
||||
'απόγευμα, οπότε όποτε σε βολεύει εμένα με βολεύει. Ευχαριστώ ξανά που το ετοίμασες, ξέρω πως ' +
|
||||
'πήρε χρόνο.',
|
||||
uk:
|
||||
'Привіт, вибач за пізню відповідь — я весь ранок сидів на нараді і щойно звідти вийшов. Подивився ' +
|
||||
'документ, який ти надіслав, і чесно кажучи, мої думки такі — майже все добре. У мене є пара ' +
|
||||
'речей, які я б змінив у другій половині, але нічого серйозного. Вступ написаний дуже добре. ' +
|
||||
'Напиши, коли матимеш кілька хвилин, і спокійно все обговоримо. Я вільний увесь день, тобто коли тобі зручно, ' +
|
||||
'мені теж підходить. Дякую ще раз, що зібрав це докупи, знаю, що це зайняло час.',
|
||||
ru:
|
||||
'Привет, извини, что долго не отвечал — всё утро просидел на встрече и только что освободился. ' +
|
||||
'Посмотрел документ, который ты прислал, и, честно говоря, думаю, что почти всё нормально. Есть ' +
|
||||
'пара моментов, которые я бы изменил во второй половине, но ничего серьёзного. Введение написано ' +
|
||||
'правда хорошо. Напиши, когда будет пара свободных минут, спокойно всё обсудим. Я свободен весь ' +
|
||||
'день, так что когда тебе удобно, мне тоже подходит. Спасибо ещё раз, что всё это собрал, знаю, ' +
|
||||
'что это заняло время.',
|
||||
ro:
|
||||
'Salut, scuze pentru răspunsul întârziat — am fost toată dimineața într-o ședință și abia acum am ' +
|
||||
'scăpat. M-am uitat pe documentul pe care mi l-ai trimis și, sincer, cred că e aproape totul ' +
|
||||
'bine. Sunt vreo două-trei lucruri pe care le-aș schimba în a doua jumătate, dar nimic grav. ' +
|
||||
'Introducerea e scrisă chiar bine. Spune-mi când ai câteva minute și vorbim pe îndelete despre ' +
|
||||
'asta. Sunt liber toată după-amiaza, deci oricând îți convine mie îmi convine. Mulțumesc încă o ' +
|
||||
'dată că ai pus totul cap la cap, știu că a durat.',
|
||||
hu:
|
||||
'Szia, bocs a késői válaszért — egész délelőtt gyűlésen ültem, és csak most sikerült ' +
|
||||
'kiszabadulnom. Megnéztem a dokumentumot, amit küldtél, és őszintén szólva szerintem szinte ' +
|
||||
'minden rendben van. Van pár dolog, amit megváltoztatnék a második felében, de semmi komoly. A ' +
|
||||
'bevezető nagyon jól van megírva. Szólj, ha van pár szabad perced, és nyugodtan átbeszéljük. ' +
|
||||
'Egész délután érek rá, szóval amikor neked jó, nekem is megfelel. Köszönöm még egyszer, hogy ' +
|
||||
'összeraktad, tudom, hogy hosszú időbe telt.',
|
||||
ja:
|
||||
'返信が遅くなってごめん。朝からずっと会議で、今やっと抜けられたところ。送ってもらった資料、見たよ。' +
|
||||
'正直、ほとんど問題ないと思う。後半のところで直したい部分が二つ三つあるけど、大したことじゃない。' +
|
||||
'導入の部分はすごくよく書けてると思う。時間があるときに教えて、ゆっくり話そう。' +
|
||||
'午後はずっと空いてるから、都合のいいときでいいよ。まとめてくれてありがとう、時間かかったよね。',
|
||||
zh:
|
||||
'抱歉这么晚才回你——我一早上都在开会,刚出来。你发的那份文件我看过了,说实话,大部分都挺好的。' +
|
||||
'后半部分有几个地方我想改一下,但都不是什么大问题。开头写得真的很不错。你什么时候有空跟我说一声,我们好好聊聊。' +
|
||||
'我整个下午都有空,所以你什么时候方便都行。再次谢谢你把这个整理出来,我知道挺花时间的。',
|
||||
ko:
|
||||
'답장이 늦어서 미안해. 오전 내내 회의하다가 방금 겨우 빠져나왔어. 보내준 문서 봤는데, 솔직히 거의 다 괜찮은 것 같아. 후반부에 고치고 싶은 부분이 두어 개 있긴 한데, ' +
|
||||
'심각한 건 아니야. 서론은 진짜 잘 쓴 것 같아. 시간 될 때 얘기해줘, 그럼 차분히 얘기해보자. 오후 내내 시간 있으니까 편한 때 아무 때나 괜찮아. 정리해줘서 다시 한번 ' +
|
||||
'고마워, 시간 좀 걸렸을 텐데.',
|
||||
ar:
|
||||
'مرحبًا، آسف على تأخري في الرد — كنت في اجتماع طوال الصباح ولتوّي خرجت منه. اطّلعت على المستند ' +
|
||||
'الذي أرسلته، وبصراحة أعتقد أن معظمه جيد. هناك بعض الأشياء التي أود تغييرها في النصف الثاني، لكن ' +
|
||||
'لا شيء خطير. المقدمة مكتوبة بشكل جيد جدًا. هل يمكن أن تخبرني متى تكون لديك بضع دقائق لنتحدث ' +
|
||||
'بهدوء؟ أنا متفرغ طوال بعد الظهر، فأي وقت يناسبك يناسبني. شكرًا مرة أخرى على إعدادك لهذا، أعرف أن ' +
|
||||
'الأمر استغرق وقتًا.',
|
||||
he:
|
||||
'היי, סליחה על התגובה המאוחרת — הייתי בישיבה כל הבוקר ורק עכשיו יצאתי. הסתכלתי על המסמך ששלחת, ' +
|
||||
'ובכנות, אני חושב שרוב הדברים בסדר. יש כמה דברים שהייתי משנה בחצי השני, אבל שום דבר רציני. הפתיחה ' +
|
||||
'כתובה ממש טוב. תגיד לי מתי יש לך כמה דקות ונדבר על זה בנחת. אני פנוי כל אחר הצהריים, אז מתי שנוח ' +
|
||||
'לך נוח גם לי. תודה שוב שסידרת את זה, אני יודע שזה לקח זמן.',
|
||||
hi:
|
||||
'हाय, देर से जवाब देने के लिए माफ़ करना — मैं पूरी सुबह मीटिंग में था और अभी-अभी निकल पाया। तुमने ' +
|
||||
'जो डॉक्यूमेंट भेजा था, वो देख लिया, और सच कहूं तो ज़्यादातर हिस्सा ठीक लग रहा है। दूसरे हिस्से ' +
|
||||
'में कुछ चीज़ें हैं जो मैं बदलना चाहूंगा, पर कुछ गंभीर नहीं है। शुरुआत बहुत अच्छी लिखी गई है। जब ' +
|
||||
'भी कुछ मिनट मिलें बता देना, फिर आराम से बात करते हैं। पूरी दोपहर मैं फ्री हूं, तो जब भी तुम्हें ' +
|
||||
'सही लगे मुझे ठीक है। इसे तैयार करने के लिए फिर से शुक्रिया, पता है इसमें वक़्त लगा होगा।',
|
||||
};
|
||||
|
||||
// Beam search rather than the default greedy decode (whisper.cpp ships `beam_size = -1`). It is the
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { Handler } from 'hono';
|
||||
import { createUser, getUserByEmail, getUserByUsername, USER_ROLES } from 'officerdb';
|
||||
import type { UserRole } from 'officerdb';
|
||||
import { createUser, getUserByEmail, getUserByUsername, getRole } from 'officerdb';
|
||||
import argon2 from 'argon2';
|
||||
import * as errors from '@@/custom-errors';
|
||||
import { validatePublicKey } from '@@/os-user-ssh';
|
||||
@@ -28,9 +27,6 @@ import { toPublicUser } from './manage-users';
|
||||
// which is precisely the trap the hand-INSERT route fell into. An account the owner created in the admin
|
||||
// UI is verified by definition — the owner is the verification.
|
||||
|
||||
/** Roles this route may assign. Never 'Super Admin' — see below. */
|
||||
const ASSIGNABLE_ROLES = USER_ROLES.filter((r) => r !== 'Super Admin');
|
||||
|
||||
export const createUserHandler: Handler = async function (ctx) {
|
||||
const body = (ctx.get('body') ?? {}) as Record<string, unknown>;
|
||||
|
||||
@@ -55,12 +51,19 @@ export const createUserHandler: Handler = async function (ctx) {
|
||||
// return whichever the query reached first. That answer decides the identity the agent sidecar runs
|
||||
// as, who reaches the vault and which origin is privileged, so it is not a thing to leave to a query
|
||||
// plan. If the owner ever needs to hand the server over, that is a deliberate transfer, not a dropdown.
|
||||
if (!(ASSIGNABLE_ROLES as readonly string[]).includes(role)) {
|
||||
throw errors.BAD_REQUEST(
|
||||
role === 'Super Admin'
|
||||
? 'There is one server owner and it cannot be created here.'
|
||||
: `Role must be one of: ${ASSIGNABLE_ROLES.join(', ')}`,
|
||||
);
|
||||
//
|
||||
// Checked as a literal BEFORE the table lookup: 'Super Admin' is always a real row (it is seeded and
|
||||
// isSystem), so a table-only check would let it through and rely on the message below to explain why
|
||||
// it still isn't offered here — this way the refusal and its reason are decided in one place.
|
||||
if (role === 'Super Admin') {
|
||||
throw errors.BAD_REQUEST('There is one server owner and it cannot be created here.');
|
||||
}
|
||||
// The runtime replacement for what USER_ROLES used to guard at the type level (docs/roles-and-teams.md
|
||||
// phase 2 part 4) — `role` is user input from here on, and a role that is not a row in `roles` must
|
||||
// never reach `createUser`, or the FK on `users.role` throws a raw Postgres error instead of this
|
||||
// message.
|
||||
if (!(await getRole(role))) {
|
||||
throw errors.BAD_REQUEST(`Unknown role '${role}'`);
|
||||
}
|
||||
|
||||
// The inbound SSH key, if the owner supplied one. Validated HERE rather than at use, so a bad paste is a
|
||||
@@ -87,7 +90,7 @@ export const createUserHandler: Handler = async function (ctx) {
|
||||
name,
|
||||
username,
|
||||
status: 'Active',
|
||||
role: role as UserRole,
|
||||
role,
|
||||
});
|
||||
|
||||
// The Linux side: directories, the Linux user, the confinement, the keys. Non-fatal and reported rather
|
||||
@@ -97,7 +100,7 @@ export const createUserHandler: Handler = async function (ctx) {
|
||||
//
|
||||
// Retryable in place afterwards via POST /users/:id/provision-linux, so a host that was not ready when the
|
||||
// account was made does not cost anybody their password and dashboards.
|
||||
const os = await provisionOsAccount({ userId: user.id, email, username, role: role as UserRole, inboundKey });
|
||||
const os = await provisionOsAccount({ userId: user.id, email, username, role, inboundKey });
|
||||
|
||||
// Tell any plugin that provisions upstream accounts. Detached and unawaited: a plugin's sidecar being
|
||||
// down must not fail the creation of a platform account, and the list is re-sent when it registers.
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import type { Handler } from 'hono';
|
||||
import { getUsers, getUserById, updateUser, deleteUser, USER_ROLES, OWNER_USER_ID } from 'officerdb';
|
||||
import type { UserRole } from 'officerdb';
|
||||
import { getUsers, getUserById, updateUser, deleteUser, listRoles, getRole, OWNER_USER_ID } from 'officerdb';
|
||||
import * as errors from '@@/custom-errors';
|
||||
import { deprovisionOsAccount } from '@@/os-user-deprovision';
|
||||
import { dropPostgresRole, provisionPostgresRole, rolePermitsDevTools } from '@@/os-user-postgres';
|
||||
import { dropPostgresRole, provisionPostgresRole } from '@@/os-user-postgres';
|
||||
import { lookupOsUser } from '@@/os-user';
|
||||
import { DATA_PATH } from '@@/data-path';
|
||||
import { broadcastProvisionableUsers } from '@@/plugins/user-provisioning';
|
||||
@@ -50,13 +49,16 @@ export const toPublicUser = (u: Awaited<ReturnType<typeof getUsers>>[number]): P
|
||||
});
|
||||
|
||||
export const listUsersHandler: Handler = async function (ctx) {
|
||||
const users = await getUsers();
|
||||
const [users, roles] = await Promise.all([getUsers(), listRoles()]);
|
||||
const roleNames = roles.map((r) => r.name);
|
||||
return ctx.json({
|
||||
users: users.sort((a, b) => a.id - b.id).map(toPublicUser),
|
||||
// Every role, so the owner's own row can display its value. The UI must not offer 'Super Admin' in a
|
||||
// picker — both write paths refuse it — which is what `assignableRoles` is for.
|
||||
roles: USER_ROLES,
|
||||
assignableRoles: USER_ROLES.filter((r) => r !== 'Super Admin'),
|
||||
// picker — both write paths refuse it — which is what `assignableRoles` is for. Read from the `roles`
|
||||
// table rather than a hardcoded list (docs/roles-and-teams.md phase 2 part 4) — a role created at
|
||||
// runtime through /api/roles must show up here without a deploy.
|
||||
roles: roleNames,
|
||||
assignableRoles: roleNames.filter((r) => r !== 'Super Admin'),
|
||||
ownerId: OWNER_USER_ID,
|
||||
});
|
||||
};
|
||||
@@ -66,9 +68,14 @@ export const updateUserRoleHandler: Handler = async function (ctx) {
|
||||
if (!Number.isInteger(id) || id < 1) throw errors.BAD_REQUEST('Invalid user id');
|
||||
|
||||
const { role } = ctx.get('body') as { role?: unknown };
|
||||
if (typeof role !== 'string' || !(USER_ROLES as readonly string[]).includes(role)) {
|
||||
throw errors.BAD_REQUEST(`Role must be one of: ${USER_ROLES.join(', ')}`);
|
||||
}
|
||||
// The runtime replacement for what USER_ROLES used to guard at the type level (docs/roles-and-teams.md
|
||||
// phase 2 part 4): a role that is not a row in `roles` must never reach `updateUser`, or the FK on
|
||||
// `users.role` throws a raw Postgres error instead of this message.
|
||||
if (typeof role !== 'string') throw errors.BAD_REQUEST(`Unknown role '${String(role)}'`);
|
||||
// Kept rather than re-fetched below: this is the one read of the DESTINATION role's row this handler
|
||||
// needs, and `wants` reuses it instead of asking the database the same question twice.
|
||||
const newRole = await getRole(role);
|
||||
if (!newRole) throw errors.BAD_REQUEST(`Unknown role '${role}'`);
|
||||
|
||||
// ck_users_owner_is_super_admin would reject this anyway — the point of catching it here is the
|
||||
// message. A raw CHECK violation surfaces as a 500 with Postgres wording, which tells the person
|
||||
@@ -77,21 +84,26 @@ export const updateUserRoleHandler: Handler = async function (ctx) {
|
||||
throw errors.FORBIDDEN('The server owner cannot be demoted.');
|
||||
}
|
||||
|
||||
// And nobody else can be promoted INTO it. The CHECK constraint pins user 1's role but cannot stop a
|
||||
// second row holding it — a row-level check cannot see other rows — and `getOwnerUser()` resolves the
|
||||
// owner by that role, so two holders make "who owns this server" a question the query plan answers.
|
||||
// It decides the agent sidecar's identity, vault access and which origin is privileged. Handing the
|
||||
// server over is a deliberate act, not a dropdown.
|
||||
if (id !== OWNER_USER_ID && role === 'Super Admin') {
|
||||
throw errors.FORBIDDEN('There is one server owner, and this is not how it changes.');
|
||||
}
|
||||
/*
|
||||
A second Super Admin is allowed since 2026-08-23, and the refusal that used to live here is gone.
|
||||
|
||||
It existed because `getOwnerUser()` resolved the owner BY that role, so two holders made "who owns this
|
||||
server" a question the query plan answered — and that answer decided the agent sidecar's identity, vault
|
||||
access and which origin is privileged. Ownership is now `OWNER_USER_ID`, so the role no longer carries
|
||||
it and several accounts may hold it.
|
||||
|
||||
What the role does carry is real and worth saying out loud in the UI rather than here: it provisions an
|
||||
OS account with sudo. That puts the holder outside Officer's permission system entirely and cannot be
|
||||
undone by revoking the role afterwards. Granting it is handing over the machine — which is the operator's
|
||||
decision to make, and no longer one this endpoint refuses on their behalf.
|
||||
*/
|
||||
|
||||
const existing = await getUserById(id);
|
||||
if (!existing) throw errors.NOT_FOUND('User not found');
|
||||
|
||||
// ── The database role follows the platform role ──
|
||||
//
|
||||
// Without this the gate in `rolePermitsDevTools` is decorative: it would decide what a Developer gets at
|
||||
// Without this, `grantsPostgres` on the role row is decorative: it would decide what a Developer gets at
|
||||
// creation and then never look again, so demoting one would leave their Postgres role, their databases
|
||||
// and a working password in their ~/.zshenv. A permission that survives its own revocation is worse than
|
||||
// not having gated it, because the UI then says something untrue.
|
||||
@@ -99,8 +111,13 @@ export const updateUserRoleHandler: Handler = async function (ctx) {
|
||||
// REVOKE BEFORE RECORDING, grant after. Dropping first means a failure aborts with the role unchanged, so
|
||||
// the account still says Developer and the whole thing can be retried. Doing it the other way round would
|
||||
// leave a Member holding database access with nothing in the row to indicate it.
|
||||
const had = rolePermitsDevTools(existing.role);
|
||||
const wants = rolePermitsDevTools(role as UserRole);
|
||||
//
|
||||
// Docker does NOT follow the role here — only Postgres does. That is a pre-existing gap (docker was
|
||||
// never wired into role change, only into initial provisioning in provision-os.ts), not something this
|
||||
// change introduces or was asked to close.
|
||||
const oldRole = await getRole(existing.role);
|
||||
const had = oldRole?.grantsPostgres ?? false;
|
||||
const wants = newRole.grantsPostgres;
|
||||
|
||||
if (had && !wants && existing.osUser) {
|
||||
const dropped = await dropPostgresRole(existing.osUser);
|
||||
@@ -120,7 +137,7 @@ export const updateUserRoleHandler: Handler = async function (ctx) {
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await updateUser(id, { role: role as UserRole });
|
||||
const updated = await updateUser(id, { role });
|
||||
if (!updated) throw errors.NOT_FOUND('User not found');
|
||||
|
||||
// Promotion. Non-fatal and reported, like every other provisioning step: the role change itself has
|
||||
|
||||
@@ -2,8 +2,7 @@ import type { MiddlewareHandler } from 'hono';
|
||||
import { createRouter } from '../../create-router';
|
||||
import * as errors from '@@/custom-errors';
|
||||
import { isSuperAdmin } from '../../super-admin';
|
||||
import { getAllRoleGrants, replaceRoleGrants, USER_ROLES } from 'officerdb';
|
||||
import type { UserRole } from 'officerdb';
|
||||
import { getAllRoleGrants, replaceRoleGrants, listRoles, getRole } from 'officerdb';
|
||||
import { PERMISSIONS, GRANTABLE_PERMISSIONS, PERMISSION_BY_KEY } from '../../permissions/registry';
|
||||
import { getEffectivePermissions, invalidateRoleGrants } from '../../permissions/authorize';
|
||||
import { permissionAvailability } from '../../app-store/availability';
|
||||
@@ -101,13 +100,17 @@ permissionAdminRouter.get('/permissions', ownerGate, async (ctx) => {
|
||||
hasPersonalWrites: !!c.personal?.length,
|
||||
});
|
||||
|
||||
const allRoles = await listRoles();
|
||||
|
||||
return ctx.json({
|
||||
// Only the grantable kinds are offered. `execution` and `admin` are deliberately absent: a UI that
|
||||
// shows a checkbox it will refuse to honour is worse than one that never offered it.
|
||||
permissions: GRANTABLE_PERMISSIONS.filter((c) => !unavailable.has(c.key)).map(describe),
|
||||
// Roles a grant may name. Super Admin is excluded: the owner bypasses this table entirely, and the
|
||||
// database refuses a row for that role.
|
||||
roles: USER_ROLES.filter((r) => r !== 'Super Admin'),
|
||||
// Roles a grant may name. Read from the `roles` table (docs/roles-and-teams.md phase 2 part 4) rather
|
||||
// than a hardcoded list, so a role created at runtime through /api/roles is grantable without a
|
||||
// deploy. Super Admin is excluded: the owner bypasses this table entirely, and the database refuses a
|
||||
// row for that role.
|
||||
roles: allRoles.map((r) => r.name).filter((r) => r !== 'Super Admin'),
|
||||
// The column is `permission` now too, so this is a straight pass-through again. It was briefly a
|
||||
// mapping — the wire said `permission` while the column still said `permission` — which is what let
|
||||
// the table be renamed without any client noticing.
|
||||
@@ -116,8 +119,11 @@ permissionAdminRouter.get('/permissions', ownerGate, async (ctx) => {
|
||||
});
|
||||
|
||||
permissionAdminRouter.put('/permissions/:role', ownerGate, async (ctx) => {
|
||||
const role = ctx.req.param('role') as UserRole;
|
||||
if (!USER_ROLES.includes(role)) throw errors.BAD_REQUEST(`Unknown role '${role}'`);
|
||||
const role = ctx.req.param('role');
|
||||
// The runtime replacement for what USER_ROLES used to guard at the type level (docs/roles-and-teams.md
|
||||
// phase 2 part 4): a role that is not a row in `roles` must never reach `replaceRoleGrants`, or the FK
|
||||
// on `role_permissions.role` throws a raw Postgres error instead of this message.
|
||||
if (!(await getRole(role))) throw errors.BAD_REQUEST(`Unknown role '${role}'`);
|
||||
if (role === 'Super Admin') throw errors.BAD_REQUEST('The owner is not governed by grants');
|
||||
|
||||
const body = ctx.get('body') as { grants?: unknown } | undefined;
|
||||
|
||||
@@ -5,7 +5,7 @@ import { checkOsAccount } from '@@/os-user-verify';
|
||||
import { provisionSshAccess } from '@@/os-user-ssh';
|
||||
import { seedShellConfig } from '@@/os-user-shell';
|
||||
import { provisionClaudeCli } from '@@/os-user-claude';
|
||||
import { provisionPostgresRole, rolePermitsDevTools } from '@@/os-user-postgres';
|
||||
import { provisionPostgresRole, roleDevGrants } from '@@/os-user-postgres';
|
||||
import { provisionRootlessDocker } from '@@/os-user-docker';
|
||||
import { provisionUserDirs } from '@@/data-path';
|
||||
|
||||
@@ -42,7 +42,7 @@ export async function provisionOsAccount(params: {
|
||||
userId: number;
|
||||
email: string;
|
||||
username: string;
|
||||
/** Decides whether they get a Postgres role. See `rolePermitsDevTools`. */
|
||||
/** Decides whether they get a Postgres role and/or a Docker daemon. See `roleDevGrants`. */
|
||||
role: UserRole;
|
||||
/** Inbound SSH key for `authorized_keys`. Already validated by the caller. */
|
||||
inboundKey?: string | null;
|
||||
@@ -92,10 +92,14 @@ export async function provisionOsAccount(params: {
|
||||
// cannot do it for them and must not try, because the alternative is lending them the owner's credential.
|
||||
const claude = await provisionClaudeCli({ email: params.email, osUser: account.osUser });
|
||||
|
||||
// A Postgres login role of the same name, with CREATEDB — for a Developer, and nobody else. What
|
||||
// replaced rootless Docker for the "let me run a database to develop against" case, and it inherits
|
||||
// that feature's role gate along with its purpose: `rolePermitsDevTools` is the one place that rule is
|
||||
// written down.
|
||||
// One read of the role row for both gates below, rather than one query per tool — `roleDevGrants`
|
||||
// returns both flags together for exactly this caller.
|
||||
const devGrants = await roleDevGrants(params.role);
|
||||
|
||||
// A Postgres login role of the same name, with CREATEDB — gated on `grantsPostgres`. What replaced
|
||||
// rootless Docker for the "let me run a database to develop against" case, and it inherits that
|
||||
// feature's role gate along with its purpose: `roleDevGrants` is the one place that rule is written
|
||||
// down, now as a row on the role rather than a name tested in code.
|
||||
//
|
||||
// Also the step that shuts PUBLIC out of the platform's own database — deliberately inside the function
|
||||
// that creates the role rather than in the setup script, so it cannot be skipped by an install that was
|
||||
@@ -103,7 +107,7 @@ export async function provisionOsAccount(params: {
|
||||
//
|
||||
// Null, not a skipped-but-ok result: "there is no database role because of who they are" and "the
|
||||
// database role worked" are different answers and the caller reports them differently.
|
||||
const postgres = rolePermitsDevTools(params.role)
|
||||
const postgres = devGrants.grantsPostgres
|
||||
? await provisionPostgresRole({
|
||||
email: params.email,
|
||||
osUser: account.osUser,
|
||||
@@ -112,14 +116,14 @@ export async function provisionOsAccount(params: {
|
||||
})
|
||||
: null;
|
||||
|
||||
// Their own rootless Docker daemon — for a Developer, and nobody else. Last, and the most tolerant of
|
||||
// failure: a host without the uidmap package or a kernel that will not do rootless still gets a perfectly
|
||||
// good account, minus containers.
|
||||
// Their own rootless Docker daemon — gated on `grantsDocker`. Last, and the most tolerant of failure: a
|
||||
// host without the uidmap package or a kernel that will not do rootless still gets a perfectly good
|
||||
// account, minus containers.
|
||||
//
|
||||
// Behind the role gate rather than unconditional. The cost is real and it is per member, not per install:
|
||||
// one daemon, one image cache and one subuid range each — see os-user-docker.ts. That is worth paying for
|
||||
// someone whose job is to build things here, and not for an account that just reads its dashboards.
|
||||
const docker = rolePermitsDevTools(params.role)
|
||||
const docker = devGrants.grantsDocker
|
||||
? await provisionRootlessDocker({
|
||||
osUser: account.osUser,
|
||||
uid: account.uid,
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { loadSkillsAcross } from './skills/load';
|
||||
import { loadAffordancesAcross } from './affordances/load';
|
||||
import { loadPluginEntries } from './notifications/catalogue';
|
||||
|
||||
/**
|
||||
* A plugin's capabilities are READ where they live rather than copied into `capabilities/`
|
||||
* (docs/plugin-capabilities.md). These pin the three properties that were not obvious.
|
||||
*
|
||||
* `capabilityRoots` itself is not tested here — it reads the real database and the real plugin
|
||||
* directory, so a test of it would assert whatever this machine happens to have installed. What is worth
|
||||
* pinning is what the loaders do with a list once they have one, and that is pure.
|
||||
*/
|
||||
|
||||
const seed = (files: Record<string, string>): string => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'zz-caps-'));
|
||||
for (const [rel, body] of Object.entries(files)) {
|
||||
const abs = join(dir, rel);
|
||||
mkdirSync(join(abs, '..'), { recursive: true });
|
||||
writeFileSync(abs, body);
|
||||
}
|
||||
return dir;
|
||||
};
|
||||
|
||||
const skill = (name: string, desc: string) => `---\nname: ${name}\ndescription: ${desc}\n---\n\nbody\n`;
|
||||
|
||||
describe('skills merge across sources', () => {
|
||||
test('a plugin directory contributes its skills', () => {
|
||||
const owner = seed({ 'mine/SKILL.md': skill('Mine', 'the owner wrote this') });
|
||||
const plugin = seed({ 'theirs/SKILL.md': skill('Theirs', 'a plugin shipped this') });
|
||||
try {
|
||||
const ids = loadSkillsAcross([owner, plugin]).map((s) => s.id).sort();
|
||||
expect(ids).toEqual(['mine', 'theirs']);
|
||||
} finally {
|
||||
rmSync(owner, { recursive: true, force: true });
|
||||
rmSync(plugin, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// Precedence, and the reason for it: a plugin must not be able to take over the meaning of a name its
|
||||
// owner is already using, just by picking the same directory name.
|
||||
test("the owner's own wins a name collision", () => {
|
||||
const owner = seed({ 'deploy/SKILL.md': skill('Deploy', 'the owner version') });
|
||||
const plugin = seed({ 'deploy/SKILL.md': skill('Deploy', 'the plugin version') });
|
||||
try {
|
||||
const found = loadSkillsAcross([owner, plugin]);
|
||||
expect(found).toHaveLength(1);
|
||||
expect(found[0]!.description).toBe('the owner version');
|
||||
} finally {
|
||||
rmSync(owner, { recursive: true, force: true });
|
||||
rmSync(plugin, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// Which is what makes disable mean disable: the caller simply stops passing that root.
|
||||
test('dropping a source drops its skills', () => {
|
||||
const owner = seed({ 'mine/SKILL.md': skill('Mine', 'kept') });
|
||||
const plugin = seed({ 'theirs/SKILL.md': skill('Theirs', 'gone when disabled') });
|
||||
try {
|
||||
expect(loadSkillsAcross([owner, plugin])).toHaveLength(2);
|
||||
expect(loadSkillsAcross([owner])).toHaveLength(1);
|
||||
} finally {
|
||||
rmSync(owner, { recursive: true, force: true });
|
||||
rmSync(plugin, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('a source that does not exist is skipped, not fatal', () => {
|
||||
const owner = seed({ 'mine/SKILL.md': skill('Mine', 'kept') });
|
||||
try {
|
||||
expect(loadSkillsAcross([owner, join(tmpdir(), 'zz-caps-nope')])).toHaveLength(1);
|
||||
} finally {
|
||||
rmSync(owner, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('affordances are not de-duplicated', () => {
|
||||
// Unlike skills: an affordance is guidance, several can be true at once, and two plugins describing the
|
||||
// same surface differently is information rather than a collision.
|
||||
test('both sources contribute even with the same file name', () => {
|
||||
const a = seed({ 'x/one.md': '---\nname: A\ndescription: from a\n---\nbody a\n' });
|
||||
const b = seed({ 'y/one.md': '---\nname: B\ndescription: from b\n---\nbody b\n' });
|
||||
try {
|
||||
expect(loadAffordancesAcross([a, b])).toHaveLength(2);
|
||||
} finally {
|
||||
rmSync(a, { recursive: true, force: true });
|
||||
rmSync(b, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('notification codes come from the manifest, not the path', () => {
|
||||
// The whole reason loadPluginEntries exists. Copied to capabilities/ the path carried
|
||||
// <publisher>/<appName>; read in place it carries nothing, so the identity has to travel separately.
|
||||
test('a plugin in its own directory still gets a namespaced code', () => {
|
||||
const dir = seed({ 'notifications/catalogue.yaml': "nowPlaying:\n title: 'Now playing'\n" });
|
||||
try {
|
||||
const entries = loadPluginEntries({ appName: 'music', dir, manifest: { publisher: 'officerdev' } });
|
||||
expect(Object.keys(entries)).toEqual(['officerdev/music:nowPlaying']);
|
||||
expect(entries['officerdev/music:nowPlaying']!.thread).toBe('officerdev/music');
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// The property that must survive the move: a plugin cannot claim the platform's voice by writing a
|
||||
// core-looking key into its own yaml.
|
||||
test('a yaml key cannot claim another namespace', () => {
|
||||
const dir = seed({ 'notifications/c.yaml': "officer/core:test:\n title: 'Nice try'\n" });
|
||||
try {
|
||||
const keys = Object.keys(loadPluginEntries({ appName: 'x', dir, manifest: { publisher: 'someone' } }));
|
||||
expect(keys).toEqual(['someone/x:officer/core:test']);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import { existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { itemsDir, type ItemType } from './data-path';
|
||||
import { snapshotPlugins } from './plugins/mount';
|
||||
|
||||
/**
|
||||
* Where capabilities are read from — the owner's own store, plus every installed and enabled plugin's.
|
||||
*
|
||||
* ── Why plugins are read in place rather than copied in ──
|
||||
*
|
||||
* `capabilities/` is a git repository, and the point of it is that anybody can fork it: core's tasks and
|
||||
* skills, plus their own on top. That only holds if the directory contains exactly two things, what core
|
||||
* ships and what its owner wrote. Publishing a plugin's files into it broke that — a fork accumulated
|
||||
* files it did not author, with nothing on disk saying which, so committing them carried another plugin's
|
||||
* work and not committing them left `git status` permanently dirty.
|
||||
*
|
||||
* Copying was wrong a second way that has nothing to do with git. A copy goes STALE: `plugins:update` is
|
||||
* a `git pull` in the plugin's directory, and a capability copied out of it keeps describing the previous
|
||||
* version until something republishes. And a copy survives `plugins:disable`, so a disabled plugin's
|
||||
* skill stayed in the agent's index — the plugin off, the agent still told it was there.
|
||||
*
|
||||
* Reading in place fixes all of that by construction, and needs no namespace to do it. Scoping the paths
|
||||
* would have made provenance READABLE; separate directories make it STRUCTURAL, which is the stronger
|
||||
* property and the cheaper one.
|
||||
*
|
||||
* See `docs/plugin-capabilities.md`.
|
||||
*
|
||||
* ── Enabled, not merely installed ──
|
||||
*
|
||||
* A disabled plugin contributes nothing. That is the whole meaning of disable — routes down, sidecar
|
||||
* stopped — and a capability that outlived it was the one piece that did not comply.
|
||||
*
|
||||
* ── Cost ──
|
||||
*
|
||||
* This reads the database, so it is async, and it must not be called per turn. Its callers resolve it
|
||||
* once per session (`skillsBlock`, `affordanceBlock`) or per request on surfaces that are already doing
|
||||
* database work. A missing directory is skipped rather than erroring: most plugins ship no capabilities
|
||||
* at all, and that is the ordinary case rather than a fault.
|
||||
*/
|
||||
export async function capabilityRoots(type: ItemType | 'affordances' | 'notifications'): Promise<string[]> {
|
||||
const roots = [itemsDir(type as ItemType)];
|
||||
|
||||
let states: Awaited<ReturnType<typeof snapshotPlugins>>['states'] = [];
|
||||
try {
|
||||
({ states } = await snapshotPlugins());
|
||||
} catch {
|
||||
// The owner's own capabilities still load. A database that cannot be read is a much louder problem
|
||||
// somewhere else, and losing the agent's whole skill index to it would only obscure that.
|
||||
return roots;
|
||||
}
|
||||
|
||||
for (const state of states) {
|
||||
if (!state.install?.enabled) continue;
|
||||
const dir = join(state.plugin.dir, type);
|
||||
if (existsSync(dir)) roots.push(dir);
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
|
||||
/**
|
||||
* The same list, for a caller that already holds a snapshot.
|
||||
*
|
||||
* Exists so a request doing several of these — the items rescan reads five types — pays for one database
|
||||
* read rather than five.
|
||||
*/
|
||||
export function capabilityRootsFrom(
|
||||
states: Awaited<ReturnType<typeof snapshotPlugins>>['states'],
|
||||
type: ItemType | 'affordances' | 'notifications',
|
||||
): string[] {
|
||||
const roots = [itemsDir(type as ItemType)];
|
||||
for (const state of states) {
|
||||
if (!state.install?.enabled) continue;
|
||||
const dir = join(state.plugin.dir, type);
|
||||
if (existsSync(dir)) roots.push(dir);
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
|
||||
/**
|
||||
* Installed and enabled plugins, in the shape the notification catalogue wants.
|
||||
*
|
||||
* Notifications are the one capability whose namespace cannot be read off a path once the file stays in
|
||||
* the plugin's own directory — see `notifications/catalogue.ts` → `loadPluginEntries`. So the source list
|
||||
* carries the identity with it.
|
||||
*/
|
||||
export async function pluginCatalogueSources(): Promise<
|
||||
{ appName: string; dir: string; manifest: { publisher: string } }[]
|
||||
> {
|
||||
try {
|
||||
const { states } = await snapshotPlugins();
|
||||
return states
|
||||
.filter((s) => s.install?.enabled)
|
||||
.map((s) => ({ appName: s.plugin.appName, dir: s.plugin.dir, manifest: { publisher: s.plugin.manifest.publisher } }));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
+59
-10
@@ -198,17 +198,27 @@ export const getEmailAttachmentCacheDir = (ownerEmail: string) =>
|
||||
join(getEmailAccountsDir(ownerEmail), 'attachment_cache');
|
||||
|
||||
/**
|
||||
* Every commander a user has shares this ONE directory as its cwd — deliberately not one directory per
|
||||
* commander. The first design was per-commander, and was dropped the same night it was proposed: a fresh
|
||||
* directory would have to exist before or exactly when a commander's first turn fires, and racing that
|
||||
* creation against the harness's own spawn is precisely the kind of timing hazard Claude Code's
|
||||
* directory-per-project convention was never built to be driven by. One shared, pre-existing directory
|
||||
* removes the race instead of handling it.
|
||||
* The root every commander's own directory lives under — `$DATA_PATH/<email>/commanders/<dirName>/`.
|
||||
*
|
||||
* **Not a shared cwd any more.** Two designs were tried and retracted before this one. First, one directory
|
||||
* per commander, dropped the night it was proposed: a fresh directory had to exist before or exactly when a
|
||||
* commander's first turn fired, and racing that creation against the harness's own spawn was the kind of
|
||||
* timing hazard Claude Code's directory-per-project convention was never built to be driven by. So it became
|
||||
* one shared directory for every commander a user has — which removed the race but made the directory the
|
||||
* commander's only identity, which is what made the ordering problem `docs/commander-directory.md` describes
|
||||
* inescapable. Then, briefly (2026-08-22), a commander chose an arbitrary directory anywhere in its owner's
|
||||
* tree, once identity moved to the `command_hq_sessions` row instead of the cwd.
|
||||
*
|
||||
* **One directory per commander again, and the race is gone for a different reason than either previous
|
||||
* attempt solved it.** The row (and so `dirName`) is created by `POST /command-hq/commanders` BEFORE any
|
||||
* turn spawns — the ordering fix is exactly what makes a fresh, empty, commander-named directory safe to
|
||||
* create ahead of time: nothing races its own creation, because creation happens first and spawning waits
|
||||
* on it. See `docs/commander-directory.md`.
|
||||
*
|
||||
* `DATA_PATH`-rooted even for the OWNER, which is a deliberate departure from ordinary `/chat` — an owner's
|
||||
* normal chat runs in `getOwnerHomeDir` (their real login home). Commanders use the managed directory
|
||||
* instead, on both sides of the account split, which is what keeps them out of the owner's own project
|
||||
* transcripts as surely as `command_hq_sessions` keeps them out of the `/chat` list. See `docs/command-hq.md`.
|
||||
* transcripts as surely as `command_hq_sessions` keeps them out of the `/chat` list.
|
||||
*
|
||||
* **A sibling of `home`, but reachable like `home` — not confined like every other sibling.** Every other
|
||||
* directory alongside a member's `home` (`attachments`, `email_accounts`, …) is 700 and owned by the
|
||||
@@ -216,11 +226,51 @@ export const getEmailAttachmentCacheDir = (ownerEmail: string) =>
|
||||
* wrong for a directory a member's own `claude` must `cd` into via `setpriv`. `confineUserTree`
|
||||
* (`os-user.ts`) chowns this one to the member the same way it chowns `home`. An account provisioned
|
||||
* BEFORE this existed will not have that chown until it is re-provisioned once — see `docs/command-hq.md`.
|
||||
*
|
||||
* That chown is recursive at provisioning time only — a subdirectory created for a NEW commander afterwards
|
||||
* is written by the platform process (the service user), not by the member, so it will not automatically
|
||||
* carry the member's ownership the way the root does. Not solved here; flagged in
|
||||
* `docs/commander-directory.md`'s implementation notes as a gap for a member-owned commander specifically,
|
||||
* not a regression this file introduces.
|
||||
*/
|
||||
export const getCommandersDir = (email: string) => join(DATA_PATH, email, 'commanders');
|
||||
|
||||
/**
|
||||
* Where a commander records what it spawned — one directory per commander, named by its session id.
|
||||
* One commander's own directory — `CLAUDE.md`, `state/`, and whatever it writes for itself. `dirName` is
|
||||
* `command_hq_sessions.dir_name`, derived from the row's own serial id at creation. See
|
||||
* `docs/commander-directory.md`.
|
||||
*/
|
||||
/**
|
||||
* One commander's own directory.
|
||||
*
|
||||
* **Guarded, because two callers `rm -rf` what this returns** — deleting a commander removes its directory,
|
||||
* and `dropSpawnedRecord` removes its `state/`. An empty `dirName` would resolve to the commanders ROOT and
|
||||
* take every commander the account has with it.
|
||||
*
|
||||
* That is not hypothetical arithmetic. `createCommander` inserts the row with `dirName: ''` and fixes it up
|
||||
* in the same statement pair, because the name is derived from a serial id that does not exist until the
|
||||
* insert has happened. The transaction means nothing else can observe that state — and a guard here means
|
||||
* it does not have to, which is the difference between safe and safe-so-far. A traversal is refused for the
|
||||
* same reason, cheaply, rather than reasoned about.
|
||||
*/
|
||||
export const getCommanderDir = (email: string, dirName: string): string => {
|
||||
if (!dirName || dirName.includes('/') || dirName === '.' || dirName === '..') {
|
||||
throw new Error(`refusing to resolve a commander directory for ${JSON.stringify(dirName)}`);
|
||||
}
|
||||
return join(getCommandersDir(email), dirName);
|
||||
};
|
||||
|
||||
/**
|
||||
* Where a commander records what it spawned and its loop's own pid — `state/` inside its own directory,
|
||||
* keyed by `dirName` rather than a transcript id.
|
||||
*
|
||||
* Used to be a directory of its own (`commander-state/<transcript id>/`, a sibling of the transcripts
|
||||
* rather than the commander), because there was nothing else to key it on before a commander had an
|
||||
* identity independent of its transcript. Collapsed in here 2026-08-24: `state/` is INSTANCE data (what a
|
||||
* commander has handled, what it spawned, its loop's pid) as opposed to `CLAUDE.md` and whatever else the
|
||||
* commander writes beside it, which is DEFINITION — the split `docs/commander-directory.md` calls
|
||||
* structural, kept as a subdirectory rather than a convention precisely so it is enforced by a path rather
|
||||
* than remembered by whoever writes into it.
|
||||
*
|
||||
* Beside the transcripts rather than inside them: a transcript is the harness's file and the platform does
|
||||
* not write into it. `spawned` is a plain newline-delimited list of session ids, appended by the loop the
|
||||
@@ -231,8 +281,7 @@ export const getCommandersDir = (email: string) => join(DATA_PATH, email, 'comma
|
||||
* That makes this record load-bearing rather than convenient. Without it a commander cannot be deleted
|
||||
* completely, its children cannot be followed, and its cost cannot be totalled.
|
||||
*/
|
||||
export const getCommanderStateDir = (email: string, sessionId: string) =>
|
||||
join(DATA_PATH, email, 'commander-state', sessionId);
|
||||
export const getCommanderStateDir = (email: string, dirName: string) => join(getCommanderDir(email, dirName), 'state');
|
||||
|
||||
/**
|
||||
* Which mail account's store to use, found on DISK rather than by asking the database.
|
||||
|
||||
+2
-2
@@ -9,6 +9,7 @@ import { themesRouter } from './api/themes/themes';
|
||||
import { themeFavoritesRouter } from './api/themes/favorites';
|
||||
import { waitlistRouter } from './api/waitlist/waitlist';
|
||||
import { usersRouter } from './api/users/users-router';
|
||||
import { rolesRouter } from './api/roles/router';
|
||||
import { apiKeysRouter } from './api/api-keys/router';
|
||||
import { skillsRouter } from './api/skills/skills';
|
||||
import { tasksRouter } from './api/tasks/tasks';
|
||||
@@ -26,7 +27,6 @@ import { pluginsRouter } from './api/plugins/router';
|
||||
import { isPluginPublicPath, pluginForProtocolHeaders } from './plugins/public-paths';
|
||||
import { agentHandoffRouter } from './api/agent-handoff/router';
|
||||
import { terminalRouter } from './api/terminal/sidecar-server';
|
||||
import { minesweeperRouter } from './api/minesweeper/router';
|
||||
import { sendoffRouter } from './api/sendoff/router';
|
||||
import { subAgentsRouter } from './api/sub-agents/router';
|
||||
import { caldavRouter } from './api/dav/sidecar-server';
|
||||
@@ -129,6 +129,7 @@ const PROTECTED_MOUNTS: [prefix: string, router: ReturnType<typeof createRouter>
|
||||
// Protected, unlike `/themes` beside it — see api/themes/favorites.ts for why they are separate mounts.
|
||||
['/theme-favorites', themeFavoritesRouter],
|
||||
['/users', usersRouter],
|
||||
['/roles', rolesRouter],
|
||||
['/skills', skillsRouter],
|
||||
['/tasks', tasksRouter],
|
||||
['/agents', agentsRouter],
|
||||
@@ -142,7 +143,6 @@ const PROTECTED_MOUNTS: [prefix: string, router: ReturnType<typeof createRouter>
|
||||
['/dashboards', dashboardsRouter],
|
||||
['/file-browser', fileBrowserRouter],
|
||||
['/terminal', terminalRouter],
|
||||
['/minesweeper', minesweeperRouter],
|
||||
['/sendoff', sendoffRouter],
|
||||
['/sub-agents', subAgentsRouter],
|
||||
// ['/memos', memosRouter], // plugin — switched off 2026-08-13
|
||||
|
||||
@@ -44,6 +44,8 @@ export type NotificationEntry = {
|
||||
thread?: string;
|
||||
};
|
||||
|
||||
export type PluginCatalogueSource = { appName: string; dir: string; manifest: { publisher: string } };
|
||||
|
||||
export type Catalogue = {
|
||||
/**
|
||||
* Changes whenever the aggregate does. A CONTENT HASH, not a counter.
|
||||
@@ -195,6 +197,58 @@ export function loadPublishedEntries(root: string = NOTIFICATIONS_DIR): Record<s
|
||||
return entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* A single plugin's catalogue, read from its OWN directory.
|
||||
*
|
||||
* Separate from `loadPublishedEntries` because the namespace has to come from somewhere else. That
|
||||
* function derives `<publisher>/<appName>` from the path, which worked while installing COPIED a
|
||||
* plugin's yaml to `capabilities/notifications/<publisher>/<appName>/`. Read in place the path is just
|
||||
* `notifications/catalogue.yaml` and carries no identity at all — so the codes would silently vanish,
|
||||
* which is the failure this exists to prevent.
|
||||
*
|
||||
* The manifest supplies it instead, and that is strictly better provenance: `publisher` and `appName` are
|
||||
* validated at discovery, where the path was only ever a directory somebody chose. The property that
|
||||
* matters is unchanged — the platform imposes the namespace and the yaml cannot claim one. Writing
|
||||
* `officer/core:test` as a key still produces `<you>/<yourplugin>:officer/core:test`.
|
||||
*/
|
||||
export function loadPluginEntries(plugin: {
|
||||
appName: string;
|
||||
dir: string;
|
||||
manifest: { publisher: string };
|
||||
}): Record<string, NotificationEntry> {
|
||||
const root = join(plugin.dir, 'notifications');
|
||||
let files: string[];
|
||||
try {
|
||||
files = (readdirSync(root, { recursive: true }) as string[]).filter((f) => /\.ya?ml$/.test(f));
|
||||
} catch {
|
||||
return {}; // shipping none is the ordinary case
|
||||
}
|
||||
|
||||
const { publisher } = plugin.manifest;
|
||||
const entries: Record<string, NotificationEntry> = {};
|
||||
|
||||
for (const rel of files.sort()) {
|
||||
let doc: unknown;
|
||||
try {
|
||||
doc = YAML.parse(readFileSync(join(root, rel), 'utf8'));
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (doc === null || typeof doc !== 'object') continue;
|
||||
|
||||
for (const [key, value] of Object.entries(doc as Record<string, unknown>)) {
|
||||
const entry = coerce(value);
|
||||
if (!entry) continue;
|
||||
entries[`${publisher}/${plugin.appName}:${key}`] = {
|
||||
...entry,
|
||||
thread: entry.thread ?? `${publisher}/${plugin.appName}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole catalogue: the platform's own plus everything installed plugins publish.
|
||||
*
|
||||
@@ -202,8 +256,11 @@ export function loadPublishedEntries(root: string = NOTIFICATIONS_DIR): Record<s
|
||||
* from its install path — so this only matters if the store is edited by hand, and the platform's own
|
||||
* notifications are the ones that must keep working when it is.
|
||||
*/
|
||||
export function loadCatalogue(root: string = NOTIFICATIONS_DIR): Catalogue {
|
||||
const entries = { ...loadPublishedEntries(root), ...CORE_ENTRIES };
|
||||
export function loadCatalogue(root: string = NOTIFICATIONS_DIR, plugins: PluginCatalogueSource[] = []): Catalogue {
|
||||
// Plugin entries first so the owner's own store, then core, still win a collision — the same precedence
|
||||
// as before, with plugins now read from their own directories rather than copies.
|
||||
const fromPlugins = Object.assign({}, ...plugins.map(loadPluginEntries));
|
||||
const entries = { ...fromPlugins, ...loadPublishedEntries(root), ...CORE_ENTRIES };
|
||||
|
||||
// Sorted so the hash depends on content, not on the order the filesystem happened to return.
|
||||
const canonical = JSON.stringify(
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
import { cp, readdir, rm } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { NOTIFICATIONS_DIR } from './catalogue';
|
||||
import type { DiscoveredPlugin } from '../plugins/manifest';
|
||||
|
||||
// Copying a plugin's notification catalogue into the store the sidecar reads.
|
||||
//
|
||||
// Deliberately the same shape as `publishAffordances` — a directory in the plugin repository, copied to a
|
||||
// platform-managed place on install, removed on uninstall. Two mechanisms for "the plugin ships some
|
||||
// files" would drift, and that one is already understood.
|
||||
//
|
||||
// The destination namespace is what makes codes safe: `<publisher>/<appName>/`, mirroring `mountPrefix`,
|
||||
// so a plugin's codes are stamped with its provenance by WHERE IT LANDS rather than by anything it
|
||||
// declares. See `catalogue.ts`.
|
||||
|
||||
/** Where a plugin's catalogue lands. `officerdev/music/`, beside `officer/core`'s built-in entries. */
|
||||
export const notificationDirFor = (plugin: DiscoveredPlugin): string =>
|
||||
join(NOTIFICATIONS_DIR, plugin.manifest.publisher, plugin.appName);
|
||||
|
||||
/** What the plugin ships, if anything. */
|
||||
export const notificationSourceFor = (plugin: DiscoveredPlugin): string => join(plugin.dir, 'notifications');
|
||||
|
||||
/**
|
||||
* Publish a plugin's catalogue. Returns the number of files, or 0 when it ships none — the ordinary case.
|
||||
*
|
||||
* Idempotent by overwriting, so re-running an install republishes rather than erroring. It does NOT remove
|
||||
* files the plugin has since dropped; that is uninstall's job, and deleting on every install would make a
|
||||
* half-finished copy destructive.
|
||||
*/
|
||||
export async function publishNotifications(plugin: DiscoveredPlugin): Promise<number> {
|
||||
const source = notificationSourceFor(plugin);
|
||||
if (!existsSync(source)) return 0;
|
||||
|
||||
const dest = notificationDirFor(plugin);
|
||||
await cp(source, dest, { recursive: true, force: true });
|
||||
|
||||
try {
|
||||
return ((await readdir(dest, { recursive: true })) as string[]).filter((f) => /\.ya?ml$/.test(f)).length;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop a plugin's catalogue.
|
||||
*
|
||||
* Removes the plugin's own directory, never the publisher's: `officerdev/` holds every first-party
|
||||
* plugin's, and taking it out because one was uninstalled would strip the rest.
|
||||
*
|
||||
* The codes then stop resolving, which is a defined state rather than a broken one — a device holding a
|
||||
* queued push for a departed plugin renders the unknown-code fallback. See docs/push-notifications.md.
|
||||
*
|
||||
* Safe to call when nothing was ever published.
|
||||
*/
|
||||
export async function unpublishNotifications(plugin: DiscoveredPlugin): Promise<void> {
|
||||
await rm(notificationDirFor(plugin), { recursive: true, force: true });
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import { createRole } from 'officerdb';
|
||||
import { db } from 'officerdb/db';
|
||||
import { roles } from 'officerdb/schema';
|
||||
import { like } from 'drizzle-orm';
|
||||
import { roleDevGrants } from './os-user-postgres';
|
||||
|
||||
/*
|
||||
`roleDevGrants` is the replacement for the hardcoded `role === 'Developer'` gate — see the comment above
|
||||
it. What matters here is that it reads the ROW, not the name: a role called something else entirely gets
|
||||
dev tooling if and only if its `grantsPostgres`/`grantsDocker` columns say so, and a role that does not
|
||||
exist reads as neither rather than throwing.
|
||||
*/
|
||||
|
||||
const PREFIX = 'zz-test-devgrants-';
|
||||
const name = (suffix: string) => `${PREFIX}${suffix}`;
|
||||
|
||||
afterEach(async () => {
|
||||
await db.delete(roles).where(like(roles.name, `${PREFIX}%`));
|
||||
});
|
||||
|
||||
describe('roleDevGrants', () => {
|
||||
test('reads both flags off a role that is not named Developer', async () => {
|
||||
const created = await createRole({ name: name('custom-dev'), grantsPostgres: true, grantsDocker: true });
|
||||
expect(created.ok).toBe(true);
|
||||
|
||||
expect(await roleDevGrants(name('custom-dev'))).toEqual({ grantsPostgres: true, grantsDocker: true });
|
||||
});
|
||||
|
||||
test('a role with grantsDocker false does not get docker, even with grantsPostgres true', async () => {
|
||||
const created = await createRole({ name: name('pg-only'), grantsPostgres: true, grantsDocker: false });
|
||||
expect(created.ok).toBe(true);
|
||||
|
||||
expect(await roleDevGrants(name('pg-only'))).toEqual({ grantsPostgres: true, grantsDocker: false });
|
||||
});
|
||||
|
||||
test('a role granted neither gets neither', async () => {
|
||||
const created = await createRole({ name: name('plain') });
|
||||
expect(created.ok).toBe(true);
|
||||
|
||||
expect(await roleDevGrants(name('plain'))).toEqual({ grantsPostgres: false, grantsDocker: false });
|
||||
});
|
||||
|
||||
test('a role that does not exist reads as neither, matching "absence denies" rather than throwing', async () => {
|
||||
await expect(roleDevGrants(name('never-created'))).resolves.toEqual({
|
||||
grantsPostgres: false,
|
||||
grantsDocker: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,10 @@
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import type { UserRole } from 'officerdb';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import postgres from 'postgres';
|
||||
import { db } from 'officerdb/db';
|
||||
import { getRole } from 'officerdb';
|
||||
import { osUserHome, runAs } from './os-user';
|
||||
|
||||
// A Postgres login role for a member, named the same as their Linux account.
|
||||
@@ -66,20 +66,34 @@ import { osUserHome, runAs } from './os-user';
|
||||
// OWNER TO, CREATE OR REPLACE over the top, and `PGOPTIONS=-c event_triggers=off` (that GUC is superuser
|
||||
// only). A superuser can still use it, which is the recovery path if this ever locks something out.
|
||||
|
||||
/** Which dev tools a role's accounts get, read off the role row. See `roleDevGrants` for the reasoning. */
|
||||
export type DevToolGrants = { grantsPostgres: boolean; grantsDocker: boolean };
|
||||
|
||||
/**
|
||||
* Which platform roles get a database role at all.
|
||||
* Which platform roles get a database role, and which get a rootless Docker daemon — read from the
|
||||
* `roles` table (`grantsPostgres`, `grantsDocker`) instead of tested by name.
|
||||
*
|
||||
* `Developer` only. This is the gate rootless Docker was under before it was disabled, inherited by the
|
||||
* thing that replaced it rather than invented for it — a member who was not trusted to run containers is
|
||||
* not thereby trusted to run databases.
|
||||
* Historically this was `role === 'Developer'`, a single hardcoded check that gated both. Docker was the
|
||||
* original gate — Postgres is what replaced rootless Docker per member (disabled 2026-08-13), and it
|
||||
* inherited Docker's role gate rather than being given a new one: a member who was not trusted to run
|
||||
* containers is not thereby trusted to run databases. That history is why both flags still default to the
|
||||
* same seed value for `Developer` (`scripts/seed-roles.ts`), even though the row now lets them diverge for
|
||||
* a custom role.
|
||||
*
|
||||
* `Admin` is deliberately NOT included. Administering the platform is not developing on it, and the two
|
||||
* are separate roles precisely so they can be held separately. Add it here if that turns out to be wrong;
|
||||
* do not add it at a call site.
|
||||
* `Admin` is deliberately seeded with both flags false. Administering the platform is not developing on
|
||||
* it, and the two are separate roles precisely so they can be held separately — see the seed for where
|
||||
* that is now decided, since there is no longer a call site to add it at.
|
||||
*
|
||||
* The owner never reaches this — they run as the service account and already have the cluster.
|
||||
* The owner never reaches this — they run as the service account and already have the cluster and a
|
||||
* regular Docker install.
|
||||
*
|
||||
* A role that no longer exists (renamed out from under a stale reference, or deleted) reads as neither
|
||||
* grant, matching "absence denies" everywhere else in this system rather than throwing.
|
||||
*/
|
||||
export const rolePermitsDevTools = (role: UserRole): boolean => role === 'Developer';
|
||||
export async function roleDevGrants(role: string): Promise<DevToolGrants> {
|
||||
const row = await getRole(role);
|
||||
return { grantsPostgres: row?.grantsPostgres ?? false, grantsDocker: row?.grantsDocker ?? false };
|
||||
}
|
||||
|
||||
/** Legal Linux/Postgres account name. Identical to `validateUsername`, restated because this one reaches SQL. */
|
||||
const ROLE_NAME_RE = /^[a-zA-Z0-9._-]{2,32}$/;
|
||||
|
||||
@@ -23,7 +23,7 @@ import { assertPermissionTotality, isExemptApiPath } from './totality';
|
||||
// switched off 2026-08-13. It was listed here until 2026-08-18 and made this file's own totality tests
|
||||
// fail for five days — the mirror claiming a provider the server does not serve, which is precisely the
|
||||
// drift the comment above warns about, caught by the check and then ignored because it was "the baseline".
|
||||
const REAL_WS = ['terminal', 'chat', 'chat-notify', 'task-runner', 'pipeline', 'sidecar', 'minesweeper'];
|
||||
const REAL_WS = ['terminal', 'chat', 'chat-notify', 'task-runner', 'pipeline', 'sidecar'];
|
||||
const realApi = () => [...new Set(PERMISSIONS.flatMap((c) => c.api))];
|
||||
const surface = () => ({
|
||||
apiPrefixes: [...realApi(), '/auth', '/landing-page-data', '/waitlist', '/sidecar'],
|
||||
|
||||
@@ -328,25 +328,6 @@ const CORE_REGISTRY: Permission[] = [
|
||||
ws: ['task-runner', 'pipeline'],
|
||||
routes: ['/jobs'],
|
||||
},
|
||||
// Minesweeper X, on a virtual display, streamed to the browser over RFB.
|
||||
//
|
||||
// `execution` rather than `app`, and the reason is not that a game is dangerous. The wine process runs
|
||||
// as the SERVER OWNER on the owner's display — so its File and Extras menus are file dialogs with the
|
||||
// owner's home behind them, and "granted Minesweeper" would quietly mean "granted a file picker as the
|
||||
// owner". `plugins/desktop` reached the same conclusion from the same premise.
|
||||
//
|
||||
// There is a second reason to keep it here for now: one display and one game means two viewers share a
|
||||
// board and fight over the mouse. Per-viewer sessions would be a display and a wine prefix each, which
|
||||
// is cheap for this but is a design decision rather than an implementation detail.
|
||||
{
|
||||
key: 'minesweeper',
|
||||
label: 'Minesweeper',
|
||||
description: 'A Windows Minesweeper session on a virtual display, as the server owner',
|
||||
kind: 'execution',
|
||||
api: ['/minesweeper'],
|
||||
ws: ['minesweeper'],
|
||||
routes: ['/minesweeper'],
|
||||
},
|
||||
/*
|
||||
SendOff is `app`, not `confined`, and the distinction is the point.
|
||||
|
||||
@@ -461,6 +442,22 @@ const CORE_REGISTRY: Permission[] = [
|
||||
// `headscale` lived here until 2026-08-15, when it left with the rest of offscale. A plugin declares
|
||||
// its own permissions in its manifest and they are registered at install — see plugins/mount.ts. The
|
||||
// platform no longer knows this permission exists, which is the entire point.
|
||||
{
|
||||
key: 'roles',
|
||||
label: 'Roles',
|
||||
description: 'Create, rename and delete the roles this server grants',
|
||||
// Admin, and for the same reason `user-admin` beside it is: a role is not a member's own data, it is
|
||||
// policy that governs every account holding it, so there is no level of "read" that makes creating or
|
||||
// deleting one a feature to grant. `registry.test.ts` asserts admin can never be handed out — this
|
||||
// entry is what makes that assertion mean something for roles specifically.
|
||||
//
|
||||
// Separate from `user-admin` rather than folded into it: that permission's `/users` is the account
|
||||
// list and the grants a role holds (`/users/permissions`); this one is the roles TABLE itself — the
|
||||
// name, the description, whether it is system-named. Two routers, two questions, so a boot-time check
|
||||
// that a mounted prefix has exactly one owner stays meaningful for both.
|
||||
kind: 'admin',
|
||||
api: ['/roles'],
|
||||
},
|
||||
];
|
||||
|
||||
// ── Derived lookups ───────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -54,6 +54,12 @@ const EXEMPT_WS_PROVIDERS: Record<string, string> = {
|
||||
// same cookie the HTTP side issues after verifying the OWNER's token — so it carries proof of the owner
|
||||
// without carrying an account through `upgradeWs`. Refused outright without that cookie.
|
||||
'dev-proxy': 'dev server HMR relay; gated by the owner-only cookie the dev proxy issues',
|
||||
// One relay serving every plugin's socket, so a single registry entry here would be a HOLE: granting one
|
||||
// plugin would grant them all. `upgradePluginWs` therefore asks the PLUGIN's own capability, as if the
|
||||
// socket were a GET against its HTTP prefix — which means the decision is already in the registry, just
|
||||
// not under this provider's name. Exempt because the check is per-request and per-plugin, never because
|
||||
// it is absent.
|
||||
'plugin-ws': "relayed per plugin; authorised by that plugin's own capability in upgradePluginWs",
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { listRoles, setRoleGrant } from 'officerdb';
|
||||
import { invalidateRoleGrants } from '../permissions/authorize';
|
||||
import type { DiscoveredPlugin } from './manifest';
|
||||
|
||||
/**
|
||||
* Installing a plugin grants it to every role.
|
||||
*
|
||||
* ── The policy, and why it changed ──
|
||||
*
|
||||
* Until 2026-08-24 a newly installed plugin reached nobody. Every grant had to be added by hand afterwards,
|
||||
* per role, through the permissions screen — and the owner was the only one who could do it. The defensive
|
||||
* default made sense while the permission model was being built and nobody had members; it stopped making
|
||||
* sense once installing a plugin became a deliberate administrative act. A system administrator installing
|
||||
* something has already decided the server should have it. Requiring a second decision, in a different
|
||||
* screen, to make it reachable is a step whose only reliable outcome is that it gets forgotten and the
|
||||
* plugin appears broken.
|
||||
*
|
||||
* So: install grants, the owner revokes what should not be there. `uninstallPlugin` already removes those
|
||||
* grants, so the pair is symmetric and reinstalling means granting again — which is the honest behaviour.
|
||||
*
|
||||
* ── What did NOT change, and this is the important part ──
|
||||
*
|
||||
* **"Absence denies" is still the only rule in the resolver.** This writes ROWS. It does not teach
|
||||
* `authorize.ts` that a missing grant means yes, which would have been the other way to get this behaviour
|
||||
* and is the version that bites later: every capability with no row for any reason — a failed write, a
|
||||
* half-finished install, a bug — would become permitted, and nothing would look wrong.
|
||||
*
|
||||
* The blast radius is also only ever a plugin's own keys. `core` is granted to everyone by definition,
|
||||
* `execution` and `admin` are never grantable at any level, and `confined` still resolves to nothing for an
|
||||
* account with no Linux user. None of that is reachable from here.
|
||||
*
|
||||
* ── Why the owner's role is skipped ──
|
||||
*
|
||||
* Not politeness: `ck_role_permissions_not_owner` is a CHECK constraint that refuses `Super Admin` in that
|
||||
* table outright, so including it would fail the whole insert rather than grant anything. The owner
|
||||
* bypasses every permission check before grants are consulted, so the row would be inert regardless — and
|
||||
* that schema comment says why an inert row is worse than none: somebody eventually revokes one and expects
|
||||
* it to do something.
|
||||
*
|
||||
* The name is a literal here because it is a literal in the constraint. There is no constant for it: the
|
||||
* owner is an IDENTITY (`OWNER_USER_ID`) rather than a role, and this string is the one place the old
|
||||
* role-shaped notion of ownership still survives.
|
||||
*/
|
||||
const OWNER_ROLE_NAME = 'Super Admin';
|
||||
|
||||
export type GrantOnInstallResult = { granted: number; roles: string[] };
|
||||
|
||||
export async function grantPluginToAllRoles(plugin: DiscoveredPlugin): Promise<GrantOnInstallResult> {
|
||||
const keys = plugin.manifest.permissions.map((p) => p.key);
|
||||
if (!keys.length) return { granted: 0, roles: [] };
|
||||
|
||||
const roles = (await listRoles()).map((r) => r.name).filter((name) => name !== OWNER_ROLE_NAME);
|
||||
if (!roles.length) return { granted: 0, roles: [] };
|
||||
|
||||
let granted = 0;
|
||||
for (const role of roles) {
|
||||
for (const key of keys) {
|
||||
// `write`, not `read`. "Full access unless revoked" is the policy, and a read-only default would be a
|
||||
// third state nobody asked for — the owner would still have to visit the screen to make the plugin
|
||||
// usable, which is the step this exists to remove.
|
||||
await setRoleGrant(role, key, 'write');
|
||||
granted += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// The cache is keyed on role and its whole invalidation contract is this call. Without it the grants are
|
||||
// in the database and invisible until something else happens to invalidate — which would present as the
|
||||
// plugin still being unreachable right after an install that said it granted access.
|
||||
invalidateRoleGrants();
|
||||
|
||||
return { granted, roles };
|
||||
}
|
||||
|
||||
/**
|
||||
* What a role created at runtime starts with.
|
||||
*
|
||||
* The mirror of the above, and it closes a gap that predates the policy change. `DEFAULT_ROLE_PERMISSIONS`
|
||||
* — the `confined` baseline every seeded role gets — was only ever applied in `auth/bootstrap.ts`, so a
|
||||
* role created through `/api/roles` began with NOTHING. Not the plugins, not even chat, files and terminal.
|
||||
* A new "Support" role could reach less than Member and nothing said why.
|
||||
*
|
||||
* That was survivable while the default was deny-everything and grants were expected to be added by hand.
|
||||
* It stops being survivable now: leaving it would mean the new policy holds for roles that existed when a
|
||||
* plugin was installed and not for any created afterwards, which is the kind of half-applied rule that
|
||||
* reads as a bug in whichever half somebody meets first.
|
||||
*
|
||||
* So a new role starts where Member does — the confined baseline plus every installed plugin — and the
|
||||
* owner takes away what should not be there. Same direction as everything else here.
|
||||
*/
|
||||
export async function seedGrantsForNewRole(role: string): Promise<number> {
|
||||
if (role === OWNER_ROLE_NAME) return 0;
|
||||
|
||||
const { snapshotPlugins } = await import('./mount');
|
||||
const { DEFAULT_ROLE_PERMISSIONS } = await import('../permissions/registry');
|
||||
|
||||
const keys = new Set<string>(DEFAULT_ROLE_PERMISSIONS);
|
||||
try {
|
||||
const { states } = await snapshotPlugins();
|
||||
for (const state of states) {
|
||||
if (!state.install?.enabled) continue;
|
||||
for (const p of state.plugin.manifest.permissions) keys.add(p.key);
|
||||
}
|
||||
} catch {
|
||||
// The baseline still lands. A plugin list that cannot be read is a louder problem elsewhere, and
|
||||
// failing the whole seed over it would leave the role with nothing — the state this exists to prevent.
|
||||
}
|
||||
|
||||
for (const key of keys) await setRoleGrant(role, key, 'write');
|
||||
invalidateRoleGrants(role);
|
||||
return keys.size;
|
||||
}
|
||||
@@ -16,10 +16,8 @@ import { addPluginToEcosystem, pluginProcessName, removePluginFromEcosystem } fr
|
||||
import { discoverPlugins } from './discover';
|
||||
import { fetchPlugin } from './fetch';
|
||||
import { revokeGrantsForPermissions } from 'officerdb';
|
||||
import { grantPluginToAllRoles } from './grant-on-install';
|
||||
import { publishAssets, unpublishAssets } from '../app-store/assets';
|
||||
import { publishAffordances, unpublishAffordances } from '../affordances/publish';
|
||||
import { publishNotifications, unpublishNotifications } from '../notifications/publish';
|
||||
import { publishSkills, unpublishSkills } from '../skills/publish';
|
||||
import { refreshPluginMounts, snapshotPlugins } from './mount';
|
||||
import { publishExtension, unpublishExtension, extensionFileName } from './zip';
|
||||
import { generatePluginSchemas, pushPluginSchema } from './schema';
|
||||
@@ -241,7 +239,7 @@ export async function installPlugin(
|
||||
await step(steps, onStep, `dependencies: installing ${deps.packagesToInstall.join(', ')} with ${deps.manager}`);
|
||||
}
|
||||
const installed = deps.packagesToInstall.length
|
||||
? await installDependencies(deps.manager!, deps.packagesToInstall)
|
||||
? await installDependencies(deps.manager!, deps.packagesToInstall, deps.architectures)
|
||||
: { ok: true as const, installed: [], output: '' };
|
||||
if (!installed.ok) {
|
||||
return {
|
||||
@@ -402,40 +400,19 @@ export async function installPlugin(
|
||||
await step(steps, onStep, `extension: published to /plugins/${appName}/${file}`);
|
||||
}
|
||||
|
||||
// What this plugin needs an AGENT to know about itself. A plugin is a new surface arriving at
|
||||
// runtime, and nothing else carries that across: install gitea and no agent learns that `tea` is
|
||||
// authenticated unless the plugin says so. Same shape as the assets above — a directory in the
|
||||
// repository, copied to a platform-managed place.
|
||||
// GRANT IT TO EVERYONE. Installing a plugin is a deliberate administrative act, and requiring a second
|
||||
// decision in a different screen to make it reachable has one reliable outcome: it gets forgotten and
|
||||
// the plugin looks broken. The owner revokes what should not be there.
|
||||
//
|
||||
// Not fatal. An affordance that failed to copy costs an agent some knowledge; refusing the install
|
||||
// over it would cost the owner the plugin.
|
||||
// Rows, not a rule change — authorize.ts still denies on absence. See grant-on-install.ts.
|
||||
try {
|
||||
const published = await publishAffordances(plugin);
|
||||
if (published) {
|
||||
await step(steps, onStep, `affordances: ${published} published to capabilities/affordances/`);
|
||||
}
|
||||
const { granted, roles } = await grantPluginToAllRoles(plugin);
|
||||
if (granted) await step(steps, onStep, `access: granted to ${roles.join(', ')}`);
|
||||
} catch (err) {
|
||||
await step(steps, onStep, `affordances: NOT published — ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
|
||||
// Same bargain for the notification catalogue: a plugin that cannot publish its codes still installs,
|
||||
// and its pushes render the unknown-code fallback on the device rather than nothing at all.
|
||||
try {
|
||||
const published = await publishNotifications(plugin);
|
||||
if (published) {
|
||||
await step(steps, onStep, `notifications: ${published} published to capabilities/notifications/`);
|
||||
}
|
||||
} catch (err) {
|
||||
await step(steps, onStep, `notifications: NOT published — ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
|
||||
// Same bargain again: a plugin that cannot publish its skills still installs. The agent simply does
|
||||
// not learn the reference exists, which is the state every plugin was in before this.
|
||||
try {
|
||||
const published = await publishSkills(plugin);
|
||||
if (published) await step(steps, onStep, `skills: ${published} published to capabilities/skills/`);
|
||||
} catch (err) {
|
||||
await step(steps, onStep, `skills: NOT published — ${err instanceof Error ? err.message : err}`);
|
||||
// Not fatal, and said out loud rather than swallowed: the plugin is installed and the owner can reach
|
||||
// it, so the grants can be added by hand. Silently installing something nobody can reach is the exact
|
||||
// failure this change removes, so it must not become a silent failure of its own.
|
||||
await step(steps, onStep, `access: NOT granted — ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
|
||||
// The tile, for the person who just installed it. `plugin.web` because `pluginDockManifests` only
|
||||
@@ -535,12 +512,13 @@ export async function uninstallPlugin(appName: string, onStep?: OnStep, onLog?:
|
||||
// Same reasoning: a copy, with the original still in the plugin's directory. Removed so an agent stops
|
||||
// being told about a surface that is no longer there — an affordance for an uninstalled plugin is
|
||||
// worse than none, because it reads as true.
|
||||
if (plugin) await unpublishAffordances(plugin).catch(() => {});
|
||||
// And its notification codes, which stop resolving rather than resolving to something stale. A device
|
||||
// still holding a queued push renders the unknown-code fallback, which is a defined state.
|
||||
if (plugin) await unpublishNotifications(plugin).catch(() => {});
|
||||
if (plugin) await unpublishSkills(plugin).catch(() => {});
|
||||
await step(steps, onStep, 'tables and data: untouched (published assets, affordances and notifications removed)');
|
||||
await step(
|
||||
steps,
|
||||
onStep,
|
||||
'tables and data: untouched (published assets, affordances, notifications, skills and tasks removed)',
|
||||
);
|
||||
return { ok: true, appName, steps };
|
||||
}
|
||||
|
||||
|
||||
@@ -105,7 +105,7 @@ export type OsDependency = {
|
||||
* which for a binary that is present anyway is correct and silent, and otherwise becomes an instruction
|
||||
* to the owner rather than a guess.
|
||||
*/
|
||||
packages: Partial<Record<PackageManager, string>>;
|
||||
packages: Partial<Record<PackageManager, string | string[]>>;
|
||||
/**
|
||||
* WHO has to be able to run this.
|
||||
*
|
||||
@@ -125,6 +125,33 @@ export type OsDependency = {
|
||||
* See `docs/plugin-dependency-scope.md`.
|
||||
*/
|
||||
scope?: 'owner' | 'shared';
|
||||
/**
|
||||
* An architecture this dependency needs enabled before its package can be installed.
|
||||
*
|
||||
* Debian and Ubuntu only, and it means exactly one thing: run `dpkg --add-architecture <arch>` followed
|
||||
* by `apt-get update` before installing, if that architecture is not already enabled.
|
||||
*
|
||||
* ── Why this is a field and not a package name ──
|
||||
*
|
||||
* Because it cannot be one. A 32-bit package on a 64-bit host is `wine32:i386`, and installing it fails
|
||||
* outright until the host has been told i386 exists — which is a change to the machine's dpkg
|
||||
* configuration, not a package to fetch. Minesweeper found this the direct way: `wine` alone installs
|
||||
* 64-bit-only, the plugin reports a clean install, and the game dies with `Bad EXE format`.
|
||||
*
|
||||
* ── Why it is an enum and not a command ──
|
||||
*
|
||||
* This runs as root. A manifest that could supply the command would be a manifest that can run anything
|
||||
* as root on every machine that installs it, which is a far larger grant than "this plugin may install
|
||||
* wine". The manifest picks a value from `DPKG_ARCHITECTURES`; the platform owns the sentence it turns
|
||||
* into. Same principle as the plugin websocket naming a sidecar rather than a port.
|
||||
*
|
||||
* It is also SYSTEM-WIDE and does not go away when the plugin does. `plugins:delete` will not disable an
|
||||
* architecture something else may now depend on, so this is shown to the owner before they consent
|
||||
* rather than mentioned in a log afterwards.
|
||||
*
|
||||
* Ignored where it means nothing — a pacman or brew host is already multi-arch or has no such concept.
|
||||
*/
|
||||
multiarch?: DpkgArchitecture;
|
||||
/**
|
||||
* A release binary, for a dependency no package manager carries.
|
||||
*
|
||||
@@ -282,6 +309,44 @@ export type PluginManifest = {
|
||||
* a missing one that cannot be installed here refuses the install rather than proceeding to a plugin
|
||||
* that runs and quietly produces nothing.
|
||||
*/
|
||||
/**
|
||||
* A websocket this plugin serves, relayed by the platform.
|
||||
*
|
||||
* Declaring this makes `/api/plugin-ws/<appName>` a frame relay onto `ws://127.0.0.1:<port>/ws`, where the
|
||||
* port is the one the named sidecar announced at registration. It exists because Bun's route table is
|
||||
* fixed at `serve()`, so a plugin installed afterwards could never add a socket route — which is why
|
||||
* `/minesweeper` had to live in the platform and `plugins/desktop` could not stream a display at all.
|
||||
*
|
||||
* Authorisation is the plugin's OWN capability, asked as if the socket were a GET against its HTTP prefix.
|
||||
* There is deliberately no separate socket permission: granting the plugin grants its socket, and nothing
|
||||
* can drift out of step because there is only one decision.
|
||||
*
|
||||
* The target is a SIDECAR, always, and never a port number a manifest picked.
|
||||
*
|
||||
* A fixed-port form existed for about an hour and was removed before it shipped anywhere. The hole is
|
||||
* total: nothing stops a manifest naming a port it does not own, so `{ port: 5432 }` plus a grant is a
|
||||
* raw TCP relay onto Postgres, authenticated by the platform, for anyone that plugin is granted to. `:22`
|
||||
* and officer's own port are the same sentence. Checking that the HOST was loopback — which the relay
|
||||
* does — is not the same as checking the PORT belongs to the plugin, and only the second one matters.
|
||||
*
|
||||
* A sidecar's port is trustworthy for a reason a declaration can never be: the platform watched it
|
||||
* arrive. The sidecar binds an ephemeral port and announces it at registration, so the association
|
||||
* between "this port" and "this plugin" is something the platform observed rather than something a
|
||||
* manifest asserted. Provenance, not a claim.
|
||||
*
|
||||
* The cost is that a plugin wanting a socket needs a sidecar. That is not ceremony — it is the thing
|
||||
* that makes the port mean anything. It costs nothing else: the socket lives at `/ws` on the SAME
|
||||
* listener the sidecar already serves its routes from, so a plugin does not choose between having a
|
||||
* socket and having ordinary HTTP.
|
||||
*
|
||||
* **The sidecar serves a websocket, not a raw stream.** Whatever it is really talking to — a VNC display,
|
||||
* a line protocol, another process entirely — the conversion happens inside the plugin, because the
|
||||
* plugin is the only thing that knows. The platform moves frames it does not read.
|
||||
*
|
||||
* The host is always loopback and cannot be set. A plugin naming an arbitrary host would be a
|
||||
* request-forgery primitive reachable by anyone the plugin is granted to.
|
||||
*/
|
||||
websocket?: { sidecar: string };
|
||||
osDependencies?: OsDependency[];
|
||||
|
||||
/**
|
||||
@@ -471,7 +536,26 @@ export const PACKAGE_MANAGERS: PackageManager[] = ['apt', 'pacman', 'dnf', 'brew
|
||||
* not a regex that already allows it.
|
||||
*/
|
||||
const BINARY_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/;
|
||||
const PACKAGE_RE = /^[a-z0-9][a-z0-9.+-]{0,63}$/;
|
||||
/**
|
||||
* A package name, optionally qualified by an architecture.
|
||||
*
|
||||
* `wine32:i386` is the motivating case and it is not an edge case: on Debian and Ubuntu a 32-bit package
|
||||
* on a 64-bit host can only be named that way. The suffix is drawn from `DPKG_ARCHITECTURES` below rather
|
||||
* than being any word, so this stays a closed vocabulary — the point of a narrow regex here is that a
|
||||
* manifest cannot get a metacharacter onto an `apt-get` command line, and widening it to `[a-z0-9]+`
|
||||
* after the colon would still be safe but would stop being a vocabulary.
|
||||
*/
|
||||
const PACKAGE_RE = /^[a-z0-9][a-z0-9.+-]{0,63}(:(i386|amd64|arm64|armhf|ppc64el|s390x))?$/;
|
||||
|
||||
/**
|
||||
* Architectures `dpkg --add-architecture` accepts, and the only values a manifest may ask to enable.
|
||||
*
|
||||
* Closed on purpose. `multiarch` turns into a command run as root, so the manifest gets to pick from a
|
||||
* list rather than to supply a string — the same reasoning as the package regex one line up, applied to
|
||||
* the one field that would otherwise be a way to say "run this as root" out loud.
|
||||
*/
|
||||
export const DPKG_ARCHITECTURES = ['i386', 'amd64', 'arm64', 'armhf', 'ppc64el', 's390x'] as const;
|
||||
export type DpkgArchitecture = (typeof DPKG_ARCHITECTURES)[number];
|
||||
|
||||
/** Reasons an `osDependencies` list is unusable. Empty when it is fine. */
|
||||
function osDependencyProblems(deps: unknown): string[] {
|
||||
@@ -502,6 +586,13 @@ function osDependencyProblems(deps: unknown): string[] {
|
||||
problems.push(`${at}.packages must be an object keyed by package manager`);
|
||||
continue;
|
||||
}
|
||||
const multiarch = (dep as { multiarch?: unknown }).multiarch;
|
||||
if (multiarch !== undefined && !DPKG_ARCHITECTURES.includes(multiarch as never)) {
|
||||
// Refused rather than ignored: a typo here silently drops the one step that makes the package
|
||||
// installable at all, and the failure surfaces much later as the plugin not working.
|
||||
problems.push(`${at}.multiarch must be one of ${DPKG_ARCHITECTURES.join(', ')}`);
|
||||
}
|
||||
|
||||
for (const [manager, name] of Object.entries(packages)) {
|
||||
if (!PACKAGE_MANAGERS.includes(manager as PackageManager)) {
|
||||
problems.push(
|
||||
@@ -509,8 +600,19 @@ function osDependencyProblems(deps: unknown): string[] {
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (typeof name !== 'string' || !PACKAGE_RE.test(name)) {
|
||||
problems.push(`${at}.packages.${manager} must be a plain package name — lowercase, digits, dot, plus, dash`);
|
||||
// A LIST is allowed, because one binary genuinely can need more than one package. wine is the case:
|
||||
// `wine` provides the launcher on PATH and `wine32:i386` provides the 32-bit loader it needs to run
|
||||
// an i386 executable, and installing either alone produces a wine that cannot run the thing the
|
||||
// plugin came for. Every element is validated the same way and they reach apt as separate argv
|
||||
// entries, so a list is no more powerful than the names in it.
|
||||
const names = Array.isArray(name) ? name : [name];
|
||||
const bad = names.length === 0 || names.some((n) => typeof n !== 'string' || !PACKAGE_RE.test(n));
|
||||
if (bad) {
|
||||
problems.push(
|
||||
`${at}.packages.${manager} must be a plain package name (or a non-empty list of them) — ` +
|
||||
`lowercase, digits, dot, plus, dash, optionally with an architecture suffix ` +
|
||||
`(${DPKG_ARCHITECTURES.join(', ')})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -241,6 +241,76 @@ const BUNDLED: CatalogueEntry[] = [
|
||||
// No db/ — the mirror keeps nothing. The password lives in the owner's ~/.vnc, written by the sidecar.
|
||||
has: { api: true, schema: false, sidecar: true, web: true },
|
||||
},
|
||||
{
|
||||
appName: 'minesweeper',
|
||||
publisher: 'officerdev',
|
||||
version: '1.0.0',
|
||||
platform: '>=1.0.0',
|
||||
label: 'Minesweeper',
|
||||
summary: 'Minesweeper X under wine, on a headless display, streamed to the browser',
|
||||
color: '#64748b',
|
||||
icon: 'Bomb',
|
||||
source: 'https://gitea.officer.dev/plugins/minesweeper.git',
|
||||
permissions: [
|
||||
{
|
||||
key: 'minesweeper',
|
||||
label: 'Minesweeper',
|
||||
description:
|
||||
'Play Minesweeper on the server display. The game is confined to its own wine prefix and can reach nothing else on the machine.',
|
||||
},
|
||||
],
|
||||
// Six binaries, and this is the entry where the duplication above earns its keep: the owner sees what
|
||||
// installing this will make the machine acquire BEFORE consenting, and for wine that is not a small ask.
|
||||
//
|
||||
// `apt: 'wine'` is deliberately not the whole story and cannot be. The game is a 32-bit VB6 executable,
|
||||
// so the host also needs `dpkg --add-architecture i386` and `wine32:i386` — a system-wide change that no
|
||||
// package name can express, which is why the reason carries the command instead of pretending.
|
||||
//
|
||||
// Xvfb has no dnf entry because Fedora spells it `xorg-x11-server-Xvfb`, with a capital X, and a
|
||||
// manifest package name must be lowercase. Absent means "cannot be installed automatically here", which
|
||||
// becomes an instruction rather than a wrong guess.
|
||||
osDependencies: [
|
||||
{
|
||||
binary: 'wine',
|
||||
reason:
|
||||
'Runs the game. Minesweeper X is a 32-bit Windows executable, so this host also needs i386 multiarch (`dpkg --add-architecture i386`) and the 32-bit wine package — a 64-bit-only wine cannot load it.',
|
||||
multiarch: 'i386',
|
||||
packages: { apt: ['wine', 'wine32:i386'], dnf: 'wine', pacman: 'wine', brew: 'wine-stable' },
|
||||
},
|
||||
{
|
||||
binary: 'Xvfb',
|
||||
reason:
|
||||
'The virtual display the game draws on. A framebuffer with no hardware behind it, so nothing appears on any real monitor.',
|
||||
packages: { apt: 'xvfb', pacman: 'xorg-server-xvfb' },
|
||||
},
|
||||
{
|
||||
binary: 'x11vnc',
|
||||
reason:
|
||||
'Reads that display and serves it, so the browser has something to draw. Bound to a private unix socket, never a port.',
|
||||
packages: { apt: 'x11vnc', dnf: 'x11vnc', pacman: 'x11vnc' },
|
||||
},
|
||||
{
|
||||
binary: 'xdotool',
|
||||
reason:
|
||||
'Finds the game window and reads its size, so the panel can fit its canvas to the board instead of the whole framebuffer.',
|
||||
packages: { apt: 'xdotool', dnf: 'xdotool', pacman: 'xdotool' },
|
||||
},
|
||||
{
|
||||
binary: 'xdpyinfo',
|
||||
reason:
|
||||
'Waits for the display to be usable. A live Xvfb process is not the same as a display that answers, and starting the game too early fails silently.',
|
||||
packages: { apt: 'x11-utils', dnf: 'xorg-x11-utils', pacman: 'xorg-xdpyinfo' },
|
||||
},
|
||||
{
|
||||
binary: 'winetricks',
|
||||
reason:
|
||||
'Installs the Visual Basic 6 runtime into the wine prefix on first start. Minesweeper X needs MSVBVM60.DLL, which wine does not ship; without it the process exits immediately with nothing but an unresolved-import line.',
|
||||
packages: { apt: 'winetricks', dnf: 'winetricks', pacman: 'winetricks' },
|
||||
},
|
||||
],
|
||||
// No schema. The session is processes and a wine prefix on disk, and none of that belongs in Postgres.
|
||||
has: { api: true, schema: false, sidecar: true, web: true },
|
||||
},
|
||||
{
|
||||
appName: 'jellyfin',
|
||||
publisher: 'officerdev',
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { DPKG_ARCHITECTURES, manifestProblems } from './manifest';
|
||||
import { reportDependencies } from './os-deps';
|
||||
import type { DiscoveredPlugin, OsDependency } from './manifest';
|
||||
|
||||
/**
|
||||
* A package cannot be installed for an architecture the host does not know about, and no package name can
|
||||
* say so — which is why `multiarch` is a field rather than something clever in the package string.
|
||||
*
|
||||
* Minesweeper is the case that produced it: `apt-get install wine` on a 64-bit host succeeds, reports a
|
||||
* clean plugin install, and leaves a 32-bit game dying with `Bad EXE format`. Nothing in that chain
|
||||
* mentions architectures, so it is worth pinning the two properties that keep it working.
|
||||
*/
|
||||
|
||||
const dep = (over: Partial<OsDependency> = {}): OsDependency => ({
|
||||
binary: 'definitely-not-a-real-binary-xyzzy',
|
||||
reason: 'test',
|
||||
packages: { apt: 'some-package' },
|
||||
...over,
|
||||
});
|
||||
|
||||
const plugin = (deps: OsDependency[]): DiscoveredPlugin =>
|
||||
({ appName: 'probe', dir: '/tmp/probe', manifest: { osDependencies: deps } }) as unknown as DiscoveredPlugin;
|
||||
|
||||
describe('multiarch is a closed vocabulary, not a command', () => {
|
||||
test('a valid architecture is accepted', () => {
|
||||
for (const arch of DPKG_ARCHITECTURES) {
|
||||
const problems = manifestProblems('probe', {
|
||||
publisher: 'x',
|
||||
version: '1.0.0',
|
||||
platform: '>=1.0.0',
|
||||
label: 'X',
|
||||
summary: 'x',
|
||||
color: '#000000',
|
||||
permissions: [],
|
||||
osDependencies: [dep({ multiarch: arch })],
|
||||
} as never);
|
||||
expect(problems.filter((p) => p.includes('multiarch'))).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
// The whole point of the enum. A manifest that could supply the string could run it as root on every
|
||||
// machine that installs the plugin, which is a far larger grant than "may install wine".
|
||||
test('anything else is refused, including something shell-shaped', () => {
|
||||
for (const bad of ['i486', 'I386', 'i386; rm -rf /', '$(whoami)', '']) {
|
||||
const problems = manifestProblems('probe', {
|
||||
publisher: 'x',
|
||||
version: '1.0.0',
|
||||
platform: '>=1.0.0',
|
||||
label: 'X',
|
||||
summary: 'x',
|
||||
color: '#000000',
|
||||
permissions: [],
|
||||
osDependencies: [dep({ multiarch: bad as never })],
|
||||
} as never);
|
||||
expect(problems.some((p) => p.includes('multiarch'))).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test('a package name may carry a known architecture suffix, and only a known one', () => {
|
||||
const ok = manifestProblems('probe', {
|
||||
publisher: 'x',
|
||||
version: '1.0.0',
|
||||
platform: '>=1.0.0',
|
||||
label: 'X',
|
||||
summary: 'x',
|
||||
color: '#000000',
|
||||
permissions: [],
|
||||
osDependencies: [dep({ packages: { apt: 'wine32:i386' } })],
|
||||
} as never);
|
||||
expect(ok.filter((p) => p.includes('packages'))).toEqual([]);
|
||||
|
||||
// The list form, which is what wine actually needs.
|
||||
const list = manifestProblems('probe', {
|
||||
publisher: 'x',
|
||||
version: '1.0.0',
|
||||
platform: '>=1.0.0',
|
||||
label: 'X',
|
||||
summary: 'x',
|
||||
color: '#000000',
|
||||
permissions: [],
|
||||
osDependencies: [dep({ packages: { apt: ['wine', 'wine32:i386'] } })],
|
||||
} as never);
|
||||
expect(list.filter((p) => p.includes('packages'))).toEqual([]);
|
||||
|
||||
const bad = manifestProblems('probe', {
|
||||
publisher: 'x',
|
||||
version: '1.0.0',
|
||||
platform: '>=1.0.0',
|
||||
label: 'X',
|
||||
summary: 'x',
|
||||
color: '#000000',
|
||||
permissions: [],
|
||||
osDependencies: [dep({ packages: { apt: 'wine32:sparc' } })],
|
||||
} as never);
|
||||
expect(bad.some((p) => p.includes('packages'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the report tells the owner what will CHANGE', () => {
|
||||
test('a missing dependency surfaces its architecture', () => {
|
||||
const report = reportDependencies(plugin([dep({ multiarch: 'i386' })]));
|
||||
expect(report.missing.length).toBe(1);
|
||||
// Reported on every host. Whether it is ACTED on is apt-only — see `enableArchitectures` — but the
|
||||
// owner is told what the plugin asks for regardless of what this machine will do about it.
|
||||
expect(report.architectures).toEqual(['i386']);
|
||||
});
|
||||
|
||||
// A binary already on PATH had its architecture enabled at some point in the past. Re-reporting it would
|
||||
// tell the owner the machine is about to change when it is not.
|
||||
test('a dependency that is already present does not', () => {
|
||||
const report = reportDependencies(plugin([dep({ binary: 'sh', multiarch: 'i386' })]));
|
||||
expect(report.missing.length).toBe(0);
|
||||
expect(report.architectures).toEqual([]);
|
||||
});
|
||||
|
||||
test('two dependencies wanting the same architecture report it once', () => {
|
||||
const report = reportDependencies(
|
||||
plugin([dep({ binary: 'nope-a-xyzzy', multiarch: 'i386' }), dep({ binary: 'nope-b-xyzzy', multiarch: 'i386' })]),
|
||||
);
|
||||
expect(report.architectures).toEqual(['i386']);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { chmodSync, existsSync, mkdirSync, renameSync, rmSync, statSync } from 'node:fs';
|
||||
import { homedir, userInfo } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { PACKAGE_MANAGERS, type DiscoveredPlugin, type OsDependency, type PackageManager } from './manifest';
|
||||
import { PACKAGE_MANAGERS, type DiscoveredPlugin, type DpkgArchitecture, type OsDependency, type PackageManager } from './manifest';
|
||||
|
||||
// Host binaries a plugin needs, and getting them onto the machine.
|
||||
//
|
||||
@@ -58,7 +58,13 @@ export type DependencyStatus = {
|
||||
path: string | null;
|
||||
present: boolean;
|
||||
/** What would be installed on THIS machine, or null when this manager has no entry for it. */
|
||||
packageName: string | null;
|
||||
/**
|
||||
* The package(s) that would provide this binary here, or null when this manager has no entry for it.
|
||||
*
|
||||
* A LIST because one binary can need more than one package — wine needs both its launcher and the 32-bit
|
||||
* loader, and installing either alone gives a wine that cannot run what the plugin came for.
|
||||
*/
|
||||
packageName: string[] | null;
|
||||
/** Resolved download URL when the plugin ships a release binary, or null. */
|
||||
releaseUrl: string | null;
|
||||
/**
|
||||
@@ -81,8 +87,21 @@ export type DependencyStatus = {
|
||||
unreachable: boolean;
|
||||
/** Why it is unreachable, for the install log. */
|
||||
unreachableReason: string | null;
|
||||
/**
|
||||
* Carried through from the manifest so the report can say what installing this will CHANGE, not just
|
||||
* what it will fetch. Enabling an architecture is system-wide and permanent.
|
||||
*/
|
||||
multiarch: DpkgArchitecture | null;
|
||||
};
|
||||
|
||||
/** A dependency's packages for one manager, always as a list — the manifest allows either form. */
|
||||
export function packageNames(dep: OsDependency, manager: PackageManager): string[] | null {
|
||||
const entry = dep.packages[manager];
|
||||
if (!entry) return null;
|
||||
const names = Array.isArray(entry) ? entry : [entry];
|
||||
return names.length ? names : null;
|
||||
}
|
||||
|
||||
/** `linux`/`darwin` and `amd64`/`arm64` — the spelling Go releases use, which is who publishes this way. */
|
||||
export function hostTarget(): { os: string; arch: string } {
|
||||
const os = process.platform === 'darwin' ? 'darwin' : 'linux';
|
||||
@@ -252,8 +271,9 @@ function probe(dep: OsDependency, manager: PackageManager | null): DependencySta
|
||||
scope,
|
||||
unreachable: !!path && genuine && !reach.ok,
|
||||
unreachableReason: reach.reason,
|
||||
packageName: (manager && dep.packages[manager]) || null,
|
||||
packageName: manager ? packageNames(dep, manager) : null,
|
||||
releaseUrl: releaseUrlFor(dep),
|
||||
multiarch: dep.multiarch ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -268,6 +288,15 @@ export type DependencyReport = {
|
||||
* has. These are the owner's to resolve by hand, and the install says so rather than guessing.
|
||||
*/
|
||||
unresolvable: DependencyStatus[];
|
||||
/**
|
||||
* Architectures the missing packages need enabled first — `dpkg --add-architecture`, apt hosts only.
|
||||
*
|
||||
* Reported rather than done quietly because it is SYSTEM-WIDE and permanent: nothing here ever disables
|
||||
* one, including uninstalling the plugin. The owner should see "this will change how this machine
|
||||
* resolves packages" on the screen where they consent, not in a log afterwards.
|
||||
*/
|
||||
architectures: DpkgArchitecture[];
|
||||
|
||||
/** Can the platform actually run the install — passwordless sudo, or a manager that never needs it. */
|
||||
canInstall: boolean;
|
||||
/** Why not, when it cannot. Written to be shown to a person. */
|
||||
@@ -305,7 +334,7 @@ export function reportDependencies(plugin: DiscoveredPlugin): DependencyReport {
|
||||
|
||||
// Deduped and sorted: ffmpeg and ffprobe are one apt package, and naming it twice on the command line
|
||||
// is the sort of thing that reads as a bug in a log even though apt does not care.
|
||||
const packagesToInstall = [...new Set(missing.map((d) => d.packageName).filter((n): n is string => !!n))].sort();
|
||||
const packagesToInstall = [...new Set(missing.flatMap((d) => d.packageName ?? []))].sort();
|
||||
|
||||
let canInstall = true;
|
||||
let blockedReason: string | null = null;
|
||||
@@ -343,7 +372,14 @@ export function reportDependencies(plugin: DiscoveredPlugin): DependencyReport {
|
||||
'Install the packages yourself and then install the plugin.';
|
||||
}
|
||||
|
||||
return { manager, dependencies, missing, packagesToInstall, unresolvable, canInstall, blockedReason };
|
||||
// Only for the ones actually being installed. A binary already present needed its architecture enabled
|
||||
// at some point in the past, and re-reporting that would tell the owner the machine is about to change
|
||||
// when it is not.
|
||||
const architectures = [
|
||||
...new Set(missing.map((d) => d.multiarch).filter((a): a is DpkgArchitecture => !!a)),
|
||||
];
|
||||
|
||||
return { manager, dependencies, missing, packagesToInstall, unresolvable, architectures, canInstall, blockedReason };
|
||||
}
|
||||
|
||||
/** The command that would be run, as argv. Exported so the UI can SHOW it — the owner should see it. */
|
||||
@@ -451,6 +487,81 @@ export async function installRelease(dep: DependencyStatus): Promise<{ ok: boole
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Enable a foreign architecture, so a package that only exists for it can be installed.
|
||||
*
|
||||
* ── Why the platform runs this at all ──
|
||||
*
|
||||
* Because no package name can express it. `wine32:i386` is not installable until dpkg has been told the
|
||||
* architecture exists, and that is a change to the machine's configuration rather than a package to
|
||||
* fetch. Leaving it to the owner means an install that reports success and produces a plugin that does
|
||||
* not work — which is exactly what happened with Minesweeper: `wine` alone lands 64-bit-only and the game
|
||||
* dies with `Bad EXE format`, several steps away from anything that mentions architectures.
|
||||
*
|
||||
* ── What it will and will not do ──
|
||||
*
|
||||
* apt hosts only. pacman and brew have no equivalent, and dnf resolves multilib without being asked.
|
||||
*
|
||||
* It is SYSTEM-WIDE and permanent. Nothing here ever removes an architecture, including
|
||||
* `plugins:delete` — something else may depend on it by then, and disabling one with packages installed
|
||||
* against it is how a machine ends up unable to run `apt-get -f install`. The manifest field is shown to
|
||||
* the owner before they consent for this reason.
|
||||
*
|
||||
* Already-enabled is success and costs one `dpkg` call. `apt-get update` runs only when something
|
||||
* actually changed, because it is slow and there is no reason to pay for it otherwise.
|
||||
*/
|
||||
async function enableArchitectures(
|
||||
manager: PackageManager,
|
||||
architectures: DpkgArchitecture[],
|
||||
): Promise<{ ok: boolean; output: string; error?: string }> {
|
||||
const wanted = [...new Set(architectures)];
|
||||
if (!wanted.length || manager !== 'apt') return { ok: true, output: '' };
|
||||
if (!Bun.which('dpkg')) return { ok: true, output: '' };
|
||||
|
||||
const current = Bun.spawnSync(['dpkg', '--print-foreign-architectures']);
|
||||
const enabled = new Set(new TextDecoder().decode(current.stdout).split(/\s+/).filter(Boolean));
|
||||
const missing = wanted.filter((a) => !enabled.has(a));
|
||||
if (!missing.length) return { ok: true, output: '' };
|
||||
|
||||
const escalate = sudoAvailable() ? ['sudo', '-n'] : [];
|
||||
let output = '';
|
||||
|
||||
for (const arch of missing) {
|
||||
// argv, never a shell string — and `arch` came from a closed enum in the manifest validator, so this
|
||||
// is belt and braces rather than the only guard. Both halves are deliberate; see the header comment.
|
||||
const proc = Bun.spawn([...escalate, 'dpkg', '--add-architecture', arch], { stdout: 'pipe', stderr: 'pipe' });
|
||||
const [out, err] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
|
||||
const code = await proc.exited;
|
||||
output += `${out}${err}`;
|
||||
if (code !== 0) {
|
||||
return {
|
||||
ok: false,
|
||||
output: output.trim().slice(-600),
|
||||
error:
|
||||
`could not enable the ${arch} architecture, which this plugin's packages need. ` +
|
||||
`Run it by hand: sudo dpkg --add-architecture ${arch} && sudo apt-get update`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// The package lists have no entries for an architecture that was not enabled a moment ago, so without
|
||||
// this the install that follows cannot find anything to install.
|
||||
const upd = Bun.spawn([...escalate, 'apt-get', 'update'], { stdout: 'pipe', stderr: 'pipe' });
|
||||
const [uo, ue] = await Promise.all([new Response(upd.stdout).text(), new Response(upd.stderr).text()]);
|
||||
const updCode = await upd.exited;
|
||||
output += `${uo}${ue}`;
|
||||
if (updCode !== 0) {
|
||||
return {
|
||||
ok: false,
|
||||
output: output.trim().slice(-600),
|
||||
error: `enabled ${missing.join(', ')} but \`apt-get update\` failed, so the new package lists are not available yet`,
|
||||
};
|
||||
}
|
||||
|
||||
return { ok: true, output: output.trim().slice(-600) };
|
||||
}
|
||||
|
||||
export type InstallDependenciesResult = { ok: boolean; installed: string[]; output: string; error?: string };
|
||||
|
||||
/**
|
||||
@@ -463,9 +574,16 @@ export type InstallDependenciesResult = { ok: boolean; installed: string[]; outp
|
||||
export async function installDependencies(
|
||||
manager: PackageManager,
|
||||
packages: string[],
|
||||
architectures: DpkgArchitecture[] = [],
|
||||
): Promise<InstallDependenciesResult> {
|
||||
if (!packages.length) return { ok: true, installed: [], output: '' };
|
||||
|
||||
// Architectures FIRST, and the order is not cosmetic: `apt-get install wine32:i386` fails outright on a
|
||||
// host that has not been told i386 exists, so doing this after would install nothing and report a
|
||||
// package that does not exist.
|
||||
const arch = await enableArchitectures(manager, architectures);
|
||||
if (!arch.ok) return { ok: false, installed: [], output: arch.output, error: arch.error };
|
||||
|
||||
try {
|
||||
const proc = Bun.spawn(installCommand(manager, packages), { stdout: 'pipe', stderr: 'pipe' });
|
||||
const [out, err] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
|
||||
|
||||
@@ -120,6 +120,25 @@ export function unregisterSidecar(id: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
Every sidecar's announced HTTP port, by name.
|
||||
|
||||
`createSidecarProxy` already learns this for the prefix it serves, by subscribing to `<name>:server`. That
|
||||
is per-proxy state and unreachable from anywhere else, which was fine while the only consumer was the
|
||||
proxy itself — and stopped being fine when a plugin needed a WEBSOCKET relayed onto the same sidecar. A
|
||||
second subscription in a second place would be a second thing to keep in step.
|
||||
|
||||
So the port is recorded here, once, from the same event. A sidecar that has not announced yet is absent
|
||||
rather than zero: "not listening" and "listening on nothing" are different answers and only one of them is
|
||||
true.
|
||||
*/
|
||||
const sidecarPorts = new Map<string, number>();
|
||||
|
||||
/** The port a named sidecar announced, or null if it has not announced one. */
|
||||
export function sidecarPort(name: string): number | null {
|
||||
return sidecarPorts.get(name) ?? null;
|
||||
}
|
||||
|
||||
export function handleSidecarMessage(id: string, msg: SidecarEvent): void {
|
||||
// Check if this is a response to a pending request
|
||||
if ('id' in msg && msg.id && pending.has(msg.id)) {
|
||||
@@ -130,6 +149,13 @@ export function handleSidecarMessage(id: string, msg: SidecarEvent): void {
|
||||
return;
|
||||
}
|
||||
|
||||
// Record any `<name>:server` announcement before dispatching, so `sidecarPort` is current by the time a
|
||||
// subscriber reacts to the same event.
|
||||
if ('type' in msg && typeof msg.type === 'string' && msg.type.endsWith(':server')) {
|
||||
const port = (msg as { port?: number }).port;
|
||||
if (typeof port === 'number') sidecarPorts.set(msg.type.slice(0, -':server'.length), port);
|
||||
}
|
||||
|
||||
// Otherwise dispatch as event
|
||||
dispatchEvent(msg);
|
||||
}
|
||||
|
||||
@@ -552,11 +552,14 @@ function armIdle(session: PersistentSession): void {
|
||||
}, IDLE_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
function createSession(
|
||||
// Async only because the preamble is: resolving which plugins are installed and enabled, so their
|
||||
// capabilities can be read where they live, reads the database. Once per session — see
|
||||
// `capability-sources.ts`. Its single caller already awaits.
|
||||
async function createSession(
|
||||
params: ClaudeSpawnStreamingParams,
|
||||
onEvent: (event: ChatEvent) => void,
|
||||
onNotify: (kind: 'turn-ended' | 'input-needed') => void,
|
||||
): PersistentSession {
|
||||
): Promise<PersistentSession> {
|
||||
const { sessionKey } = params;
|
||||
const input = makeInputQueue();
|
||||
const abort = new AbortController();
|
||||
@@ -625,24 +628,24 @@ function createSession(
|
||||
|
||||
// Read here rather than at module load: a plugin installed an hour ago should be live on the next
|
||||
// message, and this costs a few kilobytes once per session.
|
||||
const affordances = affordanceBlock();
|
||||
const affordances = await affordanceBlock();
|
||||
// The skills INDEX — names, descriptions and paths, never bodies. See skills/load.ts for why this is
|
||||
// the inverse of an affordance. Read here rather than at module load for the same reason: a skill
|
||||
// written an hour ago should be offered on the next session.
|
||||
const skills = skillsBlock();
|
||||
const skills = await skillsBlock();
|
||||
/*
|
||||
And, for a commander, the two paths only this session can be told.
|
||||
No commander block here, and none in `startClaudeTurn` either, as of `docs/commander-directory.md`.
|
||||
|
||||
Not in the skill, which is read on demand and cannot know them; not only in the background-job dialog's
|
||||
preamble, which is where this used to live and which an agent arming itself never sees. See
|
||||
`api/command-hq/commander-block.ts`.
|
||||
*/
|
||||
/*
|
||||
No commander block here, deliberately.
|
||||
Two things used to live here in turn, both dead ends. First, the background-job dialog's own preamble,
|
||||
which an agent arming itself from the conversation never saw. Then a per-turn block riding
|
||||
`effectivePrompt` (`commander-block.ts`, deleted 2026-08-24) — which could not resolve on a commander's
|
||||
FIRST turn either, since this function runs once per session and that turn has no row and no transcript
|
||||
id yet, so the block was null and no later turn ever rebuilt the preamble.
|
||||
|
||||
It used to be composed in, and could never arrive: this runs once per session, and a commander's first
|
||||
turn has no row and no transcript id, so the block resolved to null and no later turn rebuilt the
|
||||
preamble. It rides `effectivePrompt` instead — see `startClaudeTurn` in api/chat/websocket.ts.
|
||||
`POST /command-hq/commanders` creates the row, its directory and its `CLAUDE.md` BEFORE any turn spawns
|
||||
now, so a commander is told its name, its reference path and the two paths it must record itself at by a
|
||||
FILE Claude Code reads from its own working directory at session start — the same mechanism this
|
||||
function's `affordances`/`skills` blocks still use for everything else, minus the per-turn cost.
|
||||
*/
|
||||
const preamble = [affordances, skills].filter(Boolean).join('\n\n') || null;
|
||||
|
||||
@@ -916,7 +919,7 @@ export async function spawnClaudeStreaming(
|
||||
await applyModelChange(session, params.model);
|
||||
await applyPlanModeChange(session, params.planMode);
|
||||
} else {
|
||||
session = createSession(params, onEvent, onNotify);
|
||||
session = await createSession(params, onEvent, onNotify);
|
||||
}
|
||||
session.pushTurn(params.prompt, params.images);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Channel, DeliveryResult, Notification } from './types';
|
||||
import { loadCatalogue } from '../../notifications/catalogue';
|
||||
import { pluginCatalogueSources } from '../../capability-sources';
|
||||
import { discordChannel } from './discord';
|
||||
import { apnsChannel } from './apns';
|
||||
import { fcmChannel } from './fcm';
|
||||
@@ -28,7 +29,7 @@ export async function dispatch(n: Notification): Promise<DeliveryResult[]> {
|
||||
// Read ONCE, here — not per channel and not per device. `version` travels with the push as `mv`, and
|
||||
// every copy of one notification must carry the same one; two channels reading disk either side of a
|
||||
// plugin install would otherwise tell two devices different things about the same event.
|
||||
const ctx = { catalogue: loadCatalogue() };
|
||||
const ctx = { catalogue: loadCatalogue(undefined, await pluginCatalogueSources()) };
|
||||
|
||||
return Promise.all(
|
||||
active.map(async (c) => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createSidecarConnector } from '../connect';
|
||||
import { dispatch, configuredChannels } from './dispatch';
|
||||
import { handleDeviceRoute } from './devices';
|
||||
import { loadCatalogue } from '../../notifications/catalogue';
|
||||
import { pluginCatalogueSources } from '../../capability-sources';
|
||||
import { resolveNotifyUser } from './resolve-user';
|
||||
import { closeApnsSessions } from './apns';
|
||||
import type { Notification } from './types';
|
||||
@@ -76,7 +77,7 @@ const server = Bun.serve({
|
||||
// it would present as "the new plugin's notifications don't work" — the hardest kind of bug to place.
|
||||
// It is a few kilobytes off local disk against a request the app makes about once per launch.
|
||||
if (url.pathname === '/mapping' && req.method === 'GET') {
|
||||
const catalogue = loadCatalogue();
|
||||
const catalogue = loadCatalogue(undefined, await pluginCatalogueSources());
|
||||
return Response.json(catalogue, {
|
||||
// The client decides by `version`, so a cached copy would defeat the sync it exists to drive.
|
||||
headers: { 'Cache-Control': 'no-store' },
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { readdirSync, readFileSync, existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { OFFICER_ITEMS_DIR } from '../data-path';
|
||||
import { capabilityRoots } from '../capability-sources';
|
||||
|
||||
// Skills: reference the agent reads WHEN IT NEEDS IT, not on every turn.
|
||||
//
|
||||
@@ -104,6 +105,31 @@ export function compileSkills(skills: Skill[]): string | null {
|
||||
}
|
||||
|
||||
/** Read and compile in one step, for callers that only want the string. */
|
||||
export function skillsBlock(root: string = SKILLS_DIR): string | null {
|
||||
return compileSkills(loadSkills(root));
|
||||
/**
|
||||
* Every skill on offer: the owner's own, plus each installed and enabled plugin's own directory.
|
||||
*
|
||||
* A plugin's skills are read WHERE THEY LIVE rather than copied into `capabilities/` — see
|
||||
* `capability-sources.ts` for why, and `docs/plugin-capabilities.md` for the whole argument.
|
||||
*
|
||||
* **The owner's own win a name collision**, because `capabilityRoots` puts their directory first and this
|
||||
* keeps the first of each id. That is the right precedence: a skill somebody wrote deliberately should
|
||||
* not be displaced by a plugin that happened to pick the same directory name, and a plugin cannot
|
||||
* silently take over the meaning of a name already in use.
|
||||
*/
|
||||
export function loadSkillsAcross(roots: string[]): Skill[] {
|
||||
const byId = new Map<string, Skill>();
|
||||
for (const root of roots) {
|
||||
for (const skill of loadSkills(root)) if (!byId.has(skill.id)) byId.set(skill.id, skill);
|
||||
}
|
||||
return [...byId.values()].sort((a, b) => a.id.localeCompare(b.id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Read and compile in one step, for callers that only want the string.
|
||||
*
|
||||
* Async because resolving which plugins are enabled reads the database. Called once per session, which is
|
||||
* what makes that affordable — see the note in `capability-sources.ts`.
|
||||
*/
|
||||
export async function skillsBlock(): Promise<string | null> {
|
||||
return compileSkills(loadSkillsAcross(await capabilityRoots('skills')));
|
||||
}
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import { cp, readdir, rm } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { SKILLS_DIR } from './load';
|
||||
import type { DiscoveredPlugin } from '../plugins/manifest';
|
||||
|
||||
// Publishing a plugin's skills, the same shape as affordances and notification catalogues.
|
||||
//
|
||||
// Deliberately NOT namespaced by publisher, unlike those two. A skill's directory name is what appears in
|
||||
// the index and what the agent reads a path to, and `officerdev/gitea/SKILL.md` in a prompt is noise for
|
||||
// a human and for a model. Collisions are handled by the loader taking whichever sorts first, which is
|
||||
// the same rule the items store has always used — one flat namespace, first come.
|
||||
//
|
||||
// That trade is only acceptable because a skill is REFERENCE. An affordance's namespace matters because a
|
||||
// plugin must not be able to claim `officer/core:`; a skill has no authority to usurp.
|
||||
|
||||
export const skillDirFor = (plugin: DiscoveredPlugin): string => join(SKILLS_DIR, plugin.appName);
|
||||
|
||||
/** What the plugin ships, if anything. */
|
||||
export const skillSourceFor = (plugin: DiscoveredPlugin): string => join(plugin.dir, 'skills');
|
||||
|
||||
/** Publish a plugin's skills. Returns the number of directories, or 0 when it ships none. */
|
||||
export async function publishSkills(plugin: DiscoveredPlugin): Promise<number> {
|
||||
const source = skillSourceFor(plugin);
|
||||
if (!existsSync(source)) return 0;
|
||||
|
||||
await cp(source, SKILLS_DIR, { recursive: true, force: true });
|
||||
|
||||
try {
|
||||
return (await readdir(source, { withFileTypes: true })).filter((e) => e.isDirectory()).length;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop a plugin's skills.
|
||||
*
|
||||
* Removes only the directories THIS plugin shipped, read from its own source tree — not everything under
|
||||
* `skills/`, which holds the platform's own and every other plugin's.
|
||||
*/
|
||||
export async function unpublishSkills(plugin: DiscoveredPlugin): Promise<void> {
|
||||
const source = skillSourceFor(plugin);
|
||||
if (!existsSync(source)) return;
|
||||
let dirs: string[];
|
||||
try {
|
||||
dirs = (await readdir(source, { withFileTypes: true })).filter((e) => e.isDirectory()).map((e) => e.name);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const dir of dirs) await rm(join(SKILLS_DIR, dir), { recursive: true, force: true });
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getUserById } from 'officerdb';
|
||||
import { getUserById, OWNER_USER_ID } from 'officerdb';
|
||||
|
||||
// Identifies the platform owner. The user's `role` column IS the answer — there is no second mechanism
|
||||
// and no environment variable.
|
||||
@@ -13,10 +13,30 @@ import { getUserById } from 'officerdb';
|
||||
// are editable, and a cache with no invalidation contract is a staleness bug waiting for whoever builds
|
||||
// the role UI. This is one primary-key lookup on a request that has already verified a JWT.
|
||||
export async function isSuperAdmin(payload: { id?: number } | null | undefined): Promise<boolean> {
|
||||
if (!payload?.id) return false;
|
||||
return isOwner(payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* The owner is an IDENTITY, not a role. `OWNER_USER_ID` and nothing else.
|
||||
*
|
||||
* This asked `user.role === 'Super Admin'` until 2026-08-23, which was correct while roles were a hardcoded
|
||||
* TypeScript union and nothing could write a new one. The moment roles became a table it became a
|
||||
* privilege-escalation path: "add a role" and "become the owner" are the same feature, reachable from
|
||||
* whatever UI manages roles.
|
||||
*
|
||||
* So the question moved to one nothing in the product can answer for itself. `Super Admin` is still a role
|
||||
* and several accounts may hold it — it provisions an OS account with sudo, which is its own decision — but
|
||||
* holding it is not being the owner and no role grants that.
|
||||
*
|
||||
* Kept as a database read rather than a bare comparison, deliberately. A deleted or blocked account must not
|
||||
* still be the owner because its id happens to match, and this is one primary-key lookup on a request that
|
||||
* has already verified a JWT.
|
||||
*/
|
||||
export async function isOwner(payload: { id?: number } | null | undefined): Promise<boolean> {
|
||||
if (payload?.id !== OWNER_USER_ID) return false;
|
||||
try {
|
||||
const user = await getUserById(payload.id);
|
||||
return user?.role === 'Super Admin';
|
||||
const user = await getUserById(OWNER_USER_ID);
|
||||
return !!user;
|
||||
} catch {
|
||||
// A transient database error must never promote anyone. Deny, and let the next call retry.
|
||||
return false;
|
||||
|
||||
+10
-3
@@ -2,15 +2,22 @@ import type { ReactNode } from 'react';
|
||||
|
||||
type UntranslatedProps = {
|
||||
children: ReactNode;
|
||||
/** Why this text is not localized. Required so the decision is legible at the call site. */
|
||||
reason: 'foreign-ui-label';
|
||||
/**
|
||||
* Why this text is not localized. Required so the decision is legible at the call site.
|
||||
*
|
||||
* `foreign-ui-label` — quoted from another product's interface ("click **Enable**"). It has to match
|
||||
* what the reader sees on THEIR screen, and that product localizes by its own account language.
|
||||
* `product-name` — the name of a product or feature: Tmux, Neovim, Claude Code, Officer. The doc's
|
||||
* never-translate list covers these directly.
|
||||
*/
|
||||
reason: 'foreign-ui-label' | 'product-name';
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Text that is deliberately NOT localized, marked as such where it is written.
|
||||
*
|
||||
* The only reason so far is `foreign-ui-label`: a label quoted from another product's interface, which
|
||||
* The first reason was `foreign-ui-label`: a label quoted from another product's interface, which
|
||||
* docs/localization.md classifies as an identifier rather than prose. "Click **Enable**" has to match
|
||||
* what the reader sees on Google's screen, and Google localises by their Google account language rather
|
||||
* than by ours — so translating it sends somebody hunting for "Ativar" in a console that says "Enable".
|
||||
@@ -0,0 +1,81 @@
|
||||
{
|
||||
"button": {
|
||||
"cancel": "Abbrechen",
|
||||
"confirm": "Bestätigen",
|
||||
"ok": "OK",
|
||||
"close": "Schließen"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Suchen",
|
||||
"open": "Suche öffnen",
|
||||
"close": "Suche schließen",
|
||||
"clear": "Suche leeren"
|
||||
},
|
||||
"select": {
|
||||
"placeholder": "Auswählen..."
|
||||
},
|
||||
"tabs": {
|
||||
"filterPlaceholder": "Filtern"
|
||||
},
|
||||
"commandBlock": {
|
||||
"run": "Im Terminal ausführen",
|
||||
"copy": "Befehl kopieren"
|
||||
},
|
||||
"colorPicker": {
|
||||
"placeholder": "Hex-Farbe"
|
||||
},
|
||||
"state": {
|
||||
"loading": "Wird geladen…"
|
||||
},
|
||||
"dataTable": {
|
||||
"empty": "Keine Daten gefunden.",
|
||||
"pagination": {
|
||||
"showingPage": "Seite {{current}} von {{total}}",
|
||||
"showingPageShort": "S. {{current}}/{{total}}",
|
||||
"rowsPerPage": "Zeilen pro Seite",
|
||||
"perPage": "{{size}} / Seite",
|
||||
"perPageShort": "{{size}}/S.",
|
||||
"first": "Erste",
|
||||
"prev": "Zurück",
|
||||
"next": "Weiter",
|
||||
"last": "Letzte"
|
||||
}
|
||||
},
|
||||
"pagination": {
|
||||
"label": "Seitennummerierung",
|
||||
"previous": "Zurück",
|
||||
"next": "Weiter",
|
||||
"morePages": "Weitere Seiten",
|
||||
"goToPrevious": "Zur vorherigen Seite",
|
||||
"goToNext": "Zur nächsten Seite"
|
||||
},
|
||||
"carousel": {
|
||||
"previous": "Vorherige Folie",
|
||||
"next": "Nächste Folie"
|
||||
},
|
||||
"breadcrumb": {
|
||||
"more": "Mehr"
|
||||
},
|
||||
"theme": {
|
||||
"toggle": "Farbmodus umschalten"
|
||||
},
|
||||
"error": {
|
||||
"forbidden": {
|
||||
"title": "Zugriff nicht autorisiert",
|
||||
"description": "Sie sind nicht berechtigt, diese Aktion auszuführen.",
|
||||
"hint": "Wenn Sie das für einen Fehler halten, wenden Sie sich an Ihre zuständige Person."
|
||||
},
|
||||
"unauthorized": {
|
||||
"title": "Ungültige Anmeldedaten",
|
||||
"description": "Ihr Zugriffstoken ist abgelaufen",
|
||||
"hint": "Bitte melden Sie sich erneut an"
|
||||
},
|
||||
"server": {
|
||||
"title": "Serverfehler"
|
||||
}
|
||||
},
|
||||
"logo": {
|
||||
"icon": "Officer-Symbol",
|
||||
"logo": "Officer-Logo"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
{
|
||||
"button": {
|
||||
"cancel": "Cancelar",
|
||||
"confirm": "Confirmar",
|
||||
"ok": "Aceptar",
|
||||
"close": "Cerrar"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Buscar",
|
||||
"open": "Abrir búsqueda",
|
||||
"close": "Cerrar búsqueda",
|
||||
"clear": "Borrar búsqueda"
|
||||
},
|
||||
"select": {
|
||||
"placeholder": "Seleccionar..."
|
||||
},
|
||||
"tabs": {
|
||||
"filterPlaceholder": "Filtrar"
|
||||
},
|
||||
"commandBlock": {
|
||||
"run": "Ejecutar en el terminal",
|
||||
"copy": "Copiar comando"
|
||||
},
|
||||
"colorPicker": {
|
||||
"placeholder": "Color hexadecimal"
|
||||
},
|
||||
"state": {
|
||||
"loading": "Cargando…"
|
||||
},
|
||||
"dataTable": {
|
||||
"empty": "No se encontraron datos.",
|
||||
"pagination": {
|
||||
"showingPage": "Mostrando página {{current}} de {{total}}",
|
||||
"showingPageShort": "p {{current}}/{{total}}",
|
||||
"rowsPerPage": "Filas por página",
|
||||
"perPage": "{{size}} / página",
|
||||
"perPageShort": "{{size}}/p",
|
||||
"first": "Primera",
|
||||
"prev": "Anterior",
|
||||
"next": "Siguiente",
|
||||
"last": "Última"
|
||||
}
|
||||
},
|
||||
"pagination": {
|
||||
"label": "Paginación",
|
||||
"previous": "Anterior",
|
||||
"next": "Siguiente",
|
||||
"morePages": "Más páginas",
|
||||
"goToPrevious": "Ir a la página anterior",
|
||||
"goToNext": "Ir a la página siguiente"
|
||||
},
|
||||
"carousel": {
|
||||
"previous": "Diapositiva anterior",
|
||||
"next": "Diapositiva siguiente"
|
||||
},
|
||||
"breadcrumb": {
|
||||
"more": "Más"
|
||||
},
|
||||
"theme": {
|
||||
"toggle": "Alternar modo de color"
|
||||
},
|
||||
"error": {
|
||||
"forbidden": {
|
||||
"title": "Acceso no autorizado",
|
||||
"description": "No tienes autorización para realizar esta acción.",
|
||||
"hint": "Si crees que esto es un error, contacta con tu responsable."
|
||||
},
|
||||
"unauthorized": {
|
||||
"title": "Credenciales no válidas",
|
||||
"description": "Tu token de acceso ha caducado",
|
||||
"hint": "Inicia sesión de nuevo"
|
||||
},
|
||||
"server": {
|
||||
"title": "Error del servidor"
|
||||
}
|
||||
},
|
||||
"logo": {
|
||||
"icon": "Icono de Officer",
|
||||
"logo": "Logotipo de Officer"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
{
|
||||
"button": {
|
||||
"cancel": "Annuler",
|
||||
"confirm": "Confirmer",
|
||||
"ok": "OK",
|
||||
"close": "Fermer"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Rechercher",
|
||||
"open": "Ouvrir la recherche",
|
||||
"close": "Fermer la recherche",
|
||||
"clear": "Effacer la recherche"
|
||||
},
|
||||
"select": {
|
||||
"placeholder": "Sélectionner..."
|
||||
},
|
||||
"tabs": {
|
||||
"filterPlaceholder": "Filtrer"
|
||||
},
|
||||
"commandBlock": {
|
||||
"run": "Exécuter dans le terminal",
|
||||
"copy": "Copier la commande"
|
||||
},
|
||||
"colorPicker": {
|
||||
"placeholder": "Couleur hexadécimale"
|
||||
},
|
||||
"state": {
|
||||
"loading": "Chargement…"
|
||||
},
|
||||
"dataTable": {
|
||||
"empty": "Aucune donnée trouvée.",
|
||||
"pagination": {
|
||||
"showingPage": "Page {{current}} sur {{total}}",
|
||||
"showingPageShort": "p {{current}}/{{total}}",
|
||||
"rowsPerPage": "Lignes par page",
|
||||
"perPage": "{{size}} / page",
|
||||
"perPageShort": "{{size}}/p",
|
||||
"first": "Première",
|
||||
"prev": "Précédente",
|
||||
"next": "Suivante",
|
||||
"last": "Dernière"
|
||||
}
|
||||
},
|
||||
"pagination": {
|
||||
"label": "Pagination",
|
||||
"previous": "Précédent",
|
||||
"next": "Suivant",
|
||||
"morePages": "Plus de pages",
|
||||
"goToPrevious": "Aller à la page précédente",
|
||||
"goToNext": "Aller à la page suivante"
|
||||
},
|
||||
"carousel": {
|
||||
"previous": "Diapositive précédente",
|
||||
"next": "Diapositive suivante"
|
||||
},
|
||||
"breadcrumb": {
|
||||
"more": "Plus"
|
||||
},
|
||||
"theme": {
|
||||
"toggle": "Basculer le mode de couleur"
|
||||
},
|
||||
"error": {
|
||||
"forbidden": {
|
||||
"title": "Accès non autorisé",
|
||||
"description": "Vous n'êtes pas autorisé à effectuer cette action.",
|
||||
"hint": "Si vous pensez qu'il s'agit d'une erreur, contactez votre responsable."
|
||||
},
|
||||
"unauthorized": {
|
||||
"title": "Identifiants invalides",
|
||||
"description": "Votre jeton d'accès a expiré",
|
||||
"hint": "Reconnectez-vous"
|
||||
},
|
||||
"server": {
|
||||
"title": "Erreur du serveur"
|
||||
}
|
||||
},
|
||||
"logo": {
|
||||
"icon": "Icône Officer",
|
||||
"logo": "Logo Officer"
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,19 @@
|
||||
import type { TranslationMap } from 'i18n';
|
||||
import deDE from './de-DE.json';
|
||||
import enGB from './en-GB.json';
|
||||
import esES from './es-ES.json';
|
||||
import frFR from './fr-FR.json';
|
||||
import ptPT from './pt-PT.json';
|
||||
import svSE from './sv-SE.json';
|
||||
|
||||
/** Shared components' own catalog, per locale — merged into the app-wide map by `I18nBridge` under `ui.*`. */
|
||||
export const uiCatalogs: TranslationMap = { 'en-GB': enGB, 'pt-PT': ptPT };
|
||||
export const uiCatalogs: TranslationMap = {
|
||||
'en-GB': enGB,
|
||||
'pt-PT': ptPT,
|
||||
'es-ES': esES,
|
||||
'de-DE': deDE,
|
||||
'fr-FR': frFR,
|
||||
'sv-SE': svSE,
|
||||
};
|
||||
|
||||
export { useTranslation } from './useTranslation';
|
||||
|
||||
@@ -1,12 +1,37 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { findMissingKeys } from 'i18n';
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { findMissingKeys, findExtraKeys } from 'i18n';
|
||||
import en from './en-GB.json';
|
||||
import ptPT from './pt-PT.json';
|
||||
|
||||
// D2: every shipped locale ships a COMPLETE catalog. Phase 0 scaffolds this catalog with no keys yet
|
||||
// (ui.* strings land in phase 1) — the test still runs so it is load-bearing from day one.
|
||||
/**
|
||||
* Parity, over whatever locales are actually ON DISK — not a list written here.
|
||||
*
|
||||
* This used to name its locales literally, and that made it a check you had to remember to update. Adding
|
||||
* `sv-SE` added a file no parity test looked at, so it shipped out of step and every test stayed green.
|
||||
* A guard that needs maintaining to keep guarding is the thing it was written to prevent.
|
||||
*
|
||||
* Both directions, too. `findMissingKeys` alone reports half of a rename: the new key is missing from the
|
||||
* translation and caught, while the stale one it still carries is invisible. That is exactly how sv-SE
|
||||
* ended up naming two keys en-GB did not have.
|
||||
*/
|
||||
const LOCALES: Record<string, Record<string, unknown>> = Object.fromEntries(
|
||||
readdirSync(__dirname)
|
||||
.filter((f) => f.endsWith('.json') && f !== 'en-GB.json')
|
||||
.map((f) => [f.replace(/\.json$/, ''), JSON.parse(readFileSync(join(__dirname, f), 'utf8'))]),
|
||||
);
|
||||
|
||||
describe('shared components (ui.*) locale catalogs', () => {
|
||||
test('pt-PT has every key en-GB has', () => {
|
||||
expect(findMissingKeys(en, { 'pt-PT': ptPT })).toEqual([]);
|
||||
test('there is at least one locale besides en-GB', () => {
|
||||
// Otherwise every assertion below passes over an empty set and says nothing.
|
||||
expect(Object.keys(LOCALES).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('every locale has every key en-GB has', () => {
|
||||
expect(findMissingKeys(en, LOCALES)).toEqual([]);
|
||||
});
|
||||
|
||||
test('no locale has a key en-GB does not', () => {
|
||||
expect(findExtraKeys(en, LOCALES)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
{
|
||||
"button": {
|
||||
"cancel": "Avbryt",
|
||||
"confirm": "Bekräfta",
|
||||
"ok": "Ok",
|
||||
"close": "Stäng"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Sök",
|
||||
"open": "Öppna sökning",
|
||||
"close": "Stäng sökning",
|
||||
"clear": "Rensa sökning"
|
||||
},
|
||||
"select": {
|
||||
"placeholder": "Välj..."
|
||||
},
|
||||
"tabs": {
|
||||
"filterPlaceholder": "Filtrera"
|
||||
},
|
||||
"commandBlock": {
|
||||
"run": "Kör i terminalen",
|
||||
"copy": "Kopiera kommando"
|
||||
},
|
||||
"colorPicker": {
|
||||
"placeholder": "Hexfärg"
|
||||
},
|
||||
"state": {
|
||||
"loading": "Läser in…"
|
||||
},
|
||||
"dataTable": {
|
||||
"empty": "Ingen data hittades.",
|
||||
"pagination": {
|
||||
"showingPage": "Visar sida {{current}} av {{total}}",
|
||||
"showingPageShort": "s {{current}}/{{total}}",
|
||||
"rowsPerPage": "Rader per sida",
|
||||
"perPage": "{{size}} / sida",
|
||||
"perPageShort": "{{size}}/s",
|
||||
"first": "Första",
|
||||
"prev": "Föreg.",
|
||||
"next": "Nästa",
|
||||
"last": "Sista"
|
||||
}
|
||||
},
|
||||
"pagination": {
|
||||
"label": "Sidnumrering",
|
||||
"previous": "Föregående",
|
||||
"next": "Nästa",
|
||||
"morePages": "Fler sidor",
|
||||
"goToPrevious": "Gå till föregående sida",
|
||||
"goToNext": "Gå till nästa sida"
|
||||
},
|
||||
"carousel": {
|
||||
"previous": "Föregående bild",
|
||||
"next": "Nästa bild"
|
||||
},
|
||||
"breadcrumb": {
|
||||
"more": "Mer"
|
||||
},
|
||||
"theme": {
|
||||
"toggle": "Växla färgläge"
|
||||
},
|
||||
"error": {
|
||||
"forbidden": {
|
||||
"title": "Obehörig åtkomst",
|
||||
"description": "Du har inte behörighet att utföra denna åtgärd.",
|
||||
"hint": "Om du tror att detta är ett fel, kontakta din ansvariga person."
|
||||
},
|
||||
"unauthorized": {
|
||||
"title": "Ogiltiga inloggningsuppgifter",
|
||||
"description": "Din åtkomsttoken har gått ut",
|
||||
"hint": "Logga in igen"
|
||||
},
|
||||
"server": {
|
||||
"title": "Serverfel"
|
||||
}
|
||||
},
|
||||
"logo": {
|
||||
"icon": "Officer-ikon",
|
||||
"logo": "Officer-logotyp"
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user