fix(cli): pass telemetry context via stdin instead of argv (#5668)
Co-authored-by: JunghwanNA <70629228+shaun0927@users.noreply.github.com>
This commit is contained in:
@@ -5,6 +5,15 @@ All notable changes to `@mem0/cli` are documented here.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [0.2.9] — 2026-06-19
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- Telemetry no longer passes the Mem0 API key to its child process via
|
||||||
|
command-line arguments. The context is now sent over stdin, so the key is no
|
||||||
|
longer visible in the process list (`ps`, `/proc/<pid>/cmdline`, Activity
|
||||||
|
Monitor). Fixes #4862.
|
||||||
|
|
||||||
## [0.2.8] — 2026-06-01
|
## [0.2.8] — 2026-06-01
|
||||||
|
|
||||||
### Security
|
### Security
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mem0/cli",
|
"name": "@mem0/cli",
|
||||||
"version": "0.2.8",
|
"version": "0.2.9",
|
||||||
"description": "The official CLI for mem0 — the memory layer for AI agents",
|
"description": "The official CLI for mem0 — the memory layer for AI agents",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|||||||
@@ -145,11 +145,11 @@ export function captureEvent(
|
|||||||
anonDistinctIdToAlias: anonIdToAlias,
|
anonDistinctIdToAlias: anonIdToAlias,
|
||||||
};
|
};
|
||||||
|
|
||||||
const child = spawn(
|
const child = spawn(process.execPath, [SENDER_SCRIPT], {
|
||||||
process.execPath,
|
detached: true,
|
||||||
[SENDER_SCRIPT, JSON.stringify(context)],
|
stdio: ["pipe", "ignore", "ignore"],
|
||||||
{ detached: true, stdio: "ignore" },
|
});
|
||||||
);
|
child.stdin?.end(JSON.stringify(context));
|
||||||
child.unref();
|
child.unref();
|
||||||
} catch {
|
} catch {
|
||||||
/* silently swallow */
|
/* silently swallow */
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* Standalone telemetry sender — runs as a detached child process.
|
* Standalone telemetry sender — runs as a detached child process.
|
||||||
*
|
*
|
||||||
* Usage: node telemetry-sender.cjs '<json context>'
|
* Usage: node telemetry-sender.cjs (JSON context is read from stdin; a single
|
||||||
|
* argv argument is still accepted as a legacy fallback)
|
||||||
*
|
*
|
||||||
* This script is spawned by telemetry.captureEvent() and runs independently
|
* This script is spawned by telemetry.captureEvent() and runs independently
|
||||||
* of the parent CLI process. It:
|
* of the parent CLI process. It:
|
||||||
@@ -19,6 +20,31 @@
|
|||||||
const https = require("https");
|
const https = require("https");
|
||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
|
|
||||||
|
function loadContext() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (process.argv[2]) {
|
||||||
|
try {
|
||||||
|
resolve(JSON.parse(process.argv[2]));
|
||||||
|
} catch (err) {
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let data = "";
|
||||||
|
process.stdin.setEncoding("utf8");
|
||||||
|
process.stdin.on("data", (chunk) => (data += chunk));
|
||||||
|
process.stdin.on("end", () => {
|
||||||
|
try {
|
||||||
|
resolve(JSON.parse(data));
|
||||||
|
} catch (err) {
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
process.stdin.on("error", reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function httpsRequest(url, method, headers, body) {
|
function httpsRequest(url, method, headers, body) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const u = new URL(url);
|
const u = new URL(url);
|
||||||
@@ -108,7 +134,7 @@ async function sendIdentifyEvent(ctx, payload, anonId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const ctx = JSON.parse(process.argv[2]);
|
const ctx = await loadContext();
|
||||||
const payload = ctx.payload;
|
const payload = ctx.payload;
|
||||||
|
|
||||||
if (ctx.needsEmail && ctx.mem0ApiKey) {
|
if (ctx.needsEmail && ctx.mem0ApiKey) {
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const mockLoadConfig = vi.fn();
|
||||||
|
const mockSaveConfig = vi.fn();
|
||||||
|
const mockSpawn = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("../src/config.js", () => ({
|
||||||
|
CONFIG_FILE: "/tmp/mem0-config.json",
|
||||||
|
loadConfig: mockLoadConfig,
|
||||||
|
saveConfig: mockSaveConfig,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("node:child_process", () => ({
|
||||||
|
spawn: mockSpawn,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("captureEvent", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetModules();
|
||||||
|
mockLoadConfig.mockReset();
|
||||||
|
mockSaveConfig.mockReset();
|
||||||
|
mockSpawn.mockReset();
|
||||||
|
delete process.env.MEM0_TELEMETRY;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pipes the telemetry context through stdin instead of argv", async () => {
|
||||||
|
mockLoadConfig.mockReturnValue({
|
||||||
|
platform: {
|
||||||
|
apiKey: "m0-node-secret",
|
||||||
|
baseUrl: "https://api.mem0.ai",
|
||||||
|
userEmail: "",
|
||||||
|
},
|
||||||
|
telemetry: {
|
||||||
|
anonymousId: "cli-anon-node",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const stdin = { end: vi.fn() };
|
||||||
|
const child = { stdin, unref: vi.fn() };
|
||||||
|
mockSpawn.mockReturnValue(child);
|
||||||
|
|
||||||
|
const { captureEvent } = await import("../src/telemetry.js");
|
||||||
|
captureEvent("node_test_event", { case: "stdin-secret" });
|
||||||
|
|
||||||
|
expect(mockSpawn).toHaveBeenCalledTimes(1);
|
||||||
|
const [execPath, args, options] = mockSpawn.mock.calls[0];
|
||||||
|
expect(execPath).toBe(process.execPath);
|
||||||
|
expect(args).toHaveLength(1);
|
||||||
|
expect(String(args[0])).toContain("telemetry-sender.cjs");
|
||||||
|
expect(JSON.stringify(args)).not.toContain("m0-node-secret");
|
||||||
|
expect(options).toMatchObject({ detached: true, stdio: ["pipe", "ignore", "ignore"] });
|
||||||
|
|
||||||
|
expect(stdin.end).toHaveBeenCalledTimes(1);
|
||||||
|
const payload = JSON.parse(stdin.end.mock.calls[0][0]);
|
||||||
|
expect(payload.mem0ApiKey).toBe("m0-node-secret");
|
||||||
|
expect(payload.payload.event).toBe("node_test_event");
|
||||||
|
expect(child.unref).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,6 +5,19 @@ All notable changes to `mem0-cli` (Python) are documented here.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [0.2.8] — 2026-06-19
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- Telemetry no longer passes the Mem0 API key to its child process via
|
||||||
|
command-line arguments. The context is now sent over stdin, so the key is no
|
||||||
|
longer visible in the process list (`ps`, `/proc/<pid>/cmdline`, Activity
|
||||||
|
Monitor). Fixes #4862.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- `__version__` now matches the packaged version (was stale at 0.2.4).
|
||||||
|
|
||||||
## [0.2.7] — 2026-05-20
|
## [0.2.7] — 2026-05-20
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "mem0-cli"
|
name = "mem0-cli"
|
||||||
version = "0.2.7"
|
version = "0.2.8"
|
||||||
description = "The official CLI for mem0 — the memory layer for AI agents"
|
description = "The official CLI for mem0 — the memory layer for AI agents"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = "Apache-2.0"
|
license = "Apache-2.0"
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""mem0 CLI — the command-line interface for the mem0 memory layer."""
|
"""mem0 CLI — the command-line interface for the mem0 memory layer."""
|
||||||
|
|
||||||
__version__ = "0.2.4"
|
__version__ = "0.2.8"
|
||||||
|
|||||||
@@ -137,12 +137,19 @@ def capture_event(
|
|||||||
"anon_distinct_id_to_alias": anon_id_to_alias,
|
"anon_distinct_id_to_alias": anon_id_to_alias,
|
||||||
}
|
}
|
||||||
|
|
||||||
subprocess.Popen(
|
child = subprocess.Popen(
|
||||||
[sys.executable, "-m", "mem0_cli.telemetry_sender", json.dumps(context)],
|
[sys.executable, "-m", "mem0_cli.telemetry_sender"],
|
||||||
|
stdin=subprocess.PIPE,
|
||||||
stdout=subprocess.DEVNULL,
|
stdout=subprocess.DEVNULL,
|
||||||
stderr=subprocess.DEVNULL,
|
stderr=subprocess.DEVNULL,
|
||||||
start_new_session=True,
|
start_new_session=True,
|
||||||
close_fds=True,
|
close_fds=True,
|
||||||
|
text=True,
|
||||||
)
|
)
|
||||||
|
if child.stdin:
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
child.stdin.write(json.dumps(context))
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
child.stdin.close()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Standalone telemetry sender — runs as a detached subprocess.
|
"""Standalone telemetry sender — runs as a detached subprocess.
|
||||||
|
|
||||||
Usage: python -m mem0_cli.telemetry_sender '<json context>'
|
Usage: python -m mem0_cli.telemetry_sender (JSON context is read from stdin;
|
||||||
|
a single argv argument is still accepted as a legacy fallback)
|
||||||
|
|
||||||
This module is spawned by telemetry.capture_event() and runs independently
|
This module is spawned by telemetry.capture_event() and runs independently
|
||||||
of the parent CLI process. It:
|
of the parent CLI process. It:
|
||||||
@@ -20,8 +21,18 @@ import sys
|
|||||||
import urllib.request
|
import urllib.request
|
||||||
|
|
||||||
|
|
||||||
|
def _load_context() -> dict:
|
||||||
|
"""Load telemetry context from stdin, falling back to argv for compatibility."""
|
||||||
|
raw = ""
|
||||||
|
if not sys.stdin.isatty():
|
||||||
|
raw = sys.stdin.read().strip()
|
||||||
|
if not raw and len(sys.argv) > 1:
|
||||||
|
raw = sys.argv[1]
|
||||||
|
return json.loads(raw)
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
ctx = json.loads(sys.argv[1])
|
ctx = _load_context()
|
||||||
payload = ctx["payload"]
|
payload = ctx["payload"]
|
||||||
|
|
||||||
if ctx.get("needs_email") and ctx.get("mem0_api_key"):
|
if ctx.get("needs_email") and ctx.get("mem0_api_key"):
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
"""Tests for telemetry subprocess secret handling."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from mem0_cli.config import Mem0Config, save_config
|
||||||
|
from mem0_cli.telemetry import capture_event
|
||||||
|
from mem0_cli.telemetry_sender import _load_context
|
||||||
|
|
||||||
|
|
||||||
|
class _CaptureStdin:
|
||||||
|
def __init__(self):
|
||||||
|
self.buffer = ""
|
||||||
|
self.closed = False
|
||||||
|
|
||||||
|
def write(self, value: str) -> None:
|
||||||
|
self.buffer += value
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
self.closed = True
|
||||||
|
|
||||||
|
|
||||||
|
class _DummyProcess:
|
||||||
|
def __init__(self):
|
||||||
|
self.stdin = _CaptureStdin()
|
||||||
|
|
||||||
|
|
||||||
|
def test_capture_event_writes_context_to_stdin_not_argv(isolate_config, monkeypatch):
|
||||||
|
config = Mem0Config()
|
||||||
|
config.platform.api_key = "m0-test-secret"
|
||||||
|
config.telemetry.anonymous_id = "cli-anon-test"
|
||||||
|
save_config(config)
|
||||||
|
|
||||||
|
captured: dict[str, object] = {}
|
||||||
|
proc = _DummyProcess()
|
||||||
|
|
||||||
|
def fake_popen(args, **kwargs):
|
||||||
|
captured["args"] = args
|
||||||
|
captured["kwargs"] = kwargs
|
||||||
|
return proc
|
||||||
|
|
||||||
|
monkeypatch.setattr("mem0_cli.telemetry.subprocess.Popen", fake_popen)
|
||||||
|
|
||||||
|
capture_event("unit_test_event", {"case": "stdin-secret"})
|
||||||
|
|
||||||
|
argv = captured["args"]
|
||||||
|
assert argv == [sys.executable, "-m", "mem0_cli.telemetry_sender"]
|
||||||
|
assert all("m0-test-secret" not in arg for arg in argv)
|
||||||
|
|
||||||
|
kwargs = captured["kwargs"]
|
||||||
|
assert kwargs["stdin"] == subprocess.PIPE
|
||||||
|
assert kwargs["text"] is True
|
||||||
|
|
||||||
|
ctx = json.loads(proc.stdin.buffer)
|
||||||
|
assert ctx["mem0_api_key"] == "m0-test-secret"
|
||||||
|
assert ctx["payload"]["event"] == "unit_test_event"
|
||||||
|
|
||||||
|
assert proc.stdin.closed
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_context_reads_from_stdin(monkeypatch):
|
||||||
|
monkeypatch.setattr("sys.argv", ["telemetry_sender"])
|
||||||
|
monkeypatch.setattr("sys.stdin", io.StringIO('{"payload": {"event": "stdin"}}'))
|
||||||
|
|
||||||
|
ctx = _load_context()
|
||||||
|
|
||||||
|
assert ctx["payload"]["event"] == "stdin"
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_context_falls_back_to_argv(monkeypatch):
|
||||||
|
monkeypatch.setattr("sys.argv", ["telemetry_sender", '{"payload": {"event": "argv"}}'])
|
||||||
|
monkeypatch.setattr("sys.stdin", io.StringIO(""))
|
||||||
|
|
||||||
|
ctx = _load_context()
|
||||||
|
|
||||||
|
assert ctx["payload"]["event"] == "argv"
|
||||||
Reference in New Issue
Block a user