diff --git a/.antigravity-plugin/scripts/monk-launcher-telemetry.sh b/.antigravity-plugin/scripts/monk-launcher-telemetry.sh index 1ae5279..fed7d03 100755 --- a/.antigravity-plugin/scripts/monk-launcher-telemetry.sh +++ b/.antigravity-plugin/scripts/monk-launcher-telemetry.sh @@ -92,16 +92,35 @@ monk_emit_launcher_event() { _osa="$(uname -m 2>/dev/null || printf unknown)" _plat="$(printf '%s' "$_osn" | tr '[:upper:]' '[:lower:]')" - # Strip characters that would break the hand-built JSON. - _msan() { printf '%s' "$1" | tr -d '"\\' | tr -d '\n\r'; } + # Encode a shell string as JSON string content. LC_ALL=C makes awk's %c + # byte-preserving, so UTF-8 survives while every U+0000-U+001F byte that JSON + # forbids is escaped. POSIX od/awk keep this usable before the agent exists. + _mjson() { + printf '%s' "$1" | + LC_ALL=C od -An -v -t u1 2>/dev/null | + LC_ALL=C awk '{ + for (i = 1; i <= NF; i++) { + b = $i + 0 + if (b == 34) printf "\\\"" + else if (b == 92) printf "\\\\" + else if (b == 8) printf "\\b" + else if (b == 9) printf "\\t" + else if (b == 10) printf "\\n" + else if (b == 12) printf "\\f" + else if (b == 13) printf "\\r" + else if (b < 32) printf "\\u%04x", b + else printf "%c", b + } + }' + } _payload="$( printf '{"api_key":"%s","event":"plugin_launcher_started","distinct_id":"%s","properties":{"launch_client":"%s","host_client":"%s","client":"%s","first_start":%s,"agent_installed":%s,"client_id_source":"%s","plugin_version":"%s","ide_version":"%s","platform":"%s","os_arch":"%s","source":"monk-plugin-launcher"}}' \ - "$(_msan "$_ph_key")" "$(_msan "$_cid")" \ - "$(_msan "$_mlc")" "$(_msan "$_mlc")" "$(_msan "$_mlc")" \ + "$(_mjson "$_ph_key")" "$(_mjson "$_cid")" \ + "$(_mjson "$_mlc")" "$(_mjson "$_mlc")" "$(_mjson "$_mlc")" \ "$_first_start" "$_agent_installed" "$_cid_src" \ - "$(_msan "$_pv")" "$(_msan "$_mlc_ide")" \ - "$(_msan "$_plat")" "$(_msan "$_osa")" + "$(_mjson "$_pv")" "$(_mjson "$_mlc_ide")" \ + "$(_mjson "$_plat")" "$(_mjson "$_osa")" )" if command -v curl >/dev/null 2>&1; then diff --git a/.github/workflows/install-e2e.yml b/.github/workflows/install-e2e.yml index 52ce071..913ec87 100644 --- a/.github/workflows/install-e2e.yml +++ b/.github/workflows/install-e2e.yml @@ -62,6 +62,10 @@ jobs: shell: bash run: ./tests/start-monk-agent-readiness-timeout.sh + - name: Check launcher telemetry JSON escaping + shell: bash + run: ./tests/monk-launcher-telemetry-json.sh + windows: name: windows-latest runs-on: windows-latest diff --git a/plugins/monk/scripts/monk-launcher-telemetry.sh b/plugins/monk/scripts/monk-launcher-telemetry.sh index 1ae5279..fed7d03 100755 --- a/plugins/monk/scripts/monk-launcher-telemetry.sh +++ b/plugins/monk/scripts/monk-launcher-telemetry.sh @@ -92,16 +92,35 @@ monk_emit_launcher_event() { _osa="$(uname -m 2>/dev/null || printf unknown)" _plat="$(printf '%s' "$_osn" | tr '[:upper:]' '[:lower:]')" - # Strip characters that would break the hand-built JSON. - _msan() { printf '%s' "$1" | tr -d '"\\' | tr -d '\n\r'; } + # Encode a shell string as JSON string content. LC_ALL=C makes awk's %c + # byte-preserving, so UTF-8 survives while every U+0000-U+001F byte that JSON + # forbids is escaped. POSIX od/awk keep this usable before the agent exists. + _mjson() { + printf '%s' "$1" | + LC_ALL=C od -An -v -t u1 2>/dev/null | + LC_ALL=C awk '{ + for (i = 1; i <= NF; i++) { + b = $i + 0 + if (b == 34) printf "\\\"" + else if (b == 92) printf "\\\\" + else if (b == 8) printf "\\b" + else if (b == 9) printf "\\t" + else if (b == 10) printf "\\n" + else if (b == 12) printf "\\f" + else if (b == 13) printf "\\r" + else if (b < 32) printf "\\u%04x", b + else printf "%c", b + } + }' + } _payload="$( printf '{"api_key":"%s","event":"plugin_launcher_started","distinct_id":"%s","properties":{"launch_client":"%s","host_client":"%s","client":"%s","first_start":%s,"agent_installed":%s,"client_id_source":"%s","plugin_version":"%s","ide_version":"%s","platform":"%s","os_arch":"%s","source":"monk-plugin-launcher"}}' \ - "$(_msan "$_ph_key")" "$(_msan "$_cid")" \ - "$(_msan "$_mlc")" "$(_msan "$_mlc")" "$(_msan "$_mlc")" \ + "$(_mjson "$_ph_key")" "$(_mjson "$_cid")" \ + "$(_mjson "$_mlc")" "$(_mjson "$_mlc")" "$(_mjson "$_mlc")" \ "$_first_start" "$_agent_installed" "$_cid_src" \ - "$(_msan "$_pv")" "$(_msan "$_mlc_ide")" \ - "$(_msan "$_plat")" "$(_msan "$_osa")" + "$(_mjson "$_pv")" "$(_mjson "$_mlc_ide")" \ + "$(_mjson "$_plat")" "$(_mjson "$_osa")" )" if command -v curl >/dev/null 2>&1; then diff --git a/scripts/monk-launcher-telemetry.sh b/scripts/monk-launcher-telemetry.sh index 1ae5279..fed7d03 100755 --- a/scripts/monk-launcher-telemetry.sh +++ b/scripts/monk-launcher-telemetry.sh @@ -92,16 +92,35 @@ monk_emit_launcher_event() { _osa="$(uname -m 2>/dev/null || printf unknown)" _plat="$(printf '%s' "$_osn" | tr '[:upper:]' '[:lower:]')" - # Strip characters that would break the hand-built JSON. - _msan() { printf '%s' "$1" | tr -d '"\\' | tr -d '\n\r'; } + # Encode a shell string as JSON string content. LC_ALL=C makes awk's %c + # byte-preserving, so UTF-8 survives while every U+0000-U+001F byte that JSON + # forbids is escaped. POSIX od/awk keep this usable before the agent exists. + _mjson() { + printf '%s' "$1" | + LC_ALL=C od -An -v -t u1 2>/dev/null | + LC_ALL=C awk '{ + for (i = 1; i <= NF; i++) { + b = $i + 0 + if (b == 34) printf "\\\"" + else if (b == 92) printf "\\\\" + else if (b == 8) printf "\\b" + else if (b == 9) printf "\\t" + else if (b == 10) printf "\\n" + else if (b == 12) printf "\\f" + else if (b == 13) printf "\\r" + else if (b < 32) printf "\\u%04x", b + else printf "%c", b + } + }' + } _payload="$( printf '{"api_key":"%s","event":"plugin_launcher_started","distinct_id":"%s","properties":{"launch_client":"%s","host_client":"%s","client":"%s","first_start":%s,"agent_installed":%s,"client_id_source":"%s","plugin_version":"%s","ide_version":"%s","platform":"%s","os_arch":"%s","source":"monk-plugin-launcher"}}' \ - "$(_msan "$_ph_key")" "$(_msan "$_cid")" \ - "$(_msan "$_mlc")" "$(_msan "$_mlc")" "$(_msan "$_mlc")" \ + "$(_mjson "$_ph_key")" "$(_mjson "$_cid")" \ + "$(_mjson "$_mlc")" "$(_mjson "$_mlc")" "$(_mjson "$_mlc")" \ "$_first_start" "$_agent_installed" "$_cid_src" \ - "$(_msan "$_pv")" "$(_msan "$_mlc_ide")" \ - "$(_msan "$_plat")" "$(_msan "$_osa")" + "$(_mjson "$_pv")" "$(_mjson "$_mlc_ide")" \ + "$(_mjson "$_plat")" "$(_mjson "$_osa")" )" if command -v curl >/dev/null 2>&1; then diff --git a/tests/monk-launcher-telemetry-json.sh b/tests/monk-launcher-telemetry-json.sh new file mode 100755 index 0000000..532b8ab --- /dev/null +++ b/tests/monk-launcher-telemetry-json.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env sh +# Regression coverage for #179: launcher telemetry must emit valid JSON without +# losing quotes, slashes, controls, or UTF-8 bytes from dynamic properties. +set -eu + +repo_root="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" +work_dir="$(mktemp -d)" +trap 'rm -rf "$work_dir"' EXIT HUP INT TERM +capture_path="$work_dir/payload.json" + +curl() { + _test_payload="" + while [ "$#" -gt 0 ]; do + if [ "$1" = "-d" ]; then + shift + _test_payload="$1" + fi + shift + done + printf '%s' "$_test_payload" >"$capture_path" +} + +# shellcheck source=../scripts/monk-launcher-telemetry.sh +. "$repo_root/scripts/monk-launcher-telemetry.sh" + +client="$(printf 'codex"\\\tline\nctrl\001&end')" +ide_version="$(printf 'v1\r\f\b\002done')" +plugin_version="$(printf 'plugin"\\\t\n\003')" +posthog_key="$(printf 'key"\\\t\004end')" + +HOME="$work_dir/home" +MONK_AGENT_HOME="$work_dir/monk" +MONK_AGENT_INSTALL_DIR="$work_dir/bin" +MONK_POSTHOG_HOST="https://capture.invalid" +MONK_POSTHOG_KEY="$posthog_key" +MONK_PLUGIN_VERSION="$plugin_version" +export HOME MONK_AGENT_HOME MONK_AGENT_INSTALL_DIR +export MONK_POSTHOG_HOST MONK_POSTHOG_KEY MONK_PLUGIN_VERSION +export client ide_version plugin_version posthog_key capture_path + +monk_emit_launcher_event "$client" "$ide_version" +wait +test -s "$capture_path" + +if [ -z "${PYTHON:-}" ]; then + if command -v python3 >/dev/null 2>&1; then + PYTHON=python3 + elif command -v python >/dev/null 2>&1; then + PYTHON=python + else + echo "python is required to validate the captured JSON" >&2 + exit 1 + fi +fi + +"$PYTHON" - <<'PY' +import json +import os + +with open(os.environ["capture_path"], encoding="utf-8") as stream: + payload = json.load(stream) + +properties = payload["properties"] +assert payload["api_key"] == os.environ["posthog_key"] +assert properties["launch_client"] == os.environ["client"] +assert properties["host_client"] == os.environ["client"] +assert properties["client"] == os.environ["client"] +assert properties["plugin_version"] == os.environ["plugin_version"] +assert properties["ide_version"] == os.environ["ide_version"] +assert properties["first_start"] is True +assert properties["agent_installed"] is False +PY + +cmp "$repo_root/scripts/monk-launcher-telemetry.sh" \ + "$repo_root/plugins/monk/scripts/monk-launcher-telemetry.sh" +cmp "$repo_root/scripts/monk-launcher-telemetry.sh" \ + "$repo_root/.antigravity-plugin/scripts/monk-launcher-telemetry.sh" + +printf 'launcher_telemetry_json_status=pass\n'