Harden connector registry plugin records
This commit is contained in:
+22
-4
@@ -29,25 +29,36 @@ registry = ConnectorRegistry.from_plugin_records(
|
||||
{
|
||||
"id": "plugin_orders",
|
||||
"name": "orders",
|
||||
"package_path": "./orders-plugin",
|
||||
"mount": {
|
||||
"path": "orders-plugin",
|
||||
},
|
||||
"policyLabels": ["read_only"],
|
||||
},
|
||||
{
|
||||
"id": "plugin_calendar",
|
||||
"name": "calendar",
|
||||
"policy": {
|
||||
"labels": ["read_only"],
|
||||
},
|
||||
"apps": {
|
||||
"calendar": {
|
||||
"id": "connector_googlecalendar",
|
||||
"connectorId": "connector_googlecalendar",
|
||||
"authorizationAlias": "calendar_connection",
|
||||
"serverLabel": "google_calendar",
|
||||
"allowedTools": ["events_search"],
|
||||
"requireApproval": "never",
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
],
|
||||
package_root="./mounted-plugins",
|
||||
)
|
||||
|
||||
orders = Connector.from_installed_plugin("plugin_orders", registry)
|
||||
calendar = Connector.from_installed_plugin(
|
||||
"plugin_calendar",
|
||||
registry,
|
||||
authorization={"calendar": "conn_calendar_access_token"},
|
||||
authorization={"calendar_connection": "conn_calendar_access_token"},
|
||||
hosted_mcp_require_approval="always",
|
||||
)
|
||||
|
||||
@@ -62,6 +73,13 @@ This keeps package discovery, marketplace installation, workspace sharing, admin
|
||||
sync outside the SDK runtime while giving those systems a stable place to hand installed plugin
|
||||
records to the SDK.
|
||||
|
||||
Registry records accept either direct package paths such as `package_path` or nested mounted-package
|
||||
paths such as `mount.path`. When `package_root` is supplied, relative and absolute package paths
|
||||
must resolve inside that root. Hosted app records can declare auth aliases, server labels, allowed
|
||||
tools, approval settings, and deferred loading flags; auth aliases are resolved through the
|
||||
`authorization` mapping passed to `Connector.from_installed_plugin()`. Top-level `policyLabels` or
|
||||
`policy.labels` are merged into the connector's policy labels.
|
||||
|
||||
## SDK tool connectors
|
||||
|
||||
Use [`Connector.from_tools()`][agents.connectors.Connector.from_tools] when your integration is
|
||||
|
||||
@@ -43,9 +43,17 @@ def build_hosted_connector() -> Connector:
|
||||
"id": "plugin_calendar",
|
||||
"name": "calendar",
|
||||
"description": "Hosted Google Calendar connector shape.",
|
||||
"policy": {
|
||||
"labels": ["read_only"],
|
||||
},
|
||||
"apps": {
|
||||
"calendar": {
|
||||
"id": "connector_googlecalendar",
|
||||
"connectorId": "connector_googlecalendar",
|
||||
"authorizationAlias": "calendar_connection",
|
||||
"serverLabel": "google_calendar",
|
||||
"allowedTools": ["events_search"],
|
||||
"requireApproval": "never",
|
||||
"deferLoading": True,
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -54,8 +62,8 @@ def build_hosted_connector() -> Connector:
|
||||
return Connector.from_installed_plugin(
|
||||
"plugin_calendar",
|
||||
registry,
|
||||
authorization={"calendar": "demo_access_token"},
|
||||
hosted_mcp_require_approval="never",
|
||||
authorization={"calendar_connection": "demo_access_token"},
|
||||
hosted_mcp_require_approval="always",
|
||||
)
|
||||
|
||||
|
||||
@@ -119,10 +127,14 @@ def build_package_connector(package_root: Path) -> Connector:
|
||||
{
|
||||
"id": "plugin_orders",
|
||||
"name": "orders",
|
||||
"package_path": str(package_root),
|
||||
"mount": {
|
||||
"path": package_root.name,
|
||||
},
|
||||
"policyLabels": ["read_only"],
|
||||
"source": "unified_plugins_demo",
|
||||
}
|
||||
]
|
||||
],
|
||||
package_root=package_root.parent,
|
||||
)
|
||||
return Connector.from_installed_plugin("plugin_orders", registry)
|
||||
|
||||
@@ -177,6 +189,7 @@ async def verify_connector_demo() -> dict[str, Any]:
|
||||
"package_registry_source": package_connector.metadata["unified_plugin"]["source"],
|
||||
"hosted_connector_label": hosted_tool.tool_config["server_label"],
|
||||
"hosted_connector_id": hosted_tool.tool_config["connector_id"],
|
||||
"hosted_allowed_tools": hosted_tool.tool_config["allowed_tools"],
|
||||
}
|
||||
|
||||
|
||||
@@ -238,7 +251,8 @@ async def main(*, verify: bool) -> None:
|
||||
expected = {
|
||||
"direct_tool_result": "discount=25.00",
|
||||
"mcp_tool_result": "order demo_order_1001: fulfilled",
|
||||
"hosted_connector_label": "calendar",
|
||||
"hosted_connector_label": "google_calendar",
|
||||
"hosted_allowed_tools": ["events_search"],
|
||||
}
|
||||
mismatches = {
|
||||
key: (summary.get(key), expected_value)
|
||||
|
||||
+169
-9
@@ -25,6 +25,17 @@ ConnectorPolicyLabel = Literal[
|
||||
]
|
||||
"""Coarse policy labels callers can use to route connector approval and sandbox decisions."""
|
||||
|
||||
_CONNECTOR_POLICY_LABELS: set[str] = {
|
||||
"read_only",
|
||||
"write",
|
||||
"destructive",
|
||||
"external_send",
|
||||
"network",
|
||||
"secret_access",
|
||||
"local_execution",
|
||||
"sandbox_required",
|
||||
}
|
||||
|
||||
|
||||
HostedConnectorAuthorization = (
|
||||
str | Mapping[str, str] | Callable[[str, str, Mapping[str, Any]], str | None]
|
||||
@@ -47,6 +58,7 @@ class ConnectorPlugin:
|
||||
package_path: Path | None = None
|
||||
hosted_connectors: Mapping[str, Mapping[str, Any]] = field(default_factory=dict)
|
||||
metadata: Mapping[str, Any] = field(default_factory=dict)
|
||||
policy_labels: tuple[ConnectorPolicyLabel, ...] = ()
|
||||
|
||||
@classmethod
|
||||
def from_record(
|
||||
@@ -66,6 +78,7 @@ class ConnectorPlugin:
|
||||
description = _optional_record_str(record, ("description",), "Plugin description")
|
||||
package_path = _plugin_package_path(record, package_root=package_root)
|
||||
hosted_connectors = _plugin_hosted_connector_configs(record)
|
||||
policy_labels = _plugin_policy_labels(record)
|
||||
metadata = {
|
||||
key: value
|
||||
for key, value in record.items()
|
||||
@@ -83,6 +96,9 @@ class ConnectorPlugin:
|
||||
"packagePath",
|
||||
"package_path",
|
||||
"path",
|
||||
"policy",
|
||||
"policyLabels",
|
||||
"policy_labels",
|
||||
"pluginId",
|
||||
"plugin_id",
|
||||
"slug",
|
||||
@@ -96,6 +112,7 @@ class ConnectorPlugin:
|
||||
package_path=package_path,
|
||||
hosted_connectors=hosted_connectors,
|
||||
metadata=metadata,
|
||||
policy_labels=policy_labels,
|
||||
)
|
||||
|
||||
|
||||
@@ -440,6 +457,7 @@ class ConnectorRegistry:
|
||||
authorization=authorization,
|
||||
require_approval=hosted_mcp_require_approval,
|
||||
)
|
||||
connector.policy_labels.update(plugin_record.policy_labels)
|
||||
connector.tools.extend(hosted_tools)
|
||||
if hosted_tools:
|
||||
connector.policy_labels.add("network")
|
||||
@@ -611,6 +629,20 @@ def _load_hosted_connector_tools(
|
||||
tools: list[Tool] = []
|
||||
for app_name, raw_config in app_configs.items():
|
||||
connector_id = _hosted_connector_id(raw_config, f"App id for {app_name!r}")
|
||||
server_label = (
|
||||
_optional_record_str(raw_config, ("server_label", "serverLabel"), "App server label")
|
||||
or app_name
|
||||
)
|
||||
allowed_tools = _optional_record_str_list(
|
||||
raw_config, ("allowed_tools", "allowedTools"), f"Allowed tools for {app_name!r}"
|
||||
)
|
||||
app_require_approval = cast(
|
||||
RequireApprovalSetting,
|
||||
raw_config.get("require_approval", raw_config.get("requireApproval", require_approval)),
|
||||
)
|
||||
defer_loading = _optional_record_bool(
|
||||
raw_config, ("defer_loading", "deferLoading"), f"Defer loading for {app_name!r}"
|
||||
)
|
||||
resolved_authorization = _resolve_authorization(
|
||||
authorization, app_name, connector_id, raw_config
|
||||
)
|
||||
@@ -620,8 +652,10 @@ def _load_hosted_connector_tools(
|
||||
app_name,
|
||||
connector_id=connector_id,
|
||||
authorization=resolved_authorization,
|
||||
server_label=app_name,
|
||||
require_approval=require_approval,
|
||||
server_label=server_label,
|
||||
allowed_tools=allowed_tools,
|
||||
require_approval=app_require_approval,
|
||||
defer_loading=defer_loading,
|
||||
)
|
||||
tools.extend(connector.tools)
|
||||
|
||||
@@ -633,17 +667,66 @@ def _plugin_package_path(
|
||||
*,
|
||||
package_root: str | Path | None,
|
||||
) -> Path | None:
|
||||
path_value = _plugin_package_path_value(record)
|
||||
if path_value is None:
|
||||
return None
|
||||
root_path = Path(package_root).expanduser().resolve() if package_root is not None else None
|
||||
path = Path(path_value).expanduser()
|
||||
candidate = (
|
||||
path.resolve() if path.is_absolute() else ((root_path or Path.cwd()) / path).resolve()
|
||||
)
|
||||
if root_path is not None and not _is_relative_to(candidate, root_path):
|
||||
raise UserError(
|
||||
f"Plugin package path must stay inside the connector package root: {path_value}"
|
||||
)
|
||||
return candidate
|
||||
|
||||
|
||||
def _plugin_package_path_value(record: Mapping[str, Any]) -> str | None:
|
||||
path_value = _optional_record_str(
|
||||
record,
|
||||
("package_path", "packagePath", "local_path", "localPath", "path"),
|
||||
"Plugin package path",
|
||||
)
|
||||
if path_value is None:
|
||||
return None
|
||||
path = Path(path_value).expanduser()
|
||||
if not path.is_absolute() and package_root is not None:
|
||||
path = Path(package_root).expanduser() / path
|
||||
return path.resolve()
|
||||
if path_value is not None:
|
||||
return path_value
|
||||
|
||||
for key in ("package", "mount", "local_package", "localPackage"):
|
||||
nested = record.get(key)
|
||||
if nested is None:
|
||||
continue
|
||||
if not isinstance(nested, Mapping):
|
||||
raise UserError(f"Plugin {key} must be an object")
|
||||
path_value = _optional_record_str(
|
||||
nested,
|
||||
("path", "package_path", "packagePath", "local_path", "localPath"),
|
||||
f"Plugin {key} path",
|
||||
)
|
||||
if path_value is not None:
|
||||
return path_value
|
||||
return None
|
||||
|
||||
|
||||
def _plugin_policy_labels(record: Mapping[str, Any]) -> tuple[ConnectorPolicyLabel, ...]:
|
||||
policy_labels = _optional_record_policy_labels(
|
||||
record, ("policy_labels", "policyLabels"), "Plugin policy labels"
|
||||
)
|
||||
if policy_labels is not None:
|
||||
return policy_labels
|
||||
|
||||
policy = record.get("policy")
|
||||
if policy is None:
|
||||
return ()
|
||||
if not isinstance(policy, Mapping):
|
||||
raise UserError("Plugin policy must be an object")
|
||||
return (
|
||||
_optional_record_policy_labels(
|
||||
policy,
|
||||
("labels", "policy_labels", "policyLabels"),
|
||||
"Plugin policy labels",
|
||||
)
|
||||
or ()
|
||||
)
|
||||
|
||||
|
||||
def _plugin_hosted_connector_configs(
|
||||
@@ -712,6 +795,8 @@ def _plugin_metadata(plugin: ConnectorPlugin) -> dict[str, Any]:
|
||||
metadata["hosted_connectors"] = {
|
||||
app_name: dict(config) for app_name, config in plugin.hosted_connectors.items()
|
||||
}
|
||||
if plugin.policy_labels:
|
||||
metadata["policy_labels"] = sorted(plugin.policy_labels)
|
||||
return metadata
|
||||
|
||||
|
||||
@@ -726,10 +811,38 @@ def _resolve_authorization(
|
||||
if isinstance(authorization, str):
|
||||
return authorization
|
||||
if isinstance(authorization, Mapping):
|
||||
return authorization.get(app_name) or authorization.get(connector_id)
|
||||
for key in _authorization_lookup_keys(app_name, connector_id, app_config):
|
||||
token = authorization.get(key)
|
||||
if token is not None:
|
||||
return token
|
||||
return None
|
||||
return authorization(app_name, connector_id, app_config)
|
||||
|
||||
|
||||
def _authorization_lookup_keys(
|
||||
app_name: str,
|
||||
connector_id: str,
|
||||
app_config: Mapping[str, Any],
|
||||
) -> tuple[str, ...]:
|
||||
keys = [app_name, connector_id]
|
||||
for field_name in (
|
||||
"authorization_alias",
|
||||
"authorizationAlias",
|
||||
"authorization_ref",
|
||||
"authorizationRef",
|
||||
"auth_alias",
|
||||
"authAlias",
|
||||
"auth_reference",
|
||||
"authReference",
|
||||
"connection_id",
|
||||
"connectionId",
|
||||
):
|
||||
value = app_config.get(field_name)
|
||||
if isinstance(value, str) and value:
|
||||
keys.append(value)
|
||||
return tuple(dict.fromkeys(keys))
|
||||
|
||||
|
||||
def _read_json_object(path: Path) -> dict[str, Any]:
|
||||
try:
|
||||
value = json.loads(path.read_text())
|
||||
@@ -786,6 +899,53 @@ def _optional_record_str(
|
||||
return None
|
||||
|
||||
|
||||
def _optional_record_str_list(
|
||||
record: Mapping[str, Any],
|
||||
keys: tuple[str, ...],
|
||||
field_name: str,
|
||||
) -> list[str] | None:
|
||||
for key in keys:
|
||||
value = record.get(key)
|
||||
if value is not None:
|
||||
return _expect_str_list(value, field_name)
|
||||
return None
|
||||
|
||||
|
||||
def _optional_record_bool(
|
||||
record: Mapping[str, Any],
|
||||
keys: tuple[str, ...],
|
||||
field_name: str,
|
||||
) -> bool:
|
||||
for key in keys:
|
||||
value = record.get(key)
|
||||
if value is not None:
|
||||
if not isinstance(value, bool):
|
||||
raise UserError(f"{field_name} must be a boolean")
|
||||
return value
|
||||
return False
|
||||
|
||||
|
||||
def _optional_record_policy_labels(
|
||||
record: Mapping[str, Any],
|
||||
keys: tuple[str, ...],
|
||||
field_name: str,
|
||||
) -> tuple[ConnectorPolicyLabel, ...] | None:
|
||||
for key in keys:
|
||||
value = record.get(key)
|
||||
if value is not None:
|
||||
return _expect_policy_labels(value, field_name)
|
||||
return None
|
||||
|
||||
|
||||
def _expect_policy_labels(value: Any, field_name: str) -> tuple[ConnectorPolicyLabel, ...]:
|
||||
if not isinstance(value, list) or not all(isinstance(item, str) for item in value):
|
||||
raise UserError(f"{field_name} must be a list of policy label strings")
|
||||
unknown = sorted(set(value) - _CONNECTOR_POLICY_LABELS)
|
||||
if unknown:
|
||||
raise UserError(f"{field_name} contains unknown labels: {', '.join(unknown)}")
|
||||
return tuple(cast(ConnectorPolicyLabel, item) for item in value)
|
||||
|
||||
|
||||
def _expect_str_list(value: Any, field_name: str) -> list[str]:
|
||||
if not isinstance(value, list) or not all(isinstance(item, str) for item in value):
|
||||
raise UserError(f"{field_name} must be a list of strings")
|
||||
|
||||
@@ -12,6 +12,7 @@ async def test_connector_package_demo_verifies_end_to_end() -> None:
|
||||
assert summary["direct_tool_result"] == "discount=25.00"
|
||||
assert summary["mcp_tool_result"] == "order demo_order_1001: fulfilled"
|
||||
assert summary["package_registry_source"] == "unified_plugins_demo"
|
||||
assert summary["hosted_connector_label"] == "calendar"
|
||||
assert summary["hosted_connector_label"] == "google_calendar"
|
||||
assert summary["hosted_allowed_tools"] == ["events_search"]
|
||||
assert "apply_discount" in summary["agent_tool_names"]
|
||||
assert "mcp_orders__lookup_order" in summary["agent_tool_names"]
|
||||
|
||||
@@ -343,6 +343,140 @@ def test_connector_registry_loads_hosted_app_connector_record() -> None:
|
||||
assert tool_config["require_approval"] == "always"
|
||||
|
||||
|
||||
def test_connector_registry_resolves_auth_alias_and_hosted_options() -> None:
|
||||
registry = ConnectorRegistry.from_plugin_records(
|
||||
[
|
||||
{
|
||||
"id": "plugin_workspace",
|
||||
"name": "workspace",
|
||||
"apps": {
|
||||
"calendar": {
|
||||
"connectorId": "connector_googlecalendar",
|
||||
"authorizationAlias": "google_calendar_connection",
|
||||
"serverLabel": "google_calendar",
|
||||
"allowedTools": ["events_search"],
|
||||
"requireApproval": "never",
|
||||
"deferLoading": True,
|
||||
}
|
||||
},
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
connector = Connector.from_installed_plugin(
|
||||
"plugin_workspace",
|
||||
registry,
|
||||
authorization={"google_calendar_connection": "conn_calendar"},
|
||||
hosted_mcp_require_approval="always",
|
||||
)
|
||||
|
||||
tool = connector.tools[0]
|
||||
assert isinstance(tool, HostedMCPTool)
|
||||
tool_config = cast(dict[str, Any], tool.tool_config)
|
||||
assert tool_config["server_label"] == "google_calendar"
|
||||
assert tool_config["connector_id"] == "connector_googlecalendar"
|
||||
assert tool_config["authorization"] == "conn_calendar"
|
||||
assert tool_config["allowed_tools"] == ["events_search"]
|
||||
assert tool_config["require_approval"] == "never"
|
||||
assert tool_config["defer_loading"] is True
|
||||
|
||||
|
||||
def test_connector_registry_merges_policy_labels_from_plugin_record() -> None:
|
||||
registry = ConnectorRegistry.from_plugin_records(
|
||||
[
|
||||
{
|
||||
"id": "plugin_workspace",
|
||||
"name": "workspace",
|
||||
"policy": {
|
||||
"labels": ["read_only", "external_send"],
|
||||
},
|
||||
"apps": {
|
||||
"calendar": {
|
||||
"id": "connector_googlecalendar",
|
||||
}
|
||||
},
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
connector = Connector.from_installed_plugin(
|
||||
"plugin_workspace",
|
||||
registry,
|
||||
authorization={"calendar": "conn_calendar"},
|
||||
)
|
||||
|
||||
assert connector.policy_labels == {"read_only", "external_send", "network"}
|
||||
assert connector.metadata["unified_plugin"]["policy_labels"] == [
|
||||
"external_send",
|
||||
"read_only",
|
||||
]
|
||||
|
||||
|
||||
def test_connector_registry_resolves_mounted_package_paths(tmp_path) -> None:
|
||||
plugins_root = tmp_path / "mounted-plugins"
|
||||
plugin_dir = plugins_root / "orders"
|
||||
plugin_config_dir = plugin_dir / ".codex-plugin"
|
||||
plugin_config_dir.mkdir(parents=True)
|
||||
(plugin_config_dir / "plugin.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"name": "orders",
|
||||
"version": "1.0.0",
|
||||
"description": "Mounted order plugin.",
|
||||
"mcpServers": "./.mcp.json",
|
||||
}
|
||||
)
|
||||
)
|
||||
(plugin_dir / ".mcp.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"mcpServers": {
|
||||
"orders": {
|
||||
"command": "python",
|
||||
"args": ["server.py"],
|
||||
"cwd": ".",
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
registry = ConnectorRegistry.from_plugin_records(
|
||||
[
|
||||
{
|
||||
"id": "plugin_orders",
|
||||
"name": "orders",
|
||||
"mount": {
|
||||
"path": "orders",
|
||||
},
|
||||
}
|
||||
],
|
||||
package_root=plugins_root,
|
||||
)
|
||||
|
||||
connector = Connector.from_installed_plugin("plugin_orders", registry)
|
||||
|
||||
assert connector.metadata["unified_plugin"]["package_path"] == str(plugin_dir.resolve())
|
||||
assert connector.description == "Mounted order plugin."
|
||||
assert len(connector.mcp_servers) == 1
|
||||
|
||||
|
||||
def test_connector_registry_rejects_mounted_paths_outside_package_root(tmp_path) -> None:
|
||||
with pytest.raises(UserError, match="must stay inside the connector package root"):
|
||||
ConnectorRegistry.from_plugin_records(
|
||||
[
|
||||
{
|
||||
"id": "plugin_orders",
|
||||
"name": "orders",
|
||||
"mount": {
|
||||
"path": "../outside",
|
||||
},
|
||||
}
|
||||
],
|
||||
package_root=tmp_path / "mounted-plugins",
|
||||
)
|
||||
|
||||
|
||||
def test_connector_registry_skips_hosted_apps_without_authorization() -> None:
|
||||
registry = ConnectorRegistry.from_plugin_records(
|
||||
[
|
||||
|
||||
Reference in New Issue
Block a user