Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
c01cd21
test: W0c schema-fit gate — MCP delegation stays per-server, no migra…
Weegy Aug 25, 2026
bcde5bf
feat(orchestrator): per-agent grant read model for the agent detail U…
Weegy Aug 25, 2026
888d704
feat: MCP grant allowlist + per-server delegation surfacing on the gr…
Weegy Aug 25, 2026
2a893ac
feat: per-agent grants read + single-plugin toggle on the operator ag…
Weegy Aug 25, 2026
90561a5
feat(web-ui): _lib grant/assignment types + typed callers for per-age…
Weegy Aug 25, 2026
4a09e1c
feat(web-ui): agent detail route with per-agent plugin enable/disable…
Weegy Aug 25, 2026
07ab1ba
feat(web-ui): per-agent MCP server assignment + tool allowlist editor…
Weegy Aug 25, 2026
777c0ba
feat(web-ui): per-agent tool-grant list with grant-epoch display (W0c…
Weegy Aug 25, 2026
e8f3473
feat(web-ui): surface per-server delegation mode on each agent-MCP as…
Weegy Aug 25, 2026
dc0c4de
Merge branch 'feat/w0c-agent-grants-ui-w0c-agent-grants-ui-operator-a…
Weegy Aug 25, 2026
2336ef7
Merge branch 'feat/w0c-agent-grants-ui-w0c-agent-grants-ui-mcp-grant-…
Weegy Aug 25, 2026
05d2057
Merge branch 'feat/w0c-agent-grants-ui-w0c-agent-grants-ui-agent-deta…
Weegy Aug 25, 2026
bef0d26
Merge branch 'feat/w0c-agent-grants-ui-w0c-agent-grants-ui-tool-grant…
Weegy Aug 25, 2026
cff93bc
Merge branch 'feat/w0c-agent-grants-ui-w0c-agent-grants-ui-per-assign…
Weegy Aug 25, 2026
bb016aa
feat(web-ui): wire per-agent grant and MCP surfaces into the agent de…
Weegy Aug 25, 2026
e5e2ce0
Merge branch 'feat/w0c-agent-grants-ui-w0c-agent-grants-ui-migration-…
Weegy Aug 25, 2026
972267e
Merge branch 'feat/w0c-agent-grants-ui-w0c-agent-grants-ui-store-per-…
Weegy Aug 25, 2026
4129c0d
Merge branch 'feat/w0c-agent-grants-ui-w0c-agent-grants-ui-mcp-grant-…
Weegy Aug 25, 2026
e1253f1
Merge branch 'feat/w0c-agent-grants-ui-w0c-agent-grants-ui-operator-a…
Weegy Aug 25, 2026
8272870
Merge branch 'feat/w0c-agent-grants-ui-w0c-agent-grants-ui-web-lib-ty…
Weegy Aug 25, 2026
b3e8fbd
Merge branch 'feat/w0c-agent-grants-ui-w0c-agent-grants-ui-agent-deta…
Weegy Aug 25, 2026
06c2bc5
Merge branch 'feat/w0c-agent-grants-ui-w0c-agent-grants-ui-mcp-server…
Weegy Aug 25, 2026
79c933d
Merge branch 'feat/w0c-agent-grants-ui-w0c-agent-grants-ui-tool-grant…
Weegy Aug 25, 2026
37353c9
Merge branch 'feat/w0c-agent-grants-ui-w0c-agent-grants-ui-per-assign…
Weegy Aug 25, 2026
e75c89a
Merge branch 'feat/w0c-agent-grants-ui-w0c-agent-grants-ui-wiring' in…
Weegy Aug 25, 2026
c16da1c
fix: W0c coordinator fixes — wire grants store, harden gate, single d…
Weegy Aug 25, 2026
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
1 change: 1 addition & 0 deletions middleware/packages/harness-orchestrator/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ export type {
McpToolVerdictAckRow,
McpToolVerdictRow,
PersonaSkillRow,
PluginMcpGrantRow,
ScheduleInput,
ScheduleRow,
SkillInput,
Expand Down
162 changes: 146 additions & 16 deletions middleware/packages/harness-orchestrator/src/registry/agentGraphStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,26 @@ export interface McpServerRow {
readonly delegation: McpDelegation;
}

/** How an MCP server resolves the identity a call acts as (W0-1, D2). */
/** How an MCP server resolves the identity a call acts as (W0-1, D2).
*
* Scope decision (#860 W0c, resolving #862's "delegation choice per
* assignment"): delegation is a PER-SERVER property and stays one. There is
* deliberately NO per-(agent, server) delegation storage — not on
* `agent_tool_grants`, not on `plugin_mcp_grants`, no bridge table. A
* per-agent assignment view therefore shows the server's mode READ-ONLY and
* links to the per-server setting; changing it there applies to every agent
* holding a grant on that server and must say so.
*
* The real escape hatch is NOT a new column: `agent_tool_grants.config`
* (JSONB, migration 0003) could hold a `delegation` key today with zero DDL,
* which is why an SQL-only migration scan cannot see a widening. Storing any
* `config.delegation*` key on a grant row is therefore FORBIDDEN by
* convention — per-assignment delegation needs resolution semantics in
* `resolveMcpUserKey` and its own design pass, not a JSONB side door. Guarded
* twice by the W0c schema-fit gate in
* `middleware/test/mcpDelegationBackfillMigration.pg.test.ts`: a migration
* scan over the grant tables AND a source scan that fails on any code
* reading or writing a delegation key out of grant config. */
export type McpDelegation = 'per_user' | 'service';

/**
Expand All @@ -249,6 +268,23 @@ export interface ToolGrantRow {
readonly mcpServerId: string | null;
readonly config: Record<string, unknown>;
readonly createdAt: Date;
/** Grant epoch (W0c, #861): `bumpMcpGrantEpoch` stamps `config.verdictEpoch`
* (a `now()::text` timestamp) into the grant's JSONB — there is no epoch
* column. Surfaced here as a typed field so readers (the agent detail UI)
* never dig through the untyped `config`. `null` until the first bump.
* Optional so hand-built fixtures predating the field stay valid; the row
* mapper always populates it. */
readonly grantEpoch?: string | null;
}

/** One `plugin_mcp_grants` row (epic #459 W5, issue #458): the operator's
* explicit plugin → MCP-server grant. Named so the per-agent read model
* (W0c, #861) has a typed row instead of an inline shape. */
export interface PluginMcpGrantRow {
readonly pluginId: string;
readonly mcpServerId: string;
readonly grantedBy: string;
readonly grantedAt: Date;
}

export interface ScheduleRow {
Expand Down Expand Up @@ -576,6 +612,13 @@ interface ToolGrantDbRow {
created_at: Date;
}

interface PluginMcpGrantDbRow {
plugin_id: string;
mcp_server_id: string;
granted_by: string;
granted_at: Date;
}

interface ScheduleDbRow {
id: string;
agent_id: string;
Expand Down Expand Up @@ -800,6 +843,9 @@ function mapMcpServer(r: McpServerDbRow): McpServerRow {
}

function mapToolGrant(r: ToolGrantDbRow): ToolGrantRow {
// `verdictEpoch` is written by bumpMcpGrantEpoch via jsonb_set; anything
// that is not a string (absent, or a hand-edited config) reads as null.
const epoch = r.config?.['verdictEpoch'];
return {
id: r.id,
agentId: r.agent_id,
Expand All @@ -809,6 +855,16 @@ function mapToolGrant(r: ToolGrantDbRow): ToolGrantRow {
mcpServerId: r.mcp_server_id,
config: r.config,
createdAt: r.created_at,
grantEpoch: typeof epoch === 'string' ? epoch : null,
};
}

function mapPluginMcpGrant(r: PluginMcpGrantDbRow): PluginMcpGrantRow {
return {
pluginId: r.plugin_id,
mcpServerId: r.mcp_server_id,
grantedBy: r.granted_by,
grantedAt: r.granted_at,
};
}

Expand Down Expand Up @@ -1651,21 +1707,28 @@ export class AgentGraphStore {

// ── Plugin → MCP server grants (epic #459 W5, issue #458) ───────────────────

async listPluginMcpGrants(): Promise<
readonly { pluginId: string; mcpServerId: string; grantedBy: string; grantedAt: Date }[]
> {
const { rows } = await this.pool.query<{
plugin_id: string;
mcp_server_id: string;
granted_by: string;
granted_at: Date;
}>('SELECT * FROM plugin_mcp_grants');
return rows.map((r) => ({
pluginId: r.plugin_id,
mcpServerId: r.mcp_server_id,
grantedBy: r.granted_by,
grantedAt: r.granted_at,
}));
async listPluginMcpGrants(): Promise<readonly PluginMcpGrantRow[]> {
const { rows } = await this.pool.query<PluginMcpGrantDbRow>(
'SELECT * FROM plugin_mcp_grants',
);
return rows.map(mapPluginMcpGrant);
}

/** Plugin-scoped read of `plugin_mcp_grants` (W0c, #861): full grant rows
* for a set of plugin ids — one round-trip for an agent detail page that
* shows the MCP grants of every plugin enabled on that agent. SELECT-only
* by construction (no DDL, no writes). */
async listPluginMcpGrantsForPlugins(
pluginIds: readonly string[],
): Promise<readonly PluginMcpGrantRow[]> {
if (pluginIds.length === 0) return [];
const { rows } = await this.pool.query<PluginMcpGrantDbRow>(
`SELECT * FROM plugin_mcp_grants
WHERE plugin_id = ANY($1::text[])
ORDER BY plugin_id, granted_at`,
[[...pluginIds]],
);
return rows.map(mapPluginMcpGrant);
}

async listGrantedServerIdsForPlugin(pluginId: string): Promise<readonly string[]> {
Expand Down Expand Up @@ -2297,6 +2360,73 @@ export class AgentGraphStore {
return rows.map(mapToolGrant);
}

/** Agent-scoped read of `agent_tool_grants` (W0c, #861): the grants of ONE
* agent, for the agent detail page. Same row shape as `listAllToolGrants`
* (grant epoch included via `grantEpoch`). `agent_tool_grants` is a XOR
* table (0003: `agent_id` OR `subagent_id`), and the codebase attributes a
* sub-agent-held grant to its parent agent everywhere the graph is read
* (`assembleGraph`, `indexGraph.grantsByAgent`, the graph-signature
* filter) — this read matches that rule: rows held directly by the agent
* PLUS rows held by its sub-agents, distinguishable via `subAgentId`.
* SELECT-only by construction (no DDL, no writes). */
async listToolGrantsForAgent(agentId: string): Promise<readonly ToolGrantRow[]> {
const { rows } = await this.pool.query<ToolGrantDbRow>(
`SELECT * FROM agent_tool_grants
WHERE agent_id = $1
OR subagent_id IN (SELECT id FROM agent_subagents WHERE parent_agent_id = $1)
ORDER BY created_at`,
[agentId],
);
return rows.map(mapToolGrant);
}

/**
* Transactional bulk edit of an agent's MCP tool allowlist for one server
* (W0c, #862). `PUT /mcp-grants` in allowlist mode is N creates + M deletes;
* done through the single-row methods a mid-edit failure would leave the
* persisted allowlist neither old nor new. Here every write shares one
* transaction: a partial failure rolls back and the route's granted/revoked
* response always describes what actually persisted. The INSERT keeps
* `createToolGrant`'s ON CONFLICT no-op contract (unique index 0014).
*/
async applyMcpToolAllowlist(input: {
readonly agentId: string;
readonly mcpServerId: string;
/** Normalized tool names to grant (rows to INSERT). */
readonly grantRefs: readonly string[];
/** Grant row ids to revoke (rows to DELETE). */
readonly revokeIds: readonly string[];
}): Promise<void> {
const client = await this.pool.connect();
try {
await client.query('BEGIN');
for (const toolRef of input.grantRefs) {
await client.query(
`INSERT INTO agent_tool_grants
(agent_id, subagent_id, tool_kind, tool_ref, mcp_server_id, config)
VALUES ($1,NULL,'mcp',$2,$3,'{}'::jsonb)
ON CONFLICT (agent_id, mcp_server_id, tool_ref)
WHERE agent_id IS NOT NULL AND tool_kind = 'mcp'
DO NOTHING`,
[input.agentId, toolRef, input.mcpServerId],
);
}
for (const id of input.revokeIds) {
await client.query('DELETE FROM agent_tool_grants WHERE id = $1', [id]);
}
await client.query('COMMIT');
} catch (err) {
try {
await client.query('ROLLBACK');
} catch {
// connection-level failure — the pool discards the client below.
}
throw err;
} finally {
client.release();
}
}

async createToolGrant(input: ToolGrantInput): Promise<ToolGrantRow> {
if (!input.agentId && !input.subAgentId) {
throw new ConfigValidationError(
Expand Down
5 changes: 5 additions & 0 deletions middleware/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3166,6 +3166,11 @@ async function main(): Promise<void> {
getChatSessionStore,
getPluginCatalog: () => pluginCatalog,
getInstalledRegistry: () => installedRegistry,
// W0c (#861) — the per-agent grant read model needs the graph store.
// Same graphPool-guarded shape as the other AgentGraphStore sites; when
// no DATABASE_URL is set the route degrades to its own 503.
getAgentGraphStore: () =>
graphPool ? new AgentGraphStore(graphPool) : undefined,
}),
);
console.log(
Expand Down
Loading
Loading