feat(integrations): declare which surface each client is, and its version (#7326)
This commit is contained in:
@@ -81,15 +81,23 @@ def replace_output(staged: Path, output: Path) -> Path:
|
||||
return output
|
||||
|
||||
|
||||
def _render_harness_id(host: str) -> str:
|
||||
def _render_harness_id(host: str, *, portable: bool = False) -> str:
|
||||
"""Emit core/_harness_id.py for one host.
|
||||
|
||||
Carries both vocabularies from a single definition: the PostHog `source` tag
|
||||
and the platform's X-Mem0-Source / X-Application pair. Keeping them together
|
||||
is what stops the two from drifting into separate vocabularies for the same
|
||||
thing.
|
||||
|
||||
The portable bundle runs in whatever editor a user drops it into, so it does
|
||||
not know its host and must not guess one. HARNESS_ID stays "coding-agent",
|
||||
which is true and useful for grouping in PostHog, but PLATFORM_APPLICATION is
|
||||
left empty: X-Application names a real host app, is checked against an
|
||||
allowlist server-side, and a value that is always discarded is worse than no
|
||||
value -- it reads like an attribution we have and do not.
|
||||
"""
|
||||
tag = host.upper().replace("-", "_") + "_PLUGIN"
|
||||
application = "" if portable else host
|
||||
return (
|
||||
'"""Generated by integrations/agent-plugin-core/build/build.py. Do not edit."""\n'
|
||||
"\n"
|
||||
@@ -98,8 +106,10 @@ def _render_harness_id(host: str) -> str:
|
||||
"\n"
|
||||
"# Platform-side vocabulary (mem0_event.source + X-Application). The whole\n"
|
||||
"# plugin family is one source; which editor it runs in is the application.\n"
|
||||
"# An empty application means the host is unknown, and memory_core omits\n"
|
||||
"# the header entirely rather than sending a placeholder.\n"
|
||||
'PLATFORM_SOURCE = "MEM0_PLUGIN"\n'
|
||||
f'PLATFORM_APPLICATION = "{host}"\n'
|
||||
f'PLATFORM_APPLICATION = "{application}"\n'
|
||||
)
|
||||
|
||||
|
||||
@@ -122,7 +132,7 @@ def _bundle_python(
|
||||
# to call telemetry.init(). mcp_server.py and the detached telemetry.py sender
|
||||
# never did, which is how MCP searches reported harness=generic and every
|
||||
# batch they drained was labelled MEM0_PLUGIN regardless of the real host.
|
||||
(core / "_harness_id.py").write_text(_render_harness_id(host), encoding="utf-8")
|
||||
(core / "_harness_id.py").write_text(_render_harness_id(host, portable=portable), encoding="utf-8")
|
||||
|
||||
values = {
|
||||
"PLUGIN_ROOT": plugin_root,
|
||||
|
||||
@@ -1800,6 +1800,34 @@ def extraction_message_batches(
|
||||
return batches
|
||||
|
||||
|
||||
# Platform surface attribution. Read from the generated per-host module so a new
|
||||
# entrypoint is correct without remembering to configure anything.
|
||||
try: # pragma: no cover - absent only in the un-built shared source tree
|
||||
from _harness_id import PLATFORM_APPLICATION as _PLATFORM_APPLICATION
|
||||
from _harness_id import PLATFORM_SOURCE as _PLATFORM_SOURCE
|
||||
except ImportError:
|
||||
_PLATFORM_SOURCE = "MEM0_PLUGIN"
|
||||
_PLATFORM_APPLICATION = ""
|
||||
|
||||
|
||||
def platform_headers(key: str) -> dict[str, str]:
|
||||
"""Auth plus the three surface-identity headers.
|
||||
|
||||
X-Mem0-Source and X-Application are set-once by contract: this is the
|
||||
outermost layer, so it sets them, and nothing below may overwrite them.
|
||||
X-Mem0-Client is append-only — anything downstream adds itself to the tail.
|
||||
"""
|
||||
headers = {
|
||||
"Authorization": f"Token {key}",
|
||||
"Content-Type": "application/json",
|
||||
"X-Mem0-Source": _PLATFORM_SOURCE,
|
||||
"X-Mem0-Client": f"mem0-plugin/{PLUGIN_VERSION}",
|
||||
}
|
||||
if _PLATFORM_APPLICATION:
|
||||
headers["X-Application"] = _PLATFORM_APPLICATION
|
||||
return headers
|
||||
|
||||
|
||||
def _request_json(
|
||||
url: str, key: str, payload: dict[str, Any], timeout: float
|
||||
) -> tuple[dict[str, Any] | list[Any], int, int]:
|
||||
@@ -1807,7 +1835,7 @@ def _request_json(
|
||||
request = urllib.request.Request(
|
||||
url,
|
||||
data=raw,
|
||||
headers={"Authorization": f"Token {key}", "Content-Type": "application/json"},
|
||||
headers=platform_headers(key),
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||
@@ -1834,7 +1862,7 @@ def _get_json(
|
||||
) -> tuple[dict[str, Any] | list[Any], int]:
|
||||
request = urllib.request.Request(
|
||||
url,
|
||||
headers={"Authorization": f"Token {key}", "Content-Type": "application/json"},
|
||||
headers=platform_headers(key),
|
||||
method="GET",
|
||||
)
|
||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||
@@ -1980,6 +2008,13 @@ def flush_session(
|
||||
"user_id": write_user,
|
||||
"app_id": repo.app_id,
|
||||
"run_id": session_id,
|
||||
# Top level, not metadata: the backend reads `source` from the body or
|
||||
# the query string, never from metadata, which is where this used to
|
||||
# sit. The X-Mem0-Source header is also read, but only from the
|
||||
# platform release that ships alongside this change, so the body value
|
||||
# is what makes attribution work on both. The harness tag stays in
|
||||
# metadata as hook provenance.
|
||||
"source": _PLATFORM_SOURCE,
|
||||
"metadata": {**metadata, "author": write_user, "dirs": directory_chain(repo)},
|
||||
"agent_custom_instructions": PROJECT_MEMORY_INSTRUCTIONS,
|
||||
"custom_instructions": PERSONAL_MEMORY_INSTRUCTIONS,
|
||||
@@ -2523,7 +2558,7 @@ def _collect_memory_ids(
|
||||
def _delete_memory(api_url: str, key: str, memory_id: str) -> bool:
|
||||
request = urllib.request.Request(
|
||||
f"{api_url}/v1/memories/{urllib.parse.quote(memory_id)}/",
|
||||
headers={"Authorization": f"Token {key}", "Content-Type": "application/json"},
|
||||
headers=platform_headers(key),
|
||||
method="DELETE",
|
||||
)
|
||||
try:
|
||||
|
||||
@@ -70,6 +70,38 @@ def test_portable_bundle_is_conformant_and_self_contained(tmp_path: Path) -> Non
|
||||
assert not any(path.is_symlink() for path in root.rglob("*"))
|
||||
|
||||
|
||||
def _harness_identity(root: Path) -> dict[str, str]:
|
||||
"""Read the generated core/_harness_id.py without importing it."""
|
||||
values: dict[str, str] = {}
|
||||
for line in (root / "core" / "_harness_id.py").read_text(encoding="utf-8").splitlines():
|
||||
if "=" in line and not line.lstrip().startswith("#"):
|
||||
name, _, raw = line.partition("=")
|
||||
values[name.strip()] = raw.strip().strip('"')
|
||||
return values
|
||||
|
||||
|
||||
def test_the_portable_bundle_declares_no_host_application(tmp_path: Path) -> None:
|
||||
"""It runs in whatever editor a user drops it into, so it cannot know the host.
|
||||
|
||||
X-Application is allowlisted server-side. A guessed value is silently dropped
|
||||
there, which is the worst outcome: the wire says we know the host and the
|
||||
stored event says we do not.
|
||||
"""
|
||||
identity = _harness_identity(build("mem0-agent-plugin", "portable", tmp_path / "portable"))
|
||||
|
||||
assert identity["PLATFORM_APPLICATION"] == ""
|
||||
# The PostHog-side label is still useful for grouping and stays populated.
|
||||
assert identity["HARNESS_ID"] == "coding-agent"
|
||||
assert identity["PLATFORM_SOURCE"] == "MEM0_PLUGIN"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("host", ["claude-code", "cursor", "codex", "kimi", "antigravity"])
|
||||
def test_a_native_bundle_names_the_host_it_was_built_for(host: str, tmp_path: Path) -> None:
|
||||
identity = _harness_identity(build(host, "native", tmp_path / host))
|
||||
|
||||
assert identity["PLATFORM_APPLICATION"] == host
|
||||
|
||||
|
||||
@pytest.mark.parametrize("host", ["claude-code", "cursor", "codex", "kimi", "antigravity"])
|
||||
def test_native_bundle_is_self_contained(host: str, tmp_path: Path) -> None:
|
||||
root = build(host, "native", tmp_path / host)
|
||||
|
||||
@@ -179,6 +179,33 @@ def test_source_tag_defaults_agree_between_the_two_modules():
|
||||
assert left == right == "KIMI_PLUGIN"
|
||||
|
||||
|
||||
def test_the_plugin_declares_its_surface_in_the_body_and_the_headers():
|
||||
"""Body and headers both, because only the body works on every backend."""
|
||||
core = _core_dir("claude-code-plugin")
|
||||
if not core.exists():
|
||||
pytest.skip("claude-code-plugin is not built in this tree")
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
out = _run(
|
||||
core,
|
||||
Path(tmp),
|
||||
"import json, memory_core\n"
|
||||
"h = memory_core.platform_headers('k')\n"
|
||||
"print(json.dumps({'source': h.get('X-Mem0-Source'),"
|
||||
" 'app': h.get('X-Application'),"
|
||||
" 'client': h.get('X-Mem0-Client'),"
|
||||
" 'auth': h.get('Authorization'),"
|
||||
" 'ctype': h.get('Content-Type')}))",
|
||||
)
|
||||
headers = json.loads(out)
|
||||
assert headers["source"] == "MEM0_PLUGIN"
|
||||
assert headers["app"] == "claude-code"
|
||||
assert headers["client"].startswith("mem0-plugin/")
|
||||
# The transport headers the three call sites relied on must survive.
|
||||
assert headers["auth"] == "Token k"
|
||||
assert headers["ctype"] == "application/json"
|
||||
|
||||
|
||||
def _session_start(core: Path, data_dir: Path) -> list[str]:
|
||||
"""Drive the real hook_runner session-start path and return lifecycle events."""
|
||||
recorded = "\n".join(
|
||||
|
||||
Reference in New Issue
Block a user