Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions middleware/migrations/0046_plugin_public_path_grants.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
-- ── Plugin → public-path grants (epic #470 C4 / H1) ────────────────────────
-- The operator explicitly consents to a plugin serving a URL prefix WITHOUT an
-- operator session. Deny-by-default: a plugin may declare
-- `permissions.public_paths` in its manifest, but nothing is served publicly
-- until a row exists here for that exact prefix.
--
-- `plugin_id` is the manifest identity string — plugins have no agents-table
-- row, which is why this is a sibling table rather than a scope on an existing
-- grants table (same decision recorded on #458 for `plugin_mcp_grants`).
--
-- `path_prefix` is stored verbatim as declared. It is matched by exact-prefix
-- comparison on a segment boundary at request time, never as SQL LIKE and
-- never as a regex — a stored value can therefore not widen its own match.
-- The composite PK makes re-granting idempotent and makes it impossible for
-- one plugin to hold two conflicting rows for the same prefix; cross-plugin
-- exclusivity is enforced in `platform/publicPathGrants.ts` at activation
-- time, because it has to hold for DECLARED prefixes too, not just granted
-- ones.
CREATE TABLE IF NOT EXISTS plugin_public_path_grants (
plugin_id TEXT NOT NULL,
path_prefix TEXT NOT NULL,
granted_by TEXT NOT NULL,
granted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (plugin_id, path_prefix)
);

-- rollback: DROP TABLE plugin_public_path_grants;
15 changes: 15 additions & 0 deletions middleware/src/api/admin-v1.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,21 @@ export interface PluginPermissionsSummary {
* refresh tokens never reach plugin code). Surfaced as a store-detail chip.
* Loader defaults to `false`. */
acquires_oauth?: boolean;
/**
* Epic #470 C4 / H1 (`permissions.public_paths`): URL prefixes the plugin
* asks to serve WITHOUT an operator session.
*
* This is a REQUEST, not a capability — declaring it grants nothing. The
* prefix is claimed exclusively at activation (first plugin wins, a second
* one overlapping it fails to activate), and it is only served publicly once
* the operator has consented and a row exists in `plugin_public_path_grants`.
* Until then the prefix stays behind `requireAuth` like everything else.
*
* The single most consequential thing a plugin can ask for, so it is
* surfaced on its own in the store consent block rather than folded in with
* the network/memory chips. Loader defaults to `[]`.
*/
public_paths?: string[];
}

export type PluginInstallState =
Expand Down
55 changes: 55 additions & 0 deletions middleware/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,9 @@ import { ChatAgentWrapRegistry } from './platform/chatAgentWrapRegistry.js';
import { PromptContributionRegistry } from './platform/promptContributionRegistry.js';
import { installProcessGuards } from './platform/processGuards.js';
import { PluginRouteRegistry } from './platform/pluginRouteRegistry.js';
import { PublicPathGrantRegistry } from './platform/publicPathGrants.js';
import { createLazyPublicPathGrantStore } from './platform/publicPathGrantStore.js';
import { createPublicPathMount } from './platform/publicPathMount.js';
import { NotificationRouter } from './platform/notificationRouter.js';
import { PluginStatusRegistry } from './platform/pluginStatusRegistry.js';
import { OAuthReadinessTracker } from './plugins/oauth/oauthReadinessTracker.js';
Expand Down Expand Up @@ -561,6 +564,18 @@ async function main(): Promise<void> {
// so it can fire onBeforeTurn / onAfterToolCall / onAfterTurn during turns.
serviceRegistry.provide('turnHookRegistry', turnHookRegistry);
const pluginRouteRegistry = new PluginRouteRegistry();
// Epic #470 C4 / H1 — who owns which unauthenticated URL prefix, and which of
// those the operator has consented to. The registry decides routing; the
// store holds the durable consent. Both are wired into ToolPluginRuntime
// (claim on activate, release on deactivate) and into the terminating mount
// installed BEFORE the `/api` requireAuth line far below.
//
// The store is late-bound because `graphPool` is published into the service
// registry after plugins activate — see createLazyPublicPathGrantStore.
const publicPathGrants = new PublicPathGrantRegistry();
const publicPathGrantStore = createLazyPublicPathGrantStore(() =>
serviceRegistry.get<Pool>('graphPool'),
);
const notificationRouter = new NotificationRouter();

// Phase B+ — directory aggregator for the /operator/channels dashboard.
Expand Down Expand Up @@ -1075,6 +1090,13 @@ async function main(): Promise<void> {
serviceRegistry,
nativeToolRegistry,
pluginRouteRegistry,
// Epic #470 C4 / H1 — declared public-path prefixes are claimed here on
// activate and released on deactivate. `corePublicPaths` is the SAME array
// requireAuth runs against, so a plugin can never declare a prefix that is
// already a static core exemption.
publicPathGrants,
publicPathGrantStore,
corePublicPaths: publicPaths(),
notificationRouter,
uiRouteCatalog,
jobScheduler,
Expand Down Expand Up @@ -2730,6 +2752,35 @@ async function main(): Promise<void> {
// Pre-Phase-A / no-DB boot: the legacy default is the only Agent.
return reg ? undefined : 'default';
};
// ── Epic #470 C4 / H1 — the terminating public-path mount ────────────────
//
// THE POSITION OF THIS LINE IS THE FEATURE. It sits immediately before the
// OB-106 `/api` requireAuth mount below, and everything about the design
// follows from that:
//
// * A request under a prefix that is manifest-declared, exclusively owned
// AND operator-granted is dispatched to the owning plugin's router right
// here, before any authentication runs.
// * If that router does not handle it, this mount answers 404. It does NOT
// call next(). A granted prefix is a closed world owned by one plugin —
// an unhandled subpath must never travel on into the authenticated stack
// with no session attached. That is the hole a plain `publicPaths` entry
// leaves open, and the reason `auth/publicPaths.ts` stays a frozen
// core-owned literal instead of becoming a dynamic set.
// * Anything else calls next() and meets requireAuth exactly as before.
//
// Fail-closed by construction: no grants, no store, no registry, no live
// plugin — every one of those is a next(), i.e. a 401. There is no failure
// mode of this mount that produces LESS authentication than a build without
// it. Mounted after express.json/cookieParser so plugin handlers see the
// same parsed request they see through the ordinary boot-time mount.
app.use(
createPublicPathMount({
grants: publicPathGrants,
routes: pluginRouteRegistry,
}),
);

// OB-106: gate the chat-inference endpoints (`POST /api/chat`,
// `POST /api/chat/stream`) behind `requireAuth`. Without this, anonymous
// callers could trigger LLM inference (cost) and reach the tool surface
Expand Down Expand Up @@ -4107,6 +4158,10 @@ async function main(): Promise<void> {
catalog: pluginCatalog,
reactivate: reactivateAgent,
dynamicAgentRuntime,
// Epic #470 C4 / H1 — operator consent for unauthenticated plugin path
// prefixes. Behind requireAuth like every other runtime endpoint.
publicPathGrantStore,
publicPathGrants,
}),
);
console.log('[middleware] runtime introspection endpoint ready at /api/v1/admin/runtime (auth: required)');
Expand Down
78 changes: 78 additions & 0 deletions middleware/src/platform/pluginRouteRegistry.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { Router as createRouter } from 'express';
import type { Express, RequestHandler, Router } from 'express';

/**
Expand Down Expand Up @@ -33,10 +34,25 @@ interface RouteEntry {
source: string;
}

/** What the terminating public-path mount needs to hand a request to a plugin:
* the prefix the router was mounted at, plus a handler that runs it with real
* Express mount semantics. See {@link PluginRouteRegistry.resolvePublicDispatch}. */
export interface PluginRouteDispatch {
readonly prefix: string;
readonly source: string;
/** Runs the plugin's router. Calls `next()` — and only `next()` — when the
* router did not handle the request, so the caller decides what "unhandled"
* means. The public-path mount decides it means 404, never fallthrough. */
readonly handle: RequestHandler;
}

export class PluginRouteRegistry {
private readonly entries: RouteEntry[] = [];
private mounted = false;
private app: Express | null = null;
/** Per-entry mini-app used by `resolvePublicDispatch`, built once and reused.
* Keyed on the entry object so a disposed entry's wrapper is collectable. */
private readonly dispatchWrappers = new WeakMap<RouteEntry, RequestHandler>();

/**
* Register a router at the given prefix. `source` is for diagnostics —
Expand Down Expand Up @@ -100,6 +116,59 @@ export class PluginRouteRegistry {
app.use(entry.prefix, guardedRouter);
}

/**
* Epic #470 C4 / H1 — resolve the live router that owns `path` for `source`.
*
* Returns `null` when the plugin registered nothing covering that path, or
* when the entry it did register has since been disposed. Both cases mean
* the same thing to the caller: nobody is entitled to answer this request
* without a session.
*
* The returned `handle` wraps the router in a one-line Express `Router`
* mounted at the entry's own prefix. That is deliberate rather than manual
* `req.url` surgery: `req.baseUrl`/`req.url`/`req.params` rewriting and its
* restoration on `next()` are subtle, Express already implements them
* correctly, and a plugin router must see exactly the same request it sees
* through the ordinary boot-time mount — otherwise the "public" path and the
* authenticated path diverge, which is precisely the drift this whole
* mechanism exists to prevent.
*
* The disposed check lives INSIDE the wrapper, not just at resolve time, so
* a plugin deactivated between resolution and dispatch still stops serving.
*/
resolvePublicDispatch(
source: string,
path: string,
): PluginRouteDispatch | null {
let best: RouteEntry | null = null;
for (const entry of this.entries) {
if (entry.source !== source || entry.disposed) continue;
if (!isUnderPrefix(path, entry.prefix)) continue;
// Longest prefix wins — a plugin registering both `/api/plugins/x` and
// `/api/plugins/x/hooks` must get the more specific router.
if (best === null || entry.prefix.length > best.prefix.length) {
best = entry;
}
}
if (!best) return null;
const entry = best;

let handle = this.dispatchWrappers.get(entry);
if (!handle) {
const wrapper = createRouter();
wrapper.use(entry.prefix, (req, res, next) => {
if (entry.disposed) {
next();
return;
}
entry.router(req, res, next);
});
handle = wrapper as unknown as RequestHandler;
this.dispatchWrappers.set(entry, handle);
}
return { prefix: entry.prefix, source: entry.source, handle };
}

/** Diagnostic: what routers are registered today. */
list(): readonly { prefix: string; source: string; disposed: boolean }[] {
return this.entries.map((e) => ({
Expand Down Expand Up @@ -133,6 +202,15 @@ export class PluginRouteRegistry {
}
}

/** `child` is `parent`, or sits beneath it on a segment boundary. Duplicated
* deliberately from `publicPathGrants.ts` rather than imported: this registry
* is a generic Express concern and must not depend on the grant machinery. */
function isUnderPrefix(child: string, parent: string): boolean {
if (child === parent) return true;
const withSlash = parent.endsWith('/') ? parent : `${parent}/`;
return child.startsWith(withSlash);
}

function isExpressRouter(value: unknown): value is Router {
// Express routers are callable (they are RequestHandler themselves) AND
// expose a `use` method. Duck-typing is safer than instanceof because
Expand Down
Loading
Loading