Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,10 @@ export function UsageStatisticsSettings() {
<label><input type="checkbox" checked={state.consent === "enabled" || (state.consent === "default" && !state.notice_required)} disabled={busy}
onChange={event => void update(event.target.checked)} /> {zh ? "允许基础使用统计(整台机器)" : "Allow basic usage statistics (this machine)"}</label>
<p role="status">{state.sending ? (zh ? "已允许发送" : "Sending allowed")
: state.notice_required && state.consent !== "disabled" ? (zh ? "等待首次告知确认;尚未发送" : "Awaiting first-use acknowledgment; not sending")
: state.blocked_by === "temporary_path" ? (zh ? "临时环境不发送统计" : "Not sending in a temporary environment")
: state.blocked_by === "notice_required" ? (zh ? "等待首次告知确认;尚未发送" : "Awaiting first-use acknowledgment; not sending")
: (zh ? `当前不发送:${({disabled:"已关闭",CI:"CI 环境",DO_NOT_TRACK:"请勿追踪开关",LOOPX_USAGE_PING:"环境变量已关闭",consent_required:"需要明确同意",invalid_policy:"策略配置无效",invalid_endpoint:"接收地址无效",notice_required:"需要重新告知"} as Record<string,string>)[state.blocked_by ?? ""] ?? "请检查配置"}` : `Not sending: ${state.blocked_by}`)}</p>
{state.notice_required && state.consent !== "disabled" ? <button className="personal-secondary-action" type="button" disabled={busy} onClick={() => void update(true)}>{zh ? "已了解,启用统计" : "Understood, enable statistics"}</button> : null}
{["notice_required", "consent_required"].includes(state.blocked_by ?? "") ? <button className="personal-secondary-action" type="button" disabled={busy} onClick={() => void update(true)}>{zh ? "已了解,启用统计" : "Understood, enable statistics"}</button> : null}
<p>{zh ? "接收地址:" : "Recipient: "}{state.endpoint ?? (zh ? "未配置" : "Not configured")}</p>
<details><summary>{zh ? "查看待发送数据与本地发送摘要" : "Preview outgoing data and local delivery summaries"}</summary><pre>{JSON.stringify({ heartbeat: state.next_payload, aggregate: state.aggregate_preview, diagnostics: state.diagnostic_preview, goals: state.goal_preview, installation: state.installation_preview, identity_scope: state.identity_scope, dropped: state.diagnostic_dropped, delivery_history: state.delivery_history }, null, 2)}</pre></details>
</> : null}
Expand Down
13 changes: 13 additions & 0 deletions docs/development/good-smokes.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,19 @@ provider behavior.
不要把 stdout/stderr tail 提交进 fixture。Fake provider 可以验证序列化、脱敏和
fail-closed,但不能证明真实 provider 行为合格。

Standalone smoke subprocesses must force `LOOPX_USAGE_PING=0` after merging
caller overrides, including environments rebuilt from an allowlist. Temporary
HOME/state paths are also fenced by the [shared telemetry owner](../reference/usage-ping.md#disclosure-and-precedence).
For custom scratch roots, retain the declared temp root or persist an explicit
disabled setting before removing environment markers. Telemetry-specific tests
opt in only to numeric loopback collectors and qualify every channel with a
zero-request negative case and a sending positive control.

独立 smoke 在合并调用方环境后必须强制关闭遥测,重建最小环境也不例外。
共用 owner 额外防护临时 HOME/状态路径;自定义 scratch 应保留声明的临时根,
或先持久关闭再移除环境标记。遥测专项测试只向数字回环地址显式开启,
用全通道零请求反例和实际发送正例共同验收。

Follow [Public/private boundaries](../public-private-boundary.md) whenever a
smoke touches benchmark, host, agent, or external-system data.

Expand Down
27 changes: 25 additions & 2 deletions docs/reference/usage-ping.md
Original file line number Diff line number Diff line change
Expand Up @@ -236,8 +236,31 @@ Pytest and the canary smoke runner also disable collection for local validation.
Native Codex benchmark profiles force the same opt-out during installation,
runtime and Agent shell execution, even when a minimal environment removes `CI`
or the parent requests collection. Release qualification uses the same boundary.
These synthetic runs must not count as reporting installations. Telemetry tests
may explicitly enable collection only against a disposable local collector.
These synthetic runs must not count as reporting installations.

The shared sender also suppresses all channels when the machine home, telemetry
state, host home/configuration, or loaded runtime lives under a recognized
temporary root. This covers OS temporary directories (including macOS aliases
and Windows Temp), absolute `TMPDIR`/`TEMP`/`TMP` roots, and symlinks into them,
including a state file that does not exist yet. Status reports `temporary_path`;
automatic disclosure creates no installation ID. Copied/enabled state cannot
override this boundary or consume buffered counts. A temporary **working
directory alone** is not a temporary installation: detached senders themselves
work in the OS temp directory. Path checks remain local and upload no paths.

For a custom scratch location outside those roots, declare its temporary root
when launching the process or persist `usage-ping disable` in that fixture's
machine state before rebuilding its environment. Directory names such as
`test` or `scratch` are not evidence of ephemerality. Standalone smoke helpers
must still force `LOOPX_USAGE_PING=0` after caller environment overrides; path
eligibility is defense in depth when an opt-out flag is lost, not a replacement
for test isolation. No historical installation IDs are relabeled or deleted.

Telemetry transport qualifications use both `LOOPX_USAGE_PING=1` and an explicit
numeric loopback collector (`127.0.0.1` or `::1`) to exercise sends from disposable
state. This exception never permits remote collection and cannot override CI,
Do Not Track, stored disable or consent-required policy. Ordinary persistent
installations retain notice-before-default-on behavior.

`LOOPX_USAGE_POLICY=consent_required` requires explicit enable; merely displaying
the notice is insufficient. Default policy is `opt_out`; unknown policies fail
Expand Down
20 changes: 19 additions & 1 deletion docs/reference/usage-ping.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,25 @@ App 不再要求首次点击启用。环境变量覆盖和 `consent_required`
canary smoke 执行器也在本地验证时关闭采集。Native Codex 评测 profile 的安装、
运行和 Agent 工具 shell 都强制关闭,即使最小环境丢掉了 `CI` 或父进程要求开启,
也不会恢复采集。发布资格验证同样关闭。这些合成运行不能计作真实使用安装。
遥测专项测试只能针对可丢弃的本地收集器显式开启。

共用发送 owner 还会检查机器 HOME、遥测状态、Host HOME/配置目录与加载的运行时
是否位于已知临时根下,统一阻止所有通道。覆盖系统临时目录(含 macOS 别名、
Windows Temp)、绝对路径的 `TMPDIR`/`TEMP`/`TMP`,以及指向这些目录的符号链接;
状态文件尚不存在也生效。状态返回 `temporary_path`;自动告知不创建安装 ID,
复制来的“已开启”状态也不能绕过,待发送计数不被消耗。仅工作目录位于临时区
不算临时安装:后台发送进程本身就在系统临时目录工作。路径检查只在本机进行,
不上传路径。

自定义 scratch 若不在上述根下,启动时应声明临时根,或先在该 fixture 的机器
状态中执行 `usage-ping disable`,再重建环境。不能凭 `test`、`scratch` 等目录名
推断临时性。独立 smoke helper 仍须在合并调用方环境后强制设置
`LOOPX_USAGE_PING=0`;路径防护用于兜住关闭标记丢失,不替代测试隔离。
本次不重新标记或删除历史安装 ID。

遥测传输专项测试必须同时设置 `LOOPX_USAGE_PING=1` 和明确的数字回环收集地址
(`127.0.0.1` 或 `::1`),才能从可丢弃状态发送。这一例外不能向远端发送,
也不能绕过 CI、请勿追踪、持久关闭或需要明确同意的策略。普通持久安装仍沿用
“先告知、后默认开启”的规则。

`LOOPX_USAGE_POLICY=consent_required` 要求明确开启,单纯显示告知不够。
默认策略为 `opt_out`,未知值拒绝发送。发行方必须按实际适用要求选择策略;
Expand Down
1 change: 1 addition & 0 deletions examples/project/connect-route-collision-guard-smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ def run_cli(
process_env = os.environ.copy()
if env:
process_env.update(env)
process_env["LOOPX_USAGE_PING"] = "0"
return subprocess.run(
command,
cwd=REPO_ROOT,
Expand Down
24 changes: 15 additions & 9 deletions loopx/control_plane/runtime/usage_statistics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import type { CycleObservation } from "./usage_statistics_cycles.ts";
import { recordInstallation, installationPreview } from "./usage_statistics_installation.ts";
import { validInstallationUsage } from "./usage_statistics_installation_contract.ts";
import type { InstallationUsage, ProfileFeature } from "./usage_statistics_installation_contract.ts";
import { temporaryUsageEnvironment } from "./usage_statistics_environment.ts";

export const STATE_SCHEMA = "loopx_usage_ping_state_v1";
export const DEFAULT_ENDPOINT = "https://loopx-usage-collector.huangrt01.workers.dev/v1/ping";
Expand Down Expand Up @@ -50,14 +51,14 @@ 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 {
function automaticNoticeRequired(state: State, ctx: Context, path: string): 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"
return blockedBy(state, ctx, path) === "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 {
export function blockedBy(state: State, ctx: Context, path: string): string | null {
const env = ctx.env;
if (["0", "false", "no", "off"].includes((env.LOOPX_USAGE_PING ?? "").trim().toLowerCase())) return "LOOPX_USAGE_PING";
if (!["", "0"].includes((env.DO_NOT_TRACK ?? "").trim())) return "DO_NOT_TRACK";
Expand All @@ -67,6 +68,11 @@ export function blockedBy(state: State, ctx: Context): string | null {
if (!["opt_out", "consent_required"].includes(policy)) return "invalid_policy";
if (policy === "consent_required" && state.consent !== "enabled") return "consent_required";
if (!endpoint(env)) return "invalid_endpoint";
// An explicit local transport qualification may use disposable state. This
// cannot authorize a remote recipient or override CI / opt-out / consent.
const localQualification = env.LOOPX_USAGE_PING === "1"
&& ["127.0.0.1", "[::1]"].includes(new URL(endpoint(env)).hostname);
if (!localQualification && temporaryUsageEnvironment(path, env)) return "temporary_path";
if (!sameNotice(state, ctx)) return "notice_required";
return null;
}
Expand Down Expand Up @@ -116,11 +122,11 @@ function ping(state: State, ctx: Context): Ping | null {
}
export async function inspect(path: string, ctx: Context) {
const state = await load(path);
const blocked = blockedBy(state, ctx);
const blocked = blockedBy(state, ctx, path);
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),
automatic_notice_required: automaticNoticeRequired(state, ctx), last_sent_day: state.last_sent_day ?? null,
automatic_notice_required: automaticNoticeRequired(state, ctx, path), 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 },
diagnostic_preview: state.consent === "disabled" || !state.diagnostics?.length ? null : { schema: DIAGNOSTIC_SCHEMA, counters: state.diagnostics },
Expand Down Expand Up @@ -168,7 +174,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" && !automaticNoticeRequired(state, ctx)) return;
if (action === "acknowledge" && !automaticNoticeRequired(state, ctx, path)) return;
if (action === "enable") state.consent = "enabled";
if (state.notice && !sameNotice(state, ctx)) {
state.counters = [];
Expand Down Expand Up @@ -208,7 +214,7 @@ export async function observe(path: string, ctx: Context, generation: string, co
const now = (ctx.now ?? new Date()).getTime();
const allowed = await withFileMutationLock(path, async () => {
const state = await load(path);
const blocked = blockedBy(state, ctx);
const blocked = blockedBy(state, ctx, path);
if (blocked || !generation || generation !== state.generation) return false;
if (state.day && state.day > today) return false;
if (state.aggregate_last_attempt_ms !== undefined && now < state.aggregate_last_attempt_ms) return false;
Expand Down Expand Up @@ -291,7 +297,7 @@ export async function observe(path: string, ctx: Context, generation: string, co
if (url.endsWith("/goals") || url.endsWith("/installation")) {
await withFileMutationLock(path, async () => {
const current = await load(path);
if (!blockedBy(current, ctx) && current.generation === generation) request = send(url, payload).catch(() => 0);
if (!blockedBy(current, ctx, path) && current.generation === generation) request = send(url, payload).catch(() => 0);
}, 250);
}
if (!request) break;
Expand All @@ -300,7 +306,7 @@ export async function observe(path: string, ctx: Context, generation: string, co
if (url.endsWith("/ping") && accepted) sent = true;
await withFileMutationLock(path, async () => {
const latest = await load(path);
if (latest.generation !== generation || blockedBy(latest, ctx)) return;
if (latest.generation !== generation || blockedBy(latest, ctx, path)) return;
if (url.endsWith("/ping") && accepted) latest.last_sent_day = today;
const delivery: Delivery = { day: today,
channel: url.endsWith("/ping") ? "heartbeat" : url.endsWith("/aggregate") ? "cli" : url.endsWith("/installation") ? "installation" : "goal",
Expand Down
49 changes: 49 additions & 0 deletions loopx/control_plane/runtime/usage_statistics_environment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/** Local-only path eligibility. Paths never enter an outgoing payload. */
import { realpathSync } from "node:fs";
import { homedir, platform, tmpdir } from "node:os";
import { dirname, isAbsolute, join, parse, relative, resolve, sep } from "node:path";
import { fileURLToPath } from "node:url";

type Env = Record<string, string | undefined>;

function physicalPath(value: string): string {
let parent = resolve(value);
const missing: string[] = [];
// A first-use state file need not exist yet. Resolve its nearest existing
// ancestor, including symlinked homes and macOS /var -> /private/var.
while (true) {
try { return join(realpathSync(parent), ...missing); }
catch (error) {
if (!["ENOENT", "ENOTDIR"].includes(String((error as NodeJS.ErrnoException).code))) throw error;
const next = dirname(parent);
if (next === parent) return resolve(value);
missing.unshift(parent.slice(next.length).replace(/^[/\\]+/, ""));
parent = next;
}
}
}

export function temporaryUsageEnvironment(path: string, env: Env): boolean {
const windows = platform() === "win32";
const home = env.HOME || env.USERPROFILE || homedir();
const roots = [tmpdir(), env.TMPDIR, env.TEMP, env.TMP,
...(windows ? [join(env.SystemRoot || env.WINDIR || "C:\\Windows", "Temp"),
join(env.LOCALAPPDATA || join(home, "AppData", "Local"), "Temp")]
: ["/tmp", "/var/tmp", "/private/tmp", "/private/var/tmp",
...(platform() === "darwin" ? ["/var/folders", "/private/var/folders"] : [])])];
const candidates = [path, home, env.USERPROFILE, env.CODEX_HOME,
env.XDG_STATE_HOME, env.XDG_DATA_HOME, env.XDG_CONFIG_HOME,
fileURLToPath(import.meta.url)];
// Unreadable/looping routes throw into the existing unavailable-settings
// boundary; do not misclassify an unknown route as a temporary one.
const normalize = (value: string) => windows ? value.toLowerCase() : value;
const directories = [...new Set(roots.filter((root): root is string => !!root && isAbsolute(root))
.map(physicalPath).filter(root => root !== parse(root).root).map(normalize))];
return candidates.filter((value): value is string => !!value).some(value => {
const candidate = normalize(physicalPath(value));
return directories.some(root => {
const child = relative(root, candidate);
return child === "" || (!isAbsolute(child) && child !== ".." && !child.startsWith(".." + sep));
});
});
}
Loading
Loading