diff --git a/CHANGELOG.md b/CHANGELOG.md index 59c85cc..8abe133 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,81 @@ project versions the ARTIFACT, not the repository: a release is a ZIP an operator can install, so anything that does not change the ZIP does not get a version. +## 0.3.3 — 2026-08-21 + +Documentation only — no plugin code changed. Core PR +[byte5ai/omadia#824](https://github.com/byte5ai/omadia/pull/824) (epic #470 C16) +gave the two operator grants a UI and one API route, so every place this plugin +told an operator to write an `INSERT` by hand and restart the middleware was +saying something that is no longer true. The artifact is re-cut because the +in-ZIP `README.md` and the manifest's `setup.guide` — which an operator reads +*inside* the install wizard, on the step before the one that now asks — are part +of the ZIP. + +### Changed + +- **The grants are documented as a UI flow, not a database procedure.** + [Operator Guide §4](./docs/OPERATOR-GUIDE.md#4-the-two-operator-grants) now + leads with the install wizard's **Permissions** step and the plugin page's + **Permissions** panel (`#grants`), and states that both take effect in + process — **no middleware restart**. The unified route + `GET|PUT /api/v1/admin/runtime/installed/:id/grants` with + `{ sql?, public_paths? }` is kept as the automation path, alongside the + older `…/public-paths` route, which #824 preserves unchanged as an alias. + + The same rewrite lands in `README.md`, the in-ZIP + `packages/plugin/README.md`, and `setup.guide` **en + de** in the manifest. + The German copy uses the shipped UI labels (*Freigeben & aktivieren*, + *Überspringen*, *Übernehmen*), so the guide and the buttons next to it agree. + +- **Skipping the SQL grant is documented as a supported outcome.** This plugin + reaches for the database in `activate()`, so declining leaves it `errored` — + and the wizard says so rather than guessing. That is now written down, with + the recovery (grant from the panel; no reinstall, no restart), because an + operator who meets `errored` without having been told to expect it reads it + as a broken install. + +- **Uninstall purges both grant tables** on a core with #824, so a reinstall + starts un-granted and asks again instead of inheriting the previous package's + database access and unauthenticated surface. §10 said the opposite, which was + true when it was written. + +- **`scripts/acceptance-local.mjs` drives the real operator path.** It probes + for the unified route and, when the core under test ships it, grants **after** + install over that route — the same call the wizard's *Grant & activate* + makes — and asserts the plugin comes back `active` out of the PUT, which is + the in-process re-activation actually working. Without the route it falls + back to the pre-#824 pair (hand INSERT before activation, `…/public-paths` + after) and the report names which ran. `§3.15` stops being permanently + undecided: purged grant rows are a PASS, rows left behind are a FAIL on a + core that promised to clear them and a BLOCKED on one that never did. + + A consequence worth stating: on a #824 core the install now legitimately + reports `errored` between *configure* and *grant*, so the harness treats that + as the documented state rather than a failure. Calling it a FAIL would turn + the run red on the exact flow it exists to prove. + +- **`compat.core` is unchanged** at `>=1.5 <2.0`. #824 changed no + `@omadia/plugin-api` type, so there is no version to name and nothing to + raise: it changed how an operator *answers* the permissions, not what this + manifest declares. The manifest comment now records that, next to the C11 and + C15 entries, so the next person does not have to re-derive it. + +### Known + +- **Cores older than #824 are unaffected and still documented.** The hand + `INSERT` plus restart moved to + [Operator Guide Appendix A](./docs/OPERATOR-GUIDE.md#appendix-a--older-cores) + rather than being deleted — a plugin release outlives the core release it was + written against, and the version number cannot distinguish them. §4 gives the + one-line probe that can: `GET …/grants` answers 200 on a core that has it. + +- **G4 is only partly closed.** #824 made the *re-activation* path truthful, so + a grant applied from the panel records `errored` with its reason instead of + swallowing the hook's failure. The **install** path still writes `active` + before running the hook, so the original sharp edge survives on a fresh + install (§12). + ## 0.3.2 — 2026-08-21 The runner image is now published **automatically from this repository**. No diff --git a/README.md b/README.md index 054fe3b..ccf5524 100644 --- a/README.md +++ b/README.md @@ -214,8 +214,12 @@ It is idempotent and its exit code equals the FAIL count. Recorded runs: - **Via direct upload:** upload the ZIP in the Omadia admin UI. **Two grants are required after install** and neither is implied by installing -— the SQL permission (which core has no UI for yet) and the public-path -consents. Both are in +— the SQL permission and the public-path consents. Both are answered in the +admin UI: a **Permissions** step in the install wizard, and a **Permissions** +panel on the plugin page for changing your mind later. They take effect in +process, so no restart is involved. Details, the automation route, and the +hand-INSERT procedure for cores older than +[byte5ai/omadia#824](https://github.com/byte5ai/omadia/pull/824) are in [Operator Guide §4](./docs/OPERATOR-GUIDE.md#4-the-two-operator-grants). **Never re-upload a changed ZIP under the same version.** The already-`import()`ed diff --git a/docs/OPERATOR-GUIDE.md b/docs/OPERATOR-GUIDE.md index 18bd839..17fcff0 100644 --- a/docs/OPERATOR-GUIDE.md +++ b/docs/OPERATOR-GUIDE.md @@ -16,6 +16,7 @@ the runbook. - [10. Uninstall and purge](#10-uninstall-and-purge) - [11. Troubleshooting](#11-troubleshooting) - [12. Known open issues](#12-known-open-issues) +- [Appendix A — Older cores](#appendix-a--older-cores) --- @@ -69,42 +70,80 @@ directory. ## 4. The two operator grants The manifest *asks*; a human has to *agree*. Neither grant is implied by -installing. - -### 4.1 The SQL permission (`permissions.sql`) — no UI yet - -Core provisions `ctx.sql` only when a row exists in `plugin_sql_grants` whose -`ledger` matches the manifest exactly. - -> **Core ships no surface for this.** Not a UI, not an API route — -> `middleware/src/platform/pluginSqlGrants.ts` says so twice in its own source: -> *"`plugin_sql_grants` still has no shipped grant surface — nothing in `src/` -> calls `grant()`."* There is a hardcoded ramp for four **bundled** core -> plugins; `@omadia/dev-platform` is installed, not bundled, so it is not on it -> and could not be. - -Until core ships one, insert the row by hand: - -```sql -INSERT INTO plugin_sql_grants (plugin_id, ledger, granted_by) -VALUES ('@omadia/dev-platform', - 'plg_omadia_dev_platform_migrations', - 'you@example.com'); -``` - -Then restart the middleware. The ledger name must match -`permissions.sql.ledger` character for character — core compares the two and -treats any difference as no grant at all. +installing — but both are now answered in the admin UI, in the install wizard +or on the plugin page, and neither needs a middleware restart. + +> **Does your core have this?** The consent surface arrived with core PR +> [byte5ai/omadia#824](https://github.com/byte5ai/omadia/pull/824) (epic #470 +> C16). No `@omadia/plugin-api` type changed with it, so the version number +> cannot answer the question — ask the server: +> +> ```bash +> curl -X GET "$BASE/api/v1/admin/runtime/installed/@omadia%2Fdev-platform/grants" +> ``` +> +> `200` with a `declared` block means you have it. `404` means you do not — use +> [Appendix A](#appendix-a--older-cores), which is the procedure this section +> documented before #824. + +### 4.1 During install — the "Permissions" step + +After the setup form is filled in and accepted, the wizard shows a +**Permissions** step. It lists what this manifest asks for and nothing else: + +- **Own database tables** — the SQL permission, naming the migration ledger + `plg_omadia_dev_platform_migrations`. The plugin creates and migrates its own + nine tables and cannot reach omadia's or another plugin's. +- **One row per public path** — the three prefixes in §4.3, each stating that it + answers without an omadia session and that declining leaves it behind the + login. +- **Optional prerequisites** — `turnContext@1`, `githubAppJwt@1`, + `usageTelemetry@1`, `conductorRoles@1`, listed *without* checkboxes. They are + not permissions and there is nothing here for you to grant (§8). + +The boxes start ticked, because ticked is what the manifest asked for. +**Grant & activate** records the consent and activates the plugin in the same +request, then shows the state the server read back — not an assumption that it +worked. + +**Skip** is a real option and does not lose the install. It grants nothing, and +because this plugin reaches for the database in `activate()`, skipping leaves it +`errored` and the wizard says so rather than guessing. Grant it later from the +panel below: no reinstall, no restart. + +### 4.2 Afterwards — the Grants panel + +**Admin → Plugins → Dev Platform**, section **Permissions** (anchor `#grants`). +It shows what is granted against what the manifest declares, one toggle per +grant, and **Apply** saves and re-activates in place. + +This is also where a grant is withdrawn. Turning the SQL permission off and +applying leaves the plugin `errored` — the honest answer, because it cannot run +without its tables — and the panel names the reason. + +The panel starts from what is **granted**, not from what is declared, so a grant +you declined reads as declined instead of arriving pre-ticked. + +A grant on record for a ledger the manifest no longer declares gets its own +line. That is a different problem from "not granted" and needs a different fix: +tick the box to grant the ledger the plugin now asks for. + +### 4.3 What you are agreeing to + +**The SQL permission (`permissions.sql`).** Core provisions `ctx.sql` only +against a grant whose ledger matches the manifest character for character; any +difference is not a partial grant, it is no grant. The ledger is taken from the +manifest and the granting identity from your session — neither is anything a +request body can dictate. The name is not arbitrary: core derives `plg__` from the id *it* knows and rejects anything outside it. `plg_` is kernel-reserved so no core table can live there, and the folded id means one plugin cannot nominate another's ledger and forge its migration history. -### 4.2 The public-path grants (`permissions.public_paths`) - -Three prefixes are served without a kernel session, because the caller has none -to present. Each is still authenticated: +**The public-path grants (`permissions.public_paths`).** Three prefixes are +served without a kernel session, because the caller has none to present. Each is +still authenticated: | Prefix | Authenticated by | |---|---| @@ -112,24 +151,56 @@ to present. Each is still authenticated: | `/api/webhooks/github` | HMAC-SHA256 over the raw request body. | | `/api/v1/dev-platform` | The GitHub App manifest-conversion callback, bound to a kernel-signed, plugin-audience state token. | -This grant **does** have a shipped surface, behind operator auth: +Without these grants the runner cannot phone home, webhooks 401, and GitHub App +onboarding cannot complete. An ungranted public path is deliberately **not** an +activation failure: the route falls through to `requireAuth` and answers 401, +which is both the fail-closed direction and what every plugin installed before +this surface existed already depends on. + +**Consent can never exceed the declaration.** Core refuses +`runtime.sql_not_declared` and `runtime.public_path_not_declared` for anything +this manifest does not ask for, so the consent surface cannot itself be used to +hand a plugin your database or make an arbitrary URL public. + +### 4.4 For automation — the admin API + +The UI calls one route, and so can you. Both grants travel together, behind +operator auth: ```bash -# What the plugin asks for, and what you have already granted -curl -X GET "$BASE/api/v1/admin/runtime/installed/@omadia%2Fdev-platform/public-paths" +# What the manifest asks for, what is granted, the resulting state, and what is +# still missing +curl -X GET "$BASE/api/v1/admin/runtime/installed/@omadia%2Fdev-platform/grants" + +# Grant both. Takes effect in-process — no middleware restart. +curl -X PUT "$BASE/api/v1/admin/runtime/installed/@omadia%2Fdev-platform/grants" \ + -H 'Content-Type: application/json' \ + -d '{"sql":true, + "public_paths":["/api/v1/dev-runner","/api/webhooks/github","/api/v1/dev-platform"]}' +``` + +An **absent** key leaves that grant alone, which is how the panel's per-grant +toggle sends one at a time. A **present** `public_paths` is the complete set you +consent to — omitting a prefix revokes it. The response is the state read back +from the registry after the re-activation, including `last_activation_error` +when the plugin did not come back up. + +Refusals worth knowing: `409 runtime.ledger_already_owned` (another plugin holds +that table), `503` (no database is configured, so nothing was recorded), and the +two `not_declared` codes above. -# Grant. This is the COMPLETE set — omitting a prefix revokes it. +The older route keeps its exact request and response shape as an alias over the +same core, so existing automation does not need rewriting: + +```bash curl -X PUT "$BASE/api/v1/admin/runtime/installed/@omadia%2Fdev-platform/public-paths" \ -H 'Content-Type: application/json' \ -d '{"paths":["/api/v1/dev-runner","/api/webhooks/github","/api/v1/dev-platform"]}' ``` -Consent cannot exceed the declaration — core rejects with -`runtime.public_path_not_declared` any prefix this manifest does not request, so -the consent endpoint can never itself be used to make an arbitrary URL public. - -Without these grants: the runner cannot phone home, webhooks 401, and GitHub App -onboarding cannot complete. +`node scripts/acceptance-local.mjs` drives the unified route when the core under +test ships it and falls back to the pre-#824 pair when it does not, so the +acceptance run exercises whichever path a real operator would be using. ## 5. Credentials — you enter these yourself @@ -416,9 +487,13 @@ and granted public paths stop being honoured (`/api/v1/dev-runner/...` goes What survives: all nine `dev_*` tables and every row, plus the migration ledger. -> Grant rows are **not** revoked on uninstall — `plugin_sql_grants` and -> `plugin_public_path_grants` keep their rows. Orphaned, not a live hole: the -> runtime stops honouring them. The lifecycle decision is still open upstream. +> **Grants do not survive.** Since core #824 (§4), uninstall purges +> `plugin_sql_grants` and `plugin_public_path_grants` alongside the vault, so a +> reinstall under the same id starts un-granted and **asks you again** — it does +> not inherit the previous package's database access or its unauthenticated +> surface. On a core without #824 the rows stay behind instead: orphaned rather +> than a live hole, because the runtime stops honouring them, but they are worth +> deleting by hand if the plugin is gone for good. To actually destroy the data, call the purge route — explicit, destructive and type-to-confirm: @@ -458,7 +533,19 @@ this plugin only `graphPool@1` can trigger it; the other four moved to **Activation fails naming the ledger.** The ledger must sit inside `plg_omadia_dev_platform_` and match `permissions.sql.ledger` exactly. A grant -row with a different ledger name is not a partial grant — it is no grant. +row with a different ledger name is not a partial grant — it is no grant. Since +core #824 the error names the admin route that fixes it, and the Grants panel +(§4.2) shows the mismatch as its own line rather than as a plain "not granted". + +**The plugin reads `errored` right after install.** Most likely the Permissions +step was skipped: this plugin reaches for the database in `activate()`, so +without the SQL grant it rolls itself back. Grant it in the Grants panel (§4.2) +— the plugin is re-activated in place, and no reinstall or restart is needed. + +**A grant was applied and nothing changed.** Read the response, not the absence +of an error: the route returns the state it read back from the registry after +re-activating, with `last_activation_error` populated when the plugin failed to +come back up. A 503 means no database is configured and nothing was recorded. **Every route 404s but the plugin reports `status: "active"`.** Known core bug (G4, §12). The install path writes `status: 'active'` before running the @@ -487,7 +574,7 @@ Use a GitHub App or a PAT. | # | Issue | Status | |---|---|---| -| **G4** | A failed activation still reports `status: "active"`. The install path writes the status before the `onInstalled` hook and never revises it. Makes every other verdict less trustworthy. | **Open** in core | +| **G4** | A failed activation still reports `status: "active"`. The install path writes the status before the `onInstalled` hook and never revises it. Makes every other verdict less trustworthy. | **Partly fixed.** Core #824 made the *re-activation* path truthful — a grant applied from §4's panel records `errored` with the reason instead of swallowing the hook's failure. The **install** path still writes `active` first, so the sharp edge remains on a fresh install. | | **G6** | Core's `publicPaths.ts` carried two static dev-platform exemptions that collided with this plugin's declarations. This single residue caused all 33 failures of the 2026-08-21 run against `main`. | **Fixed on core `main`** — C12, core PR #807 (`e1e31f62`, 2026-08-21). Use a core at or after that commit. | | **G7** | Core's pre-activate migration run happened **before** `activate()`, pre-empting the C11 handoff: `seedLedger` found all nine already applied and `skippedNoWitness` — the one alarm the feature exists to raise — never fired. The 2026-08-21 run measured `0 seeded, 9 already seeded` on the exact upgrade the feature was built for, and nothing went red. | **Fixed in 0.3.1** (C15, core issue byte5ai/omadia#814). `permissions.sql.handoff` declares the plan so the **kernel** runs it ahead of its own migration runner. Needs core with `@omadia/plugin-api` **1.6.0**; on anything older the key is ignored and the `activate()` fallback still runs — so on a core below 1.6.0 the gap remains, and the §7 dry run is the way to see it. | @@ -501,6 +588,54 @@ Acceptance runs: Both reproduce with `node scripts/acceptance-local.mjs` (exit code equals the FAIL count), driven by `BASE_URL` and `DATABASE_URL`. +## Appendix A — Older cores + +**Only for a core without the consent surface** — one where +`GET /api/v1/admin/runtime/installed/@omadia%2Fdev-platform/grants` answers 404 +(§4). This was the whole of §4 before core PR +[byte5ai/omadia#824](https://github.com/byte5ai/omadia/pull/824), and it is kept +because a plugin release outlives the core release it was written against. + +**The SQL permission had no surface at all.** Not a UI, not an API route — +`middleware/src/platform/pluginSqlGrants.ts` says so twice in its own source: +*"`plugin_sql_grants` still has no shipped grant surface — nothing in `src/` +calls `grant()`."* The hardcoded ramp there covers four **bundled** core +plugins; `@omadia/dev-platform` is installed, not bundled, so it never was on it +and could not be. The row goes in by hand, from a client with database access: + +```sql +INSERT INTO plugin_sql_grants (plugin_id, ledger, granted_by) +VALUES ('@omadia/dev-platform', + 'plg_omadia_dev_platform_migrations', + 'you@example.com'); +``` + +**Then restart the middleware.** `ctx.services.get` is synchronous, so the grant +is read once, before the plugin's context is built — a row written afterwards +reaches a context that has already decided. Insert it *before* installing, or +restart after. This is the step #824 removed, and the reason it was worth +removing: an operator who has to schedule a restart to find out whether a +permission was needed learns to grant everything up front. + +The ledger name must match `permissions.sql.ledger` character for character +(§4.3 explains why core constrains the name at all). + +**The public-path grants did have a route**, and it still works unchanged on +every core, new and old: + +```bash +curl -X GET "$BASE/api/v1/admin/runtime/installed/@omadia%2Fdev-platform/public-paths" + +curl -X PUT "$BASE/api/v1/admin/runtime/installed/@omadia%2Fdev-platform/public-paths" \ + -H 'Content-Type: application/json' \ + -d '{"paths":["/api/v1/dev-runner","/api/webhooks/github","/api/v1/dev-platform"]}' +``` + +`PUT` takes the COMPLETE set — omitting a prefix revokes it. + +**Uninstall leaves the grant rows behind** on these cores (§10). Delete them by +hand if the plugin is not coming back. + ## See also - [`SECRETS.md`](./SECRETS.md) — the full credential list and rotation diff --git a/packages/plugin/README.md b/packages/plugin/README.md index 94b61bd..78db1a4 100644 --- a/packages/plugin/README.md +++ b/packages/plugin/README.md @@ -35,15 +35,31 @@ provider, install is refused with `install.missing_capability` (HTTP 409). The manifest asks; a human has to agree. Neither is implied by installing, and without them the plugin does not work. -1. **`permissions.sql`** — core provisions `ctx.sql` only when a row exists in - `plugin_sql_grants` for ledger `plg_omadia_dev_platform_migrations`. - **Core ships no UI or API for this yet**, so the row is inserted by hand. +1. **`permissions.sql`** — the plugin owns nine tables and migrates them itself + into the ledger `plg_omadia_dev_platform_migrations`. It cannot reach + omadia's tables or another plugin's. 2. **`permissions.public_paths`** — three prefixes served without a kernel session (each still authenticated: a per-job bearer token, a webhook HMAC, - and a signed state token). Granted through - `PUT /api/v1/admin/runtime/installed/@omadia%2Fdev-platform/public-paths`. - -Exact steps for both: + and a signed state token). + +**Both are answered in the admin UI.** The install wizard shows a +**Permissions** step after configuration; **Grant & activate** records the +consent and activates in the same request, and **Skip** grants nothing without +losing the install. Afterwards, the plugin page has a **Permissions** panel +(`#grants`) with a toggle per grant. Applying either re-activates the plugin in +process — **no middleware restart**. + +Skipping the SQL grant leaves the plugin `errored`, because it reaches for the +database in `activate()`. That is recoverable from the panel at any time; it +does not need a reinstall. + +For automation, one route carries both: +`PUT /api/v1/admin/runtime/installed/@omadia%2Fdev-platform/grants` with +`{"sql":true,"public_paths":[…]}`. The older `…/public-paths` route still works +unchanged. + +Exact steps, refusal codes, and the hand-INSERT procedure for cores older than +[byte5ai/omadia#824](https://github.com/byte5ai/omadia/pull/824): [Operator Guide §4](../../docs/OPERATOR-GUIDE.md#4-the-two-operator-grants). ## Optional capabilities @@ -97,6 +113,11 @@ is lossless. To actually destroy the data there is an explicit, type-to-confirm purge route — see [Operator Guide §10](../../docs/OPERATOR-GUIDE.md#10-uninstall-and-purge). +**The grants do not survive.** Uninstall purges both grant tables, so a +reinstall starts un-granted and asks again rather than inheriting the previous +package's database access. (On cores older than #824 the rows stay behind +instead — orphaned, not honoured.) + ## License MIT © 2026 byte5 GmbH diff --git a/packages/plugin/manifest.yaml b/packages/plugin/manifest.yaml index babf35c..cddab66 100644 --- a/packages/plugin/manifest.yaml +++ b/packages/plugin/manifest.yaml @@ -64,7 +64,7 @@ identity: # settled on (specs/470-dev-platform-plugin/plan.md §4.1). There is no # "platform" kind. kind: "extension" - version: "0.3.2" + version: "0.3.3" domain: "dev-platform" description: "Runs autonomous coding jobs against your GitHub repositories. An agent clones a repo into an isolated runner, drives an LLM through an analyze/plan/implement/review pipeline, and opens a pull request — bounded at every step by human approval gates, a diff policy, a per-job cost budget and an egress allowlist. Ships its own operator UI, HTTP routes, chat tools, background workers and nine database tables it migrates itself. Runners execute on Fly Machines or on local Docker; neither ever sees a long-lived credential. Extracted from omadia core into its own installable plugin (epic byte5ai/omadia#470)." authors: @@ -106,6 +106,13 @@ compat: # thing that performs the handoff. The floor tracks what this plugin cannot # run without, not what it would prefer. # + # C16 (byte5ai/omadia#824 — the unified grant-consent route and the install + # wizard's Permissions step) does not move it either, and could not: it + # changed no plugin-api type, so there is no version to name. It is a change + # to how an operator ANSWERS the two permissions below, not to what this + # manifest declares. On a core without it the permissions are identical and + # the answer is a hand-written grant row — OPERATOR-GUIDE Appendix A. + # # NOTE, so nobody mistakes this for a gate: core does NOT enforce it. Nothing # in `middleware/src` semver-compares `compat_core` — `manifestLoader.ts:503` # and `registryClient.ts:415` only carry the string through to the store view @@ -273,31 +280,30 @@ setup: refuses to activate rather than pretending to work — set `DATABASE_URL` and install the Neon knowledge-graph plugin first. - ### Two operator grants — do these, or the plugin does not work + ### Two operator grants — the next step after this form This plugin declares two permissions that a manifest alone cannot give it. - Core requires a human to agree, separately, after install. + A human has to agree, separately, and neither is implied by installing. + + **You are asked on the next step.** Once this form is accepted the wizard + shows **Permissions**, listing exactly what is below and nothing else. + *Grant & activate* records your consent and activates the plugin in the + same request. *Skip* grants nothing and does not lose the install — the + same choices live on the plugin page afterwards, under **Permissions**, + with a toggle per grant. Either way the plugin is re-activated in place: + **no middleware restart.** **1. The SQL permission (`permissions.sql`).** The plugin owns nine tables and migrates them itself into its own ledger, - `plg_omadia_dev_platform_migrations`. Core provisions `ctx.sql` only when - a row exists in `plugin_sql_grants`. - - > **There is no button for this yet.** Core ships no UI and no API route - > that writes that row — `middleware/src/platform/pluginSqlGrants.ts` - > records it twice in its own source ("no shipped grant surface — nothing - > in `src/` calls `grant()`"). Until core ships one, an operator with - > database access inserts it by hand: - > - > ```sql - > INSERT INTO plugin_sql_grants (plugin_id, ledger, granted_by) - > VALUES ('@omadia/dev-platform', - > 'plg_omadia_dev_platform_migrations', - > 'you@example.com'); - > ``` - > - > The ledger name must match the manifest exactly — core compares them and - > treats a mismatch as no grant at all. Restart the middleware afterwards. + `plg_omadia_dev_platform_migrations`. It cannot read or write omadia's + tables or another plugin's. The ledger name comes from this manifest and + the granting identity from your session — neither can be supplied by a + request. + + > **This plugin reaches for the database in `activate()`,** so skipping + > this grant leaves it `errored` rather than half-working, and the wizard + > says so instead of guessing. Granting it later from the plugin page + > brings it up; no reinstall. **2. The public-path grants (`permissions.public_paths`).** Three URL prefixes are served without a kernel session, because the caller has no @@ -308,20 +314,34 @@ setup: - `/api/v1/dev-platform` — the GitHub App manifest-conversion callback, bound to a kernel-signed, plugin-audience state token. - This one **does** have a shipped surface, behind operator auth: + Declining one is not an activation failure: the prefix simply stays behind + the login and answers 401. Without them the runner cannot phone home, + webhooks 401, and GitHub App onboarding cannot complete. + + Consent can never exceed the declaration — core rejects anything this + manifest does not ask for, so the consent surface cannot itself be used to + hand a plugin your database or make an arbitrary URL public. + + For automation, one route carries both, behind operator auth: ``` - GET /api/v1/admin/runtime/installed/@omadia%2Fdev-platform/public-paths - PUT /api/v1/admin/runtime/installed/@omadia%2Fdev-platform/public-paths - { "paths": ["/api/v1/dev-runner", - "/api/webhooks/github", - "/api/v1/dev-platform"] } + GET /api/v1/admin/runtime/installed/@omadia%2Fdev-platform/grants + PUT /api/v1/admin/runtime/installed/@omadia%2Fdev-platform/grants + { "sql": true, + "public_paths": ["/api/v1/dev-runner", + "/api/webhooks/github", + "/api/v1/dev-platform"] } ``` - `PUT` takes the COMPLETE set you consent to — omitting a prefix revokes - it. Consent cannot exceed the declaration: core rejects any prefix this - manifest does not ask for. Without these grants the runner cannot phone - home, webhooks 401, and GitHub App onboarding cannot complete. + An absent key leaves that grant alone; a present `public_paths` is the + COMPLETE set, so omitting a prefix revokes it. The older + `…/public-paths` route still works unchanged. + + > **On a core older than byte5ai/omadia#824** none of the above exists: + > `GET …/grants` answers 404, the SQL grant is a hand-written row in + > `plugin_sql_grants` followed by a middleware restart, and only the + > `…/public-paths` route is available. The plugin's OPERATOR-GUIDE + > Appendix A has that procedure in full. ### What this plugin does without, and what you lose @@ -433,33 +453,32 @@ setup: verweigert das Plugin die Aktivierung, statt Betrieb vorzutäuschen — setze zuerst `DATABASE_URL` und installiere das Neon-Knowledge-Graph-Plugin. - ### Zwei Freigaben durch dich — ohne sie läuft das Plugin nicht + ### Zwei Freigaben durch dich — der nächste Schritt nach diesem Formular Das Manifest *fragt*; zustimmen muss ein Mensch. Keine der beiden Freigaben ergibt sich aus der Installation. + **Gefragt wirst du im nächsten Schritt.** Sobald dieses Formular + angenommen ist, zeigt der Assistent den Schritt **Berechtigungen** — mit + genau dem, was unten steht, und nichts sonst. *Freigeben & aktivieren* + speichert deine Zustimmung und aktiviert das Plugin im selben Request. + *Überspringen* gibt nichts frei und verwirft die Installation nicht — die + gleichen Schalter findest du danach auf der Plugin-Seite unter + **Berechtigungen**, einen pro Freigabe, mit *Übernehmen* zum Speichern. In + beiden Fällen wird das Plugin direkt neu aktiviert: **kein Neustart der + Middleware.** + **1. Die SQL-Berechtigung (`permissions.sql`).** Das Plugin besitzt neun Tabellen und migriert sie selbst in sein eigenes Ledger - `plg_omadia_dev_platform_migrations`. Core stellt `ctx.sql` nur bereit, - wenn in `plugin_sql_grants` eine passende Zeile existiert. - - > **Dafür gibt es noch keinen Button.** Core liefert weder UI noch - > API-Route, die diese Zeile schreibt — - > `middleware/src/platform/pluginSqlGrants.ts` hält das zweimal im eigenen - > Quelltext fest („no shipped grant surface — nothing in `src/` calls - > `grant()`"). Bis Core eine Oberfläche liefert, trägt sie jemand mit - > Datenbankzugriff von Hand ein: - > - > ```sql - > INSERT INTO plugin_sql_grants (plugin_id, ledger, granted_by) - > VALUES ('@omadia/dev-platform', - > 'plg_omadia_dev_platform_migrations', - > 'du@example.com'); - > ``` - > - > Der Ledger-Name muss exakt dem Manifest entsprechen — Core vergleicht - > beide und wertet jede Abweichung als gar keine Freigabe. Danach die - > Middleware neu starten. + `plg_omadia_dev_platform_migrations`. Auf Tabellen von omadia oder anderen + Plugins hat es keinen Zugriff. Der Ledger-Name stammt aus diesem Manifest, + die zustimmende Identität aus deiner Session — beides kann kein Request + vorgeben. + + > **Das Plugin greift in `activate()` auf die Datenbank zu.** Überspringst + > du diese Freigabe, bleibt es deshalb `errored`, statt halb zu laufen — + > und der Assistent sagt das, statt es zu raten. Später auf der + > Plugin-Seite erteilt, kommt es hoch; ohne Neuinstallation. **2. Die Public-Path-Freigaben (`permissions.public_paths`).** Drei Präfixe werden ohne Kernel-Session ausgeliefert, weil der Aufrufer keine @@ -470,21 +489,37 @@ setup: - `/api/v1/dev-platform` — der GitHub-App-Callback, gebunden an ein kernel-signiertes State-Token mit Plugin-Audience. - Hierfür gibt es eine fertige Schnittstelle hinter Operator-Auth: + Eine abgelehnte Freigabe ist kein Aktivierungsfehler: Das Präfix bleibt + dann einfach hinter dem Login und antwortet mit 401. Ohne die Freigaben + erreicht der Runner die Middleware nicht, Webhooks laufen auf 401, und das + GitHub-App-Onboarding kann nicht abschließen. + + Die Zustimmung kann die Deklaration nie überschreiten — Core lehnt alles + ab, was dieses Manifest nicht anfragt. Die Freigabe-Schnittstelle taugt + also selbst nicht dazu, einem Plugin deine Datenbank zu geben oder eine + beliebige URL öffentlich zu machen. + + Für Automatisierung trägt eine Route beides, hinter Operator-Auth: ``` - GET /api/v1/admin/runtime/installed/@omadia%2Fdev-platform/public-paths - PUT /api/v1/admin/runtime/installed/@omadia%2Fdev-platform/public-paths - { "paths": ["/api/v1/dev-runner", - "/api/webhooks/github", - "/api/v1/dev-platform"] } + GET /api/v1/admin/runtime/installed/@omadia%2Fdev-platform/grants + PUT /api/v1/admin/runtime/installed/@omadia%2Fdev-platform/grants + { "sql": true, + "public_paths": ["/api/v1/dev-runner", + "/api/webhooks/github", + "/api/v1/dev-platform"] } ``` - `PUT` erwartet die VOLLSTÄNDIGE Menge — ein weggelassenes Präfix wird - entzogen. Die Zustimmung kann die Deklaration nicht überschreiten: Core - lehnt jedes Präfix ab, das dieses Manifest nicht anfragt. Ohne diese - Freigaben erreicht der Runner die Middleware nicht, Webhooks laufen auf - 401, und das GitHub-App-Onboarding kann nicht abschließen. + Ein fehlender Schlüssel lässt die jeweilige Freigabe unangetastet; ein + vorhandenes `public_paths` ist die VOLLSTÄNDIGE Menge — ein weggelassenes + Präfix wird entzogen. Die alte Route `…/public-paths` funktioniert + unverändert weiter. + + > **Auf einem Core älter als byte5ai/omadia#824** existiert nichts davon: + > `GET …/grants` antwortet mit 404, die SQL-Freigabe ist eine von Hand + > eingetragene Zeile in `plugin_sql_grants` plus Neustart der Middleware, + > und verfügbar ist nur die Route `…/public-paths`. Anhang A im + > OPERATOR-GUIDE des Plugins beschreibt das vollständig. ### Worauf dieses Plugin verzichten kann — und was dich das kostet diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 92c3d39..90fc659 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,6 +1,6 @@ { "name": "@omadia/dev-platform", - "version": "0.3.2", + "version": "0.3.3", "private": true, "type": "module", "main": "dist/plugin.js", diff --git a/scripts/acceptance-local.mjs b/scripts/acceptance-local.mjs index 82e48d3..2f194eb 100644 --- a/scripts/acceptance-local.mjs +++ b/scripts/acceptance-local.mjs @@ -15,14 +15,24 @@ * ADMIN_PASSWORD default omadia-local-dev-1 * PLUGIN_ZIP default packages/plugin/out/-.zip * DATABASE_URL optional; enables the DB-backed rows (ledger, table counts, - * the SQL grant INSERT). Without it those rows report BLOCKED - * rather than silently passing. + * and the pre-C16 SQL grant INSERT). Without it those rows + * report BLOCKED rather than silently passing. Not needed for + * the grants themselves on a core with the C16 route. * PHASE `install` (default: full install→probe→uninstall→reinstall) * or `probe` (assume already installed; probe only) * * IDEMPOTENT. It uninstalls a previous install before installing, and tolerates * a missing one. Re-running it is the normal case, not a special case. * + * THE TWO GRANTS follow whichever surface the core under test ships. With core + * C16 (byte5ai/omadia#824) that is the unified + * `PUT …/installed/:id/grants` — the route the install wizard's Permissions + * step and the plugin page's Grants panel both call — driven AFTER install, in + * the operator's real order. Without it the run falls back to the pre-#824 + * pair: a hand INSERT into `plugin_sql_grants` before activation, plus the + * `…/public-paths` route after. The report names which one ran, because + * "granted" over two different mechanisms is two different claims. + * * EXIT CODE is the number of FAILed rows, capped at 250. BLOCKED does not fail * the run — a blocked row is a row this harness could not reach a verdict on, * and reporting it as a pass would be the failure mode the whole document @@ -201,11 +211,94 @@ async function upload(zipPath) { return { status: res.status, body }; } -/** The C7 SQL grant. There is NO HTTP endpoint for it — see the run report's - * core-gap list — so this goes in over SQL, and says so when it cannot. */ -async function grantSql() { +// --- the two operator grants ----------------------------------------------- +// +// Core C16 (byte5ai/omadia#824) gave both permissions ONE route and a UI that +// calls it. That route is what a real operator's clicks reach, so it is what +// this harness drives — the point of an acceptance run is to walk the path the +// operator walks, not a private shortcut only the harness knows. +// +// It cannot simply assume the route though. This plugin outlives the core +// release it was written against, and the pre-C16 procedure (a hand INSERT plus +// the `/public-paths` route) is still the only one an older core offers. So: +// probe once, prefer the unified route, fall back otherwise — and SAY which one +// ran, because "granted" over two different mechanisms is two different claims +// about the system under test. + +const GRANTS = `/api/v1/admin/runtime/installed/${PID}/grants`; +const DECLARED_PUBLIC_PATHS = ['/api/v1/dev-runner', '/api/webhooks/github', '/api/v1/dev-platform']; + +/** `true` / `false` once probed, `undefined` while still unknown. */ +let hasUnifiedGrants; + +/** + * Does this core ship the C16 route? Answered ONLY in the affirmative here. + * + * A missing route and a not-installed plugin both answer 404, and core's 404 + * body is not stable enough to tell them apart. This probe runs before the + * clean slate, when the previous run's install may or may not still be there, + * so a 404 proves nothing and is deliberately not recorded as a `false`. What + * a 200 carrying a `declared` block does prove is that the route exists — and + * that is the only thing worth changing the run's ordering over. + * + * Left `undefined`, the run keeps the pre-C16 ordering, which is correct on + * every core; `grantUnified()` then settles the question after install, where + * a 404 IS conclusive. + */ +async function probeUnifiedGrants() { + const { status: s, body } = await json(GRANTS); + if (s === 200 && body && typeof body === 'object' && body.declared) hasUnifiedGrants = true; + return hasUnifiedGrants; +} + +/** Both grants over the C16 route, exactly as the wizard's "Grant & activate" + * and the plugin page's Grants panel do. Returns false when the route is not + * there, so the caller can fall back. */ +async function grantUnified() { + const { status: s, body } = await json(GRANTS, { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ sql: true, public_paths: DECLARED_PUBLIC_PATHS }), + }); + if (s === 404) { + hasUnifiedGrants = false; + return false; + } + hasUnifiedGrants = true; + if (s !== 200) { + fail('§3.8', 'both grants (C16 unified PUT)', `HTTP ${s} ${JSON.stringify(body).slice(0, 200)}`); + return true; + } + + // The route answers with the state it read back AFTER re-activating. Trust + // that, not the 200: a 200 proves the write landed, not that the plugin came + // back up, and the whole reason C16 re-activates in-process is so the answer + // to "did the grant take?" is available now rather than after a restart. + const g = body.granted ?? {}; + const paths = g.public_paths ?? []; + const missing = DECLARED_PUBLIC_PATHS.filter((p) => !paths.includes(p)); + if (g.sql === true && missing.length === 0) { + pass('§3.8', 'both grants (C16 unified PUT)', `sql=true, ${paths.length} public paths, state=${body.state}`); + } else { + fail('§3.8', 'both grants (C16 unified PUT)', `sql=${g.sql} missing=${JSON.stringify(missing)} state=${body.state}`); + } + + if (body.state === 'active') { + pass('§3.8', 'grant takes effect in-process (no restart)', 'state=active straight out of the PUT'); + } else { + fail('§3.8', 'grant takes effect in-process (no restart)', + `state=${body.state} error=${JSON.stringify(body.last_activation_error).slice(0, 200)}`); + } + return true; +} + +/** Pre-C16 SQL grant: no HTTP endpoint exists, so it goes in over SQL and says + * so when it cannot. Must run BEFORE activation — `ctx.services.get` is + * synchronous, so the grant is read once while the plugin's context is built + * and a row written afterwards reaches a context that has already decided. */ +async function grantSqlLegacy() { if (!process.env['DATABASE_URL']) { - blocked('§3.8', 'SQL grant (permissions.sql)', 'no DATABASE_URL and core exposes no HTTP grant endpoint'); + blocked('§3.8', 'SQL grant (permissions.sql)', 'no DATABASE_URL — the pre-C16 grant path is a direct INSERT and needs one'); return; } await db( @@ -213,21 +306,37 @@ async function grantSql() { ON CONFLICT (plugin_id) DO UPDATE SET ledger=EXCLUDED.ledger, granted_at=now()`, [PLUGIN_ID, LEDGER, EMAIL], ); - pass('§3.8', 'SQL grant (permissions.sql)', 'inserted into plugin_sql_grants (no HTTP endpoint exists)'); + pass('§3.8', 'SQL grant (permissions.sql)', 'inserted into plugin_sql_grants (pre-C16 core: no HTTP endpoint)'); } -async function grantPublicPaths() { - const declared = ['/api/v1/dev-runner', '/api/webhooks/github', '/api/v1/dev-platform']; +/** The C4 route. Kept as a thin alias by C16, so this exercises the promise + * that pre-#824 automation still works — on both cores. */ +async function grantPublicPathsLegacy() { const { status: s, body } = await json(`/api/v1/admin/runtime/installed/${PID}/public-paths`, { method: 'PUT', headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ paths: declared }), + body: JSON.stringify({ paths: DECLARED_PUBLIC_PATHS }), }); - if (s === 200) pass('§3.8', 'public-path consent (C4 PUT)', `granted ${declared.length}: ${JSON.stringify(body.paths ?? body)}`); + if (s === 200) pass('§3.8', 'public-path consent (C4 PUT)', `granted ${DECLARED_PUBLIC_PATHS.length}: ${JSON.stringify(body.paths ?? body)}`); else fail('§3.8', 'public-path consent (C4 PUT)', `HTTP ${s} ${JSON.stringify(body).slice(0, 160)}`); } -async function install() { +/** Everything a plugin needs granted, over whichever surface this core has. + * Called AFTER install, which is the operator's real order on a C16 core. */ +async function grantAll() { + if (hasUnifiedGrants !== false && (await grantUnified())) return; + await grantPublicPathsLegacy(); +} + +/** + * @param {{ sqlGrantedBeforeActivation?: boolean }} opts + * Whether the SQL grant is already on record when `activate()` runs. On a + * pre-C16 core it always is — the harness inserts the row first, because + * nothing can re-activate the plugin afterwards. On a C16 core it deliberately + * is NOT: the operator answers the wizard's Permissions step after configure, + * and this run walks the same order. + */ +async function install({ sqlGrantedBeforeActivation = true } = {}) { const create = await postJson(`/api/v1/install/plugins/${PID}`, {}); if (create.status !== 201) { fail('§3.6', 'install job created', `HTTP ${create.status} ${JSON.stringify(create.body).slice(0, 240)}`); @@ -241,8 +350,20 @@ async function install() { values: { llm_allowed_models: ['claude-sonnet-4-5-20250929'], runner_base_url: BASE }, }); const st = conf.body?.job?.state; - if (st === 'active') pass('§3.9', 'activation', `state=active, no error`); - else fail('§3.9', 'activation', `state=${st} error=${JSON.stringify(conf.body?.job?.error).slice(0, 300)}`); + const err = JSON.stringify(conf.body?.job?.error).slice(0, 300); + if (st === 'active') { + pass('§3.9', 'activation', 'state=active, no error'); + } else if (!sqlGrantedBeforeActivation && st === 'errored') { + // Not a failure — the documented consequence of installing before granting. + // This plugin reaches for the database in `activate()`, so with the C16 + // wizard the honest state between "configured" and "granted" is `errored`, + // and the grant PUT that follows is what has to bring it back up. Calling + // this a FAIL would make the harness red on the exact flow it is meant to + // prove works. + pass('§3.9', 'activation', `state=errored before the grant, as documented — ${err.slice(0, 140)}`); + } else { + fail('§3.9', 'activation', `state=${st} error=${err}`); + } return job.id; } @@ -402,19 +523,32 @@ async function probeUninstallCycle() { if (before && a.repos === before[0].n) pass('D3', 'row data survives uninstall', `dev_repos ${before[0].n} → ${a.repos}`); } else blocked('D3', 'uninstall KEEPS the 9 tables + ledger', 'no DATABASE_URL'); + // C16 B6 settled the lifecycle acceptance.md §3.15 left open: uninstall + // purges BOTH grant tables, so a reinstall under the same id starts + // un-granted instead of inheriting the previous package's database access and + // unauthenticated surface. On a pre-C16 core the rows stay — orphaned rather + // than live, since the runtime stops honouring them — so the verdict is a + // BLOCKED there and a real FAIL on a core that promised to clear them. const orphanSql = await db(`SELECT count(*)::int AS n FROM plugin_sql_grants WHERE plugin_id=$1`, [PLUGIN_ID]).catch(() => undefined); const orphanPub = await db(`SELECT count(*)::int AS n FROM plugin_public_path_grants WHERE plugin_id=$1`, [PLUGIN_ID]).catch(() => undefined); if (orphanSql && orphanPub) { - record('§3.15', 'grant rows after uninstall (lifecycle undecided)', - orphanSql[0].n === 0 && orphanPub[0].n === 0 ? 'PASS' : 'BLOCKED', - `plugin_sql_grants=${orphanSql[0].n} plugin_public_path_grants=${orphanPub[0].n} — acceptance.md §3.15 leaves this unanswered`); + const cleared = orphanSql[0].n === 0 && orphanPub[0].n === 0; + const counts = `plugin_sql_grants=${orphanSql[0].n} plugin_public_path_grants=${orphanPub[0].n}`; + if (cleared) { + pass('§3.15', 'uninstall purges the grant rows (C16 B6)', `${counts} — a reinstall asks again`); + } else if (hasUnifiedGrants) { + fail('§3.15', 'uninstall purges the grant rows (C16 B6)', `${counts} — this core ships C16 and should have cleared both`); + } else { + blocked('§3.15', 'uninstall purges the grant rows (C16 B6)', `${counts} — pre-C16 core keeps them; orphaned, not honoured`); + } } - // reinstall, lossless - await grantSql(); - const jobId = await install(); + // reinstall, lossless — and, on a C16 core, un-granted, so the grants have to + // be given again exactly as they were the first time. + if (hasUnifiedGrants !== true) await grantSqlLegacy(); + const jobId = await install({ sqlGrantedBeforeActivation: hasUnifiedGrants !== true }); if (jobId) { - await grantPublicPaths(); + await grantAll(); const led = await db(`SELECT count(*)::int AS n FROM ${LEDGER}`).catch(() => undefined); const repos = await db(`SELECT count(*)::int AS n FROM dev_repos`).catch(() => undefined); if (led && repos) { @@ -445,14 +579,21 @@ async function main() { pass('setup', 'admin session', `logged in as ${EMAIL}`); if (PHASE === 'install') { + // Probe BEFORE the clean slate, while a previous run's install is still + // there to probe against. The answer decides ORDERING: on a pre-C16 core + // the SQL grant must exist before activation, because there is no route to + // re-activate the plugin afterwards. + await probeUnifiedGrants(); + if (hasUnifiedGrants) record('setup', 'grant surface', 'PASS', 'core ships the C16 unified route — granting the way the wizard does, after install'); + await cleanSlate(); const zip = resolveZip(); const up = await upload(zip); if (up.status === 201) pass('§3.6', 'ZIP uploaded', `${basename(zip)} ${up.body?.package?.zip_bytes} bytes sha=${String(up.body?.package?.sha256).slice(0, 12)}`); else fail('§3.6', 'ZIP uploaded', `HTTP ${up.status} ${JSON.stringify(up.body).slice(0, 200)}`); - await grantSql(); - await install(); - await grantPublicPaths(); + if (hasUnifiedGrants !== true) await grantSqlLegacy(); + await install({ sqlGrantedBeforeActivation: hasUnifiedGrants !== true }); + await grantAll(); } await verifyInstalled();