From d5db5912cadd2a10875cadc56dda385a9fd1b5f1 Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Sun, 27 Sep 2026 20:58:37 +0800 Subject: [PATCH 1/3] feat(usage): unify CLI and App activation after first disclosure Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- apps/presentation/dashboard/src/data/chat.ts | 7 ++ .../personal-workspace-page.tsx | 4 + .../personal-workspace/personal-workspace.css | 11 +++ .../usage-statistics-notice.tsx | 87 +++++++++++++++++++ .../usage-statistics-settings.tsx | 4 +- loopx/chat_usage_statistics_api.py | 9 +- .../control_plane/runtime/usage_statistics.ts | 12 ++- loopx/usage_ping.py | 13 ++- .../control_plane_ts/usage_statistics.test.ts | 24 +++++ tests/test_usage_ping.py | 81 +++++++++++++++-- 10 files changed, 233 insertions(+), 19 deletions(-) create mode 100644 apps/presentation/dashboard/src/features/personal-workspace/usage-statistics-notice.tsx diff --git a/apps/presentation/dashboard/src/data/chat.ts b/apps/presentation/dashboard/src/data/chat.ts index 002a15b05..2046b3a49 100644 --- a/apps/presentation/dashboard/src/data/chat.ts +++ b/apps/presentation/dashboard/src/data/chat.ts @@ -2072,6 +2072,8 @@ const usageStatisticsSchema = z.object({ consent: z.enum(["default", "enabled", "disabled"]), sending: z.boolean(), blocked_by: z.string().nullable(), endpoint: z.string().nullable(), policy: z.string(), notice_required: z.boolean(), + notice: z.object({ version: z.number(), endpoint: z.string(), policy: z.string() }), + automatic_notice_required: z.boolean(), next_payload: z.unknown(), aggregate_preview: z.unknown(), goal_preview: z.unknown(), }); export type UsageStatistics = z.infer; @@ -2079,3 +2081,8 @@ export async function usageStatistics(enabled?: boolean): Promise("/api/chat/usage-statistics", enabled === undefined ? undefined : { method: "POST", body: JSON.stringify({ enabled }) })); } + +export async function acknowledgeUsageNotice(notice: UsageStatistics["notice"]): Promise { + return usageStatisticsSchema.parse(await requestJson("/api/chat/usage-statistics", + { method: "POST", body: JSON.stringify({ notice }) })); +} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx index 67f703467..a74e86028 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx @@ -65,6 +65,7 @@ import { WorkspaceActionForm, type WorkspaceActionDraft } from "./workspace-acti import { GoalActivityChip, GoalIdentityMark } from "./goal-activity-view"; import { ManagerBrief } from "./manager-brief"; import { WorkspaceSettingsPage } from "./workspace-settings-page"; +import { UsageStatisticsNotice } from "./usage-statistics-notice"; import { readWorkspaceTheme, writeWorkspaceTheme, type WorkspaceTheme } from "./workspace-theme"; import { compareProposalRecency } from "./proposal-recency"; import { WorkspaceShell } from "./workspace-shell"; @@ -1679,6 +1680,7 @@ export function PersonalWorkspacePage({ theme={theme} main={(
+
+ {!readOnly ? openSettings({ kind: "settings", tab: "machine" })} /> : null} +
{selectedGoalId && selectedGoalTab === "chat" && !readOnly && selectedAgentId === "codex" && callbacks.onStartLoopX ? callbacks.onPrepareLoopX!(selectedAgentId, selectedGoalId)} key={`${selectedGoalId}:${selectedAgentId}`} sessionId={conversationSessionId} onChange={setLoopxMode} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css index bf46b8871..3af100c66 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css @@ -1869,6 +1869,17 @@ .personal-usage-statistics > p { padding-left: 0; overflow-wrap: anywhere; } .personal-usage-statistics button { margin-block: 8px; } .personal-usage-statistics pre { max-height: 180px; overflow: auto; font-size: 12px; } +.personal-usage-notice { display: flex; align-items: flex-start; gap: 16px; padding: 16px 24px; border-bottom: 1px solid var(--pw-line); background: var(--pw-card); font-size: 12px; line-height: 1.5; } +.personal-usage-notice > div:first-child { min-width: 0; flex: 1; } +.personal-usage-notice strong { color: var(--pw-text); font-size: 14px; font-weight: 600; } +.personal-usage-notice p { margin: 4px 0 0; color: var(--pw-muted); } +.personal-usage-notice .personal-usage-recipient { overflow-wrap: anywhere; } +.personal-usage-notice-actions { display: flex; flex-wrap: wrap; gap: 8px; } +.personal-usage-notice-actions button { min-height: 44px; padding: 8px 12px; border: 1px solid var(--pw-line-strong); border-radius: 6px; color: var(--pw-text); background: var(--pw-card); white-space: nowrap; cursor: pointer; } +.personal-usage-notice-actions button:disabled { opacity: .6; cursor: wait; } +@media (max-width: 640px) { + .personal-usage-notice { flex-direction: column; gap: 8px; padding: 12px 20px; } +} .personal-action-form { margin: auto; width: min(520px, calc(100vw - 32px)); max-height: calc(100dvh - 40px); overflow: auto; padding: 24px; border: 1px solid var(--pw-line-strong); border-radius: 16px; color: var(--pw-text); background: var(--pw-card); } .personal-action-form::backdrop { background: rgb(0 0 0 / 35%); } .personal-action-form form, .personal-action-form fieldset, .personal-action-form label { display: grid; gap: 12px; } diff --git a/apps/presentation/dashboard/src/features/personal-workspace/usage-statistics-notice.tsx b/apps/presentation/dashboard/src/features/personal-workspace/usage-statistics-notice.tsx new file mode 100644 index 000000000..02c65676a --- /dev/null +++ b/apps/presentation/dashboard/src/features/personal-workspace/usage-statistics-notice.tsx @@ -0,0 +1,87 @@ +import { useEffect, useRef, useState } from "react"; +import { acknowledgeUsageNotice, usageStatistics, type UsageStatistics } from "../../data/chat"; +import { useWorkspaceI18n } from "./i18n"; + +/** Show the disclosure on the live App before acknowledging the shared policy. */ +export function UsageStatisticsNotice({ onDetails }: { onDetails: () => void }) { + const { locale } = useWorkspaceI18n(); + const zh = locale === "zh-CN"; + const [state, setState] = useState(null); + const [dismissed, setDismissed] = useState(false); + const [error, setError] = useState(false); + const [disabling, setDisabling] = useState(false); + const panel = useRef(null); + const attempted = useRef(false); + const choice = useRef(0); + useEffect(() => { + let active = true; + usageStatistics().then(value => { + if (active && value.automatic_notice_required) setState(value); + }).catch(() => {}); // An unavailable settings service cannot authorize collection. + return () => { active = false; }; + }, []); + useEffect(() => { + if (!state?.automatic_notice_required || dismissed || !panel.current) return; + let active = true; + let frame = 0; + const element = panel.current; + const acknowledge = () => { + cancelAnimationFrame(frame); + if (document.visibilityState !== "visible" || !element.getClientRects().length) return; + // Two frames let the visible disclosure paint before changing local state. + frame = requestAnimationFrame(() => { + frame = requestAnimationFrame(() => { + if (!active || attempted.current || document.visibilityState !== "visible" + || !element.getClientRects().length) return; + const rect = element.getBoundingClientRect(); + if (rect.bottom <= 0 || rect.top >= window.innerHeight) return; + attempted.current = true; + const revision = choice.current; + acknowledgeUsageNotice(state.notice).then(value => { + if (active && revision === choice.current) setState(value); + }).catch(() => { if (active && revision === choice.current) setError(true); }); + }); + }); + }; + const observer = new IntersectionObserver(entries => { + if (entries.some(entry => entry.isIntersecting)) acknowledge(); + }); + observer.observe(element); + document.addEventListener("visibilitychange", acknowledge); + return () => { + active = false; + cancelAnimationFrame(frame); + observer.disconnect(); + document.removeEventListener("visibilitychange", acknowledge); + }; + }, [state, dismissed]); + + async function disable() { + choice.current++; + attempted.current = true; + setDisabling(true); + setError(false); + try { setState(await usageStatistics(false)); } + catch { setError(true); } + finally { setDisabling(false); } + } + if (!state || dismissed) return null; + return
+
+ {state.consent === "disabled" ? (zh ? "基础使用统计已关闭" : "Basic usage statistics disabled") + : state.sending ? (zh ? "基础使用统计已开启" : "Basic usage statistics enabled") + : state.automatic_notice_required ? (zh ? "基础使用统计 · 告知后自动开启" : "Basic usage statistics · enabled after this notice") + : (zh ? "基础使用统计当前不发送,请查看详情" : "Basic usage statistics are not sending; see details")} +

{zh + ? "用于改进平台支持与使用体验。发送随机安装标识和环境信息,以及另行汇总的 CLI 使用次数、结果、耗时和 Goal 时长区间;不采集对话、代码、路径或命令参数。可随时关闭。" + : "Helps improve platform support and usage. Sends a random installation ID and environment information, plus separate CLI usage, result, timing and Goal duration summaries. No conversations, code, paths or command arguments. You can turn it off at any time."}

+

{zh ? "接收方:" : "Recipient: "}{state.endpoint}

+ {error ?

{zh ? "设置未能保存,请打开详情重试。" : "Could not save this setting. Open details to retry."}

: null} +
+
+ {state.consent !== "disabled" ? : null} + + +
+
; +} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/usage-statistics-settings.tsx b/apps/presentation/dashboard/src/features/personal-workspace/usage-statistics-settings.tsx index 9f5a6fbad..9e33b9264 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/usage-statistics-settings.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/usage-statistics-settings.tsx @@ -21,14 +21,14 @@ export function UsageStatisticsSettings() { finally { setBusy(false); } } return
- {zh ? "基础使用统计 · 默认开启,可关闭" : "Basic usage statistics · on by default, optional"} + {zh ? "基础使用统计 · 告知后默认开启,可关闭" : "Basic usage statistics · on after notice, optional"}

{zh ? "用于决定平台支持和改进命令体验。每天向 LoopX 的 Cloudflare 收集服务发送随机安装标识、版本、系统、CPU 架构、Python 版本和安装渠道;固定的 CLI 功能、结果、耗时区间和错误类别在本机按天汇总后另行发送,不带安装标识。" : "Helps prioritize platform support and CLI improvements. A daily heartbeat sends a random installation ID, version, OS, CPU architecture, Python version and install channel to the LoopX Cloudflare collector. Fixed CLI feature, result, duration and error counts are aggregated locally by day and sent separately without the ID."}

{zh ? "不采集提示词、代码、路径、命令参数、Goal 内容或原始错误。当前功能计数仅覆盖 CLI;命令成功不等于 Goal 完成。" : "No prompts, code, paths, arguments, Goal contents or raw errors. Feature counts currently cover CLI only; command success is not Goal completion."}

{zh ? "按天分别汇总所有 Host 的 quota→spend 推进周期、已绑定 Codex 任务的本地轮次时间、受管 Turn 与普通 Goal 对话的 Host 调用时间。上传固定 Host 类别及跨度/时长区间,不上传会话内容、Goal 或安装标识。三种口径重叠,不能相加;可能漏计,不代表完成、CPU 用时或计费。" : "Daily, separate span/duration buckets for all Hosts using quota→spend, local timing events from bound Codex tasks, and direct Host calls in managed Turns and regular owner Goal chat. Sends fixed Host categories, never session contents, Goal or installation IDs. The three overlapping populations cannot be added; partial observations are not completion, CPU time or billing."}

{state ? <> -

{state.sending ? (zh ? "已允许发送" : "Sending allowed") : state.notice_required && state.consent !== "disabled" ? (zh ? "等待首次告知确认;尚未发送" : "Awaiting first-use acknowledgment; not sending") diff --git a/loopx/chat_usage_statistics_api.py b/loopx/chat_usage_statistics_api.py index c2263f454..316cf7c11 100644 --- a/loopx/chat_usage_statistics_api.py +++ b/loopx/chat_usage_statistics_api.py @@ -23,16 +23,19 @@ def _usage_statistics_status(self) -> None: def _usage_statistics_update(self) -> None: try: body = self._read_json() + if set(body) == {"notice"} and isinstance(body["notice"], dict): + self._usage_statistics_request("acknowledge", notice=body["notice"]) + return if set(body) != {"enabled"} or not isinstance(body["enabled"], bool): - raise ValueError("enabled must be the only field and a boolean") + raise ValueError("provide either a boolean enabled or the displayed notice") except (TypeError, ValueError) as exc: self._send_error(str(exc), status=400, error_code="invalid_usage_settings") return self._usage_statistics_request("enable" if body["enabled"] else "disable") - def _usage_statistics_request(self, action: str) -> None: + def _usage_statistics_request(self, action: str, **fields: Any) -> None: try: - projection = control(action) + projection = control(action, **fields) except (OSError, ValueError, RuntimeError, TimeoutError, subprocess.TimeoutExpired): self._send_error("Usage settings unavailable; use loopx usage-ping status in the terminal.", status=503, error_code="usage_settings_unavailable") diff --git a/loopx/control_plane/runtime/usage_statistics.ts b/loopx/control_plane/runtime/usage_statistics.ts index 321d3b623..b5a79ebb2 100644 --- a/loopx/control_plane/runtime/usage_statistics.ts +++ b/loopx/control_plane/runtime/usage_statistics.ts @@ -39,6 +39,13 @@ function notice(ctx: Context): Notice { function sameNotice(state: State, ctx: Context): boolean { return JSON.stringify(state.notice) === JSON.stringify(notice(ctx)); } +function automaticNoticeRequired(state: State, ctx: Context): boolean { + // New installations and expanded disclosures may use notice-before-default-on. + // A changed recipient or policy still needs the owner's explicit choice. + return blockedBy(state, ctx) === "notice_required" + && (!state.notice || (state.notice.version !== NOTICE_VERSION + && state.notice.endpoint === endpoint(ctx.env) && state.notice.policy === notice(ctx).policy)); +} export function blockedBy(state: State, ctx: Context): string | null { const env = ctx.env; if (["0", "false", "no", "off"].includes((env.LOOPX_USAGE_PING ?? "").trim().toLowerCase())) return "LOOPX_USAGE_PING"; @@ -89,7 +96,8 @@ export async function inspect(path: string, ctx: Context) { const blocked = blockedBy(state, ctx); return { schema: "loopx_usage_ping_status_v1", consent: state.consent, sending: blocked === null, blocked_by: blocked, endpoint: endpoint(ctx.env) || null, policy: notice(ctx).policy, - notice: notice(ctx), notice_required: !sameNotice(state, ctx), last_sent_day: state.last_sent_day ?? null, + notice: notice(ctx), notice_required: !sameNotice(state, ctx), + automatic_notice_required: automaticNoticeRequired(state, ctx), last_sent_day: state.last_sent_day ?? null, next_payload: state.consent === "disabled" ? null : ping(state, ctx), aggregate_preview: state.consent === "disabled" || !state.counters?.length ? null : { schema: AGGREGATE_SCHEMA, counters: state.counters }, goal_preview: state.consent === "disabled" ? null : await goalPreview(path + ".goals", state.generation).catch(() => null), @@ -107,7 +115,7 @@ export async function configure(path: string, ctx: Context, action: "enable" | " return; } if (action === "acknowledge" && JSON.stringify(expectedNotice) !== JSON.stringify(notice(ctx))) throw new Error("usage_notice_changed"); - if (action === "acknowledge" && state.consent === "disabled") return; + if (action === "acknowledge" && !automaticNoticeRequired(state, ctx)) return; if (action === "enable") state.consent = "enabled"; if (state.notice && !sameNotice(state, ctx)) { state.counters = []; diff --git a/loopx/usage_ping.py b/loopx/usage_ping.py index 0812368a3..5d37571bb 100644 --- a/loopx/usage_ping.py +++ b/loopx/usage_ping.py @@ -72,12 +72,17 @@ def begin(command: str) -> tuple[str, float] | None: if state.get("consent") == "disabled": return None if (state.get("notice") or {}).get("version") != 3: - # Unattended machines remain silent until the owner sees the notice - # or explicitly enables from CLI/settings. JSON stdout stays clean. - if not sys.stderr.isatty(): + # App services defer disclosure to the visible frontend. Ordinary + # script/Agent calls disclose on stderr too; JSON stdout stays clean. + if command in {"chat", "serve-status"} and not sys.stderr.isatty(): return None + try: + if os.path.samestat(os.fstat(sys.stderr.fileno()), os.stat(os.devnull)): + return None # Discarded output cannot carry a disclosure. + except (AttributeError, OSError, ValueError): + pass # In-memory host streams can still display the notice. projection = control("status", path) - if projection["blocked_by"] != "notice_required": + if not projection["automatic_notice_required"]: return None print(projection["disclosure"], file=sys.stderr, flush=True) control("acknowledge", path, notice=projection["notice"]) diff --git a/tests/control_plane_ts/usage_statistics.test.ts b/tests/control_plane_ts/usage_statistics.test.ts index b3a5316c8..ed7784b4f 100644 --- a/tests/control_plane_ts/usage_statistics.test.ts +++ b/tests/control_plane_ts/usage_statistics.test.ts @@ -28,10 +28,12 @@ test("fresh default requires notice; status is read-only; acknowledgment enables const { path, state } = await fixture(t); const ctx = context(); const initial = await inspect(path, ctx); assert.equal(initial.consent, "default"); assert.equal(initial.blocked_by, "notice_required"); + assert.equal(initial.automatic_notice_required, true); await assert.rejects(readFile(path), /ENOENT/); await configure(path, ctx, "acknowledge", initial.notice); assert.equal((await inspect(path, ctx)).sending, true); assert.equal((await state()).consent, "default"); + assert.equal((await inspect(path, ctx)).automatic_notice_required, false); assert.ok(validPing((await inspect(path, ctx)).next_payload)); }); @@ -68,6 +70,28 @@ test("consent-required policy cannot be satisfied by acknowledgment; recipient c assert.equal((await inspect(path, ctx)).blocked_by, "notice_required"); }); +test("automatic acknowledgment cannot override a choice, changed policy or recipient, or stale disclosure", async t => { + const { path } = await fixture(t); const ctx = context(); + const first = await inspect(path, ctx); + await assert.rejects(configure(path, ctx, "acknowledge", { ...first.notice, version: 0 }), /usage_notice_changed/); + await assert.rejects(readFile(path), /ENOENT/); + await configure(path, ctx, "acknowledge", first.notice); + const original = await readFile(path, "utf8"); + for (const env of [{ LOOPX_USAGE_PING_ENDPOINT: "https://another.example/v1/ping" }, + { LOOPX_USAGE_POLICY: "consent_required" }, { CI: "true" }, { DO_NOT_TRACK: "1" }, + { LOOPX_USAGE_PING: "off" }, { LOOPX_USAGE_POLICY: "unknown" }]) { + const changed = { ...ctx, env: { ...ctx.env, ...env } }; + const status = await inspect(path, changed); + assert.equal(status.automatic_notice_required, false); + await configure(path, changed, "acknowledge", status.notice); + assert.equal(await readFile(path, "utf8"), original); + } + await configure(path, ctx, "disable"); + const disabled = await readFile(path, "utf8"); + await configure(path, ctx, "acknowledge", first.notice); + assert.equal(await readFile(path, "utf8"), disabled); +}); + test("one heartbeat per UTC day; closed-day aggregation is separate and identifier-free", async t => { const { path, state } = await fixture(t); const ctx = context(); await configure(path, ctx, "enable"); const generation = (await state()).generation; diff --git a/tests/test_usage_ping.py b/tests/test_usage_ping.py index c971a9c7a..e21b6789e 100644 --- a/tests/test_usage_ping.py +++ b/tests/test_usage_ping.py @@ -82,19 +82,69 @@ def test_settings_commands_and_corrupt_state_repair(isolated, capsys): assert json.loads(capsys.readouterr().out)['next_payload'] is None -def test_unattended_fresh_install_never_creates_state(isolated, monkeypatch): +@pytest.mark.parametrize('interactive', [False, True]) +def test_first_cli_command_discloses_before_automatic_enable(isolated, monkeypatch, capsys, interactive): + monkeypatch.setattr(sys.stderr, 'isatty', lambda: interactive) + monkeypatch.setattr(usage_ping, '_detach', lambda *_: pytest.fail('first command must not send')) + assert usage_ping.begin('status') is None + assert 'random installation ID' in capsys.readouterr().err + state = json.loads(usage_ping.state_path().read_text()) + assert 'last_attempt_day' not in state and 'counters' not in state + assert usage_ping.control('status')['sending'] is True + + +@pytest.mark.parametrize('command', ['chat', 'serve-status']) +def test_background_app_service_defers_first_disclosure_to_frontend(isolated, monkeypatch, command): monkeypatch.setattr(sys.stderr, 'isatty', lambda: False) + assert usage_ping.begin(command) is None + assert not usage_ping.state_path().exists() + + +def test_discarded_or_broken_stderr_cannot_acknowledge(isolated, monkeypatch): + with open(os.devnull, 'w') as discarded: + monkeypatch.setattr(sys, 'stderr', discarded) + assert usage_ping.begin('status') is None + assert not usage_ping.state_path().exists() + class BrokenStream: + def isatty(self): + return False + def write(self, _text): + raise BrokenPipeError() + monkeypatch.setattr(sys, 'stderr', BrokenStream()) assert usage_ping.begin('status') is None assert not usage_ping.state_path().exists() -def test_first_interactive_command_discloses_but_does_not_measure(isolated, monkeypatch, capsys): - monkeypatch.setattr(sys.stderr, 'isatty', lambda: True) +@pytest.mark.parametrize('setting,value', [ + ('LOOPX_USAGE_PING', 'off'), ('DO_NOT_TRACK', '1'), ('CI', 'true'), + ('LOOPX_USAGE_POLICY', 'consent_required'), ('LOOPX_USAGE_POLICY', 'unknown'), +]) +def test_first_disclosure_respects_policy_overrides(isolated, monkeypatch, capsys, setting, value): + monkeypatch.setenv(setting, value) assert usage_ping.begin('status') is None - assert 'random installation ID' in capsys.readouterr().err - state = json.loads(usage_ping.state_path().read_text()) - assert 'last_attempt_day' not in state and 'counters' not in state - assert usage_ping.begin('status') is not None + assert not usage_ping.state_path().exists() + assert capsys.readouterr().err == '' + + +def test_real_agent_cli_keeps_json_clean_and_sends_only_after_disclosure(isolated, collector, monkeypatch): + endpoint, received, accepted, release = collector + monkeypatch.setenv('LOOPX_USAGE_PING_ENDPOINT', endpoint) + setup = 'import sys; from pathlib import Path; from loopx import usage_ping; usage_ping.DEFAULT_RUNTIME_ROOT=Path(sys.argv[1]); from loopx.cli_runtime import main; ' + command = [sys.executable, '-c', setup + 'raise SystemExit(main(["version", "--format", "json"]))', str(isolated)] + first = subprocess.run(command, capture_output=True, text=True, timeout=30) + assert first.returncode == 0 and isinstance(json.loads(first.stdout), dict) + assert 'random installation ID' in first.stderr + assert received == [] + assert 'counters' not in json.loads(usage_ping.state_path().read_text()) + second = subprocess.run(command, capture_output=True, text=True, timeout=30) + assert second.returncode == 0 and json.loads(second.stdout) == json.loads(first.stdout) + assert 'random installation ID' not in second.stderr + assert accepted.wait(4), 'subsequent Agent call should reach the isolated collector' + usage_ping.control('disable') + release.set() + third = subprocess.run(command, capture_output=True, text=True, timeout=30) + assert third.returncode == 0 and 'random installation ID' not in third.stderr + assert usage_ping.control('status')['consent'] == 'disabled' def test_real_cli_returns_while_http_response_is_held_and_disable_survives(isolated, collector, monkeypatch): @@ -165,7 +215,19 @@ def test_real_chat_settings_share_cli_choice_and_reject_cross_origin(isolated): try: connection.request('GET', path) response = connection.getresponse() - assert response.status == 200 and json.loads(response.read())['consent'] == 'default' + initial = json.loads(response.read()) + assert response.status == 200 and initial['automatic_notice_required'] + assert not usage_ping.state_path().exists() + connection.request('POST', path, json.dumps({'notice': initial['notice']}), {'Content-Type': 'application/json'}) + response = connection.getresponse() + acknowledged = json.loads(response.read()) + assert response.status == 200 and acknowledged['sending'] + assert acknowledged['consent'] == 'default' + assert 'last_attempt_day' not in json.loads(usage_ping.state_path().read_text()) + connection.request('POST', path, json.dumps({'notice': {**initial['notice'], 'version': 0}}), {'Content-Type': 'application/json'}) + response = connection.getresponse() + assert response.status == 503 + response.read() connection.request('POST', path, json.dumps({'enabled': True}), {'Content-Type': 'application/json'}) response = connection.getresponse() assert response.status == 200 and json.loads(response.read())['consent'] == 'enabled' @@ -180,6 +242,9 @@ def test_real_chat_settings_share_cli_choice_and_reject_cross_origin(isolated): assert response.status == 400 response.read() usage_ping.control('disable') + connection.request('POST', path, json.dumps({'notice': initial['notice']}), {'Content-Type': 'application/json'}) + response = connection.getresponse() + assert response.status == 200 and not json.loads(response.read())['sending'] connection.request('GET', path) response = connection.getresponse() assert json.loads(response.read())['consent'] == 'disabled' From 28d5b8839ceca7919795038a7eb2a5b15be84ac4 Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Sun, 27 Sep 2026 20:58:38 +0800 Subject: [PATCH 2/3] docs(usage): explain automatic activation across entry points Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- docs/reference/usage-ping.md | 26 ++++++++++++++++++++++---- docs/reference/usage-ping.zh-CN.md | 17 ++++++++++++++--- 2 files changed, 36 insertions(+), 7 deletions(-) diff --git a/docs/reference/usage-ping.md b/docs/reference/usage-ping.md index fad6d6091..fc8487666 100644 --- a/docs/reference/usage-ping.md +++ b/docs/reference/usage-ping.md @@ -73,11 +73,29 @@ Additional payload fields and invalid enum combinations are rejected. ## Disclosure and precedence -The first interactive CLI command prints the recipient, fields, purpose and +Interactive CLI, unattended scripts/agents and the App use the same +**first disclosure → automatic activation → subsequent measurement** policy. +The first ordinary CLI command prints the recipient, fields, purpose and both disable mechanisms to stderr, records the disclosure, and sends nothing. -Later commands may measure/send. A fresh unattended installation does not -silently opt itself in: use the visible App setting or explicit CLI enable. -JSON stdout is unaffected. Previously enabled v0 clients keep their random ID +This also applies to captured stderr in scripts and Agent tool calls; JSON +stdout is unaffected. Discarded stderr (the null device) or a failed write +cannot acknowledge a notice. Background `chat`/`serve-status` services defer +first disclosure to the App instead of treating a service log as the App UI. + +On first opening the live App, a visible notice explains the collection and +recipient, with **Turn off**, **Details** and **Dismiss** controls. After the +notice paints in a visible tab, the App records the same acknowledgment as CLI; +no enable click or visit to settings is needed. A hidden tab, read-only shared +view, unavailable settings service or status read alone does not acknowledge it. +Acknowledgment itself never sends a measurement. Later supported activity may +measure/send; individual App clicks remain outside the collection scope. +Settings → Capability Center keeps the shared switch and payload previews. + +This changes the previous unattended CLI and App defaults: unattended CLI no +longer requires explicit enable, and the App no longer requires a first-use +enable button under the default `opt_out` policy. Environment overrides and +`consent_required` retain their precedence. +Previously enabled v0 clients keep their random ID but must see the expanded-scope disclosure; previously disabled clients stay off. An explicit stored disable blocks all channels. The following environment diff --git a/docs/reference/usage-ping.zh-CN.md b/docs/reference/usage-ping.zh-CN.md index 34464cb57..c3c506278 100644 --- a/docs/reference/usage-ping.zh-CN.md +++ b/docs/reference/usage-ping.zh-CN.md @@ -65,9 +65,20 @@ ID 随机生成,不绑定账号、不从硬件派生,但能跨天关联, ## 告知、设置与升级 -首次交互式 CLI 命令向 stderr 显示接收方、字段、用途和关闭方法,然后记录告知; -这一轮不计数、不发送。后续命令才有资格采集。全新无人值守安装不会静默启用, -需要所有者在 App 设置或 CLI 中明确开启。JSON stdout 保持不变。 +交互式 CLI、后台脚本/Agent 和 App 统一采用 **首次告知 → 自动开启 → 后续采集**。 +首次普通 CLI 命令向 stderr 显示接收方、字段、用途和关闭方法,然后记录告知; +这一轮不计数、不发送。脚本和 Agent 工具调用捕获的 stderr 也适用,JSON stdout +保持不变。stderr 指向空设备或输出失败时不记录告知。后台 `chat`/`serve-status` +服务把首次告知交给 App,不以服务日志代替 App 界面。 + +首次打开可操作的 App 时,页面显示统计范围、接收方及“关闭统计”“了解详情” +和“收起”入口。告知在可见标签页中呈现后自动记录,不需要先进入设置或点击 +启用。隐藏标签页、只读分享页面、设置服务不可用或单纯查询状态均不记录告知。 +记录告知本身不发送统计,后续受支持活动才有资格采集;App 逐次点击仍不在采集范围内。 +设置 → 能力中心保留共用开关和待发送数据预览。 + +这是对旧默认行为的调整:默认 `opt_out` 策略下,后台 CLI 不再要求明确开启, +App 不再要求首次点击启用。环境变量覆盖和 `consent_required` 的优先级不变。 旧版明确关闭的选择继续生效;旧版已经开启的保留随机 ID,但扩大范围前重新告知。 以下任一设置都会压过“已开启”,同时关闭所有通道: From c474d7a6e9f710df1ed053c8cf589396fefcd81b Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Mon, 28 Sep 2026 02:29:47 +0800 Subject: [PATCH 3/3] fix(usage): require a real disclosure stream before acknowledging An absent sys.stderr raised AttributeError in the fileno probe, which was swallowed, so print(..., file=None) wrote the notice to stdout and a statistics state was acknowledged without any disclosure being shown. Resolve the stream once, return without printing or acknowledging when no stream exists, and keep supporting captured or in-memory streams. Add the unit and ordinary-CLI regressions: stdout stays pure JSON, no notice state is created, no measurement is authorized, and a real stream still discloses before the acknowledgement. Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- loopx/usage_ping.py | 15 +++++++++++---- tests/test_usage_ping.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/loopx/usage_ping.py b/loopx/usage_ping.py index 5d37571bb..40bb1d20e 100644 --- a/loopx/usage_ping.py +++ b/loopx/usage_ping.py @@ -74,17 +74,24 @@ def begin(command: str) -> tuple[str, float] | None: if (state.get("notice") or {}).get("version") != 3: # App services defer disclosure to the visible frontend. Ordinary # script/Agent calls disclose on stderr too; JSON stdout stays clean. - if command in {"chat", "serve-status"} and not sys.stderr.isatty(): + stream = sys.stderr + if stream is None: + # No disclosure channel exists, so nothing was shown: never fall + # back to stdout and never acknowledge an unseen notice. + return None + if command in {"chat", "serve-status"} and not stream.isatty(): return None try: - if os.path.samestat(os.fstat(sys.stderr.fileno()), os.stat(os.devnull)): + if os.path.samestat(os.fstat(stream.fileno()), os.stat(os.devnull)): return None # Discarded output cannot carry a disclosure. except (AttributeError, OSError, ValueError): - pass # In-memory host streams can still display the notice. + # In-memory host streams have no descriptor but can still display + # the notice, so they keep disclosing instead of being skipped. + pass projection = control("status", path) if not projection["automatic_notice_required"]: return None - print(projection["disclosure"], file=sys.stderr, flush=True) + print(projection["disclosure"], file=stream, flush=True) control("acknowledge", path, notice=projection["notice"]) return None # First invocation only discloses; no measurement/send. generation = state.get("generation") diff --git a/tests/test_usage_ping.py b/tests/test_usage_ping.py index e21b6789e..2b92b5275 100644 --- a/tests/test_usage_ping.py +++ b/tests/test_usage_ping.py @@ -2,6 +2,7 @@ from __future__ import annotations import json +import io import os import select import socket @@ -115,6 +116,34 @@ def write(self, _text): assert not usage_ping.state_path().exists() +def test_absent_stderr_cannot_acknowledge_or_reach_stdout(isolated, monkeypatch, capsys): + monkeypatch.setattr(usage_ping, '_detach', lambda *_: pytest.fail('an undisclosed command must not measure')) + monkeypatch.setattr(sys, 'stderr', None) + assert usage_ping.begin('status') is None + captured = capsys.readouterr() + assert captured.out == '' and captured.err == '' + assert not usage_ping.state_path().exists() + + +def test_absent_stderr_keeps_real_cli_json_pure_until_a_stream_discloses(isolated, monkeypatch, capsys): + monkeypatch.setattr(usage_ping, '_detach', lambda *_: pytest.fail('a command without disclosure must not measure')) + monkeypatch.setattr(sys, 'stderr', None) + assert main(['version', '--format', 'json']) == 0 + captured = capsys.readouterr() + assert captured.out.lstrip().startswith('{') + assert json.loads(captured.out)['ok'] is True + assert captured.err == '' + assert not usage_ping.state_path().exists() + assert usage_ping.control('status')['sending'] is False + # Only the missing stream is rejected: an in-memory host stream still discloses and ACKs. + stderr = io.StringIO() + monkeypatch.setattr(sys, 'stderr', stderr) + assert main(['version', '--format', 'json']) == 0 + assert 'random installation ID' in stderr.getvalue() + assert json.loads(capsys.readouterr().out)['ok'] is True + assert json.loads(usage_ping.state_path().read_text())['notice']['version'] == 3 + + @pytest.mark.parametrize('setting,value', [ ('LOOPX_USAGE_PING', 'off'), ('DO_NOT_TRACK', '1'), ('CI', 'true'), ('LOOPX_USAGE_POLICY', 'consent_required'), ('LOOPX_USAGE_POLICY', 'unknown'),