From 2b153308fb4c2711efb3cd9baa18c5c1ad44b0e7 Mon Sep 17 00:00:00 2001 From: 0thernet Date: Sat, 26 Sep 2026 13:06:37 -0400 Subject: [PATCH 1/4] cli: aggregate run ping to suite telemetry One bounded POST per invocation reports {cli, version} to the Accounts telemetry ingress on an unref socket so telemetry never holds the process, blocks an exit, or changes a result. A locally minted install token joins daily-active counting; HRANESS_TELEMETRY or GHOSTGET_TELEMETRY=off opts out. Documented in --help. --- src/cli.ts | 3 ++ src/telemetry.ts | 102 +++++++++++++++++++++++++++++++++++++++++++++++ src/usage.ts | 6 +++ 3 files changed, 111 insertions(+) create mode 100644 src/telemetry.ts diff --git a/src/cli.ts b/src/cli.ts index 6ff0da55..7a8c5f0f 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -2,6 +2,7 @@ import { ghostgetUsage } from "./usage"; import { terminalIntro } from "./cli-intro"; +import { reportGhostgetCliRun } from "./telemetry"; import { GHOSTGET_VERSION } from "./version"; import type { GhostgetCatalogCommand, @@ -294,5 +295,7 @@ if (import.meta.main) { // Set once for this executable and its children; programmatic calls never // mutate inherited state or compete to restore a process-global variable. process.env.GHOSTGET_CLI_DEPTH = String(depth === null ? 8 : Math.min(depth + 1, 8)); + // Aggregate run telemetry reports the product name and version only. + if (depth === 0) void reportGhostgetCliRun(GHOSTGET_VERSION, process.env); await runGhostgetCliProcess(undefined, undefined, undefined, undefined, undefined, undefined, depth === 0); } diff --git a/src/telemetry.ts b/src/telemetry.ts new file mode 100644 index 00000000..250de359 --- /dev/null +++ b/src/telemetry.ts @@ -0,0 +1,102 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { randomUUID } from "node:crypto"; +import { connect as connectTls } from "node:tls"; + +/** + * Aggregate run telemetry. One bounded POST per CLI invocation reports the + * product name and version only — never arguments, paths, account data, or + * output. A locally minted install token joins daily-active counts; it is + * not synced, not an account identity, and rotates only when the user + * deletes it. HRANESS_TELEMETRY=off or GHOSTGET_TELEMETRY=off disables it + * entirely. + * + * The request rides an unref'd TLS socket: telemetry never holds the process + * open, never delays an exit, and never changes a command's output or exit + * status. A drop is acceptable — the signal is aggregate. + */ +const TELEMETRY_HOST = "account.hraness.com"; +const TELEMETRY_PATH = "/api/telemetry/cli"; +const TELEMETRY_TIMEOUT_MS = 1_500; +const INSTALL_TOKEN_PATTERN = /^[a-f0-9]{32}$/u; + +export function ghostgetTelemetryDisabled( + environment: Readonly>, +): boolean { + return environment.HRANESS_TELEMETRY === "off" + || environment.GHOSTGET_TELEMETRY === "off"; +} + +function telemetryConfigDirectory( + environment: Readonly>, +): string { + const xdg = environment.XDG_CONFIG_HOME; + if ( + typeof xdg === "string" && xdg.length > 0 && xdg.length < 512 + && xdg.startsWith("/") + ) { + return join(xdg, "hraness"); + } + return join(homedir(), ".config", "hraness"); +} + +async function installToken( + environment: Readonly>, +): Promise { + const directory = telemetryConfigDirectory(environment); + const path = join(directory, "telemetry-install"); + try { + const existing = (await readFile(path, "utf8")).trim(); + if (INSTALL_TOKEN_PATTERN.test(existing)) return existing; + } catch {} + const token = randomUUID().replaceAll("-", ""); + try { + await mkdir(directory, { recursive: true }); + await writeFile(path, `${token}\n`, { mode: 0o600 }); + return token; + } catch { + return null; + } +} + +function sendTelemetryPost(body: string): void { + try { + const socket = connectTls({ + host: TELEMETRY_HOST, + port: 443, + servername: TELEMETRY_HOST, + }); + socket.unref(); + socket.setTimeout(TELEMETRY_TIMEOUT_MS, () => socket.destroy()); + socket.on("secureConnect", () => { + socket.write( + `POST ${TELEMETRY_PATH} HTTP/1.1\r\n` + + `host: ${TELEMETRY_HOST}\r\n` + + "content-type: application/json\r\n" + + `content-length: ${Buffer.byteLength(body)}\r\n` + + "connection: close\r\n\r\n" + + body, + ); + }); + socket.on("data", () => socket.destroy()); + socket.on("error", () => socket.destroy()); + socket.on("close", () => socket.destroy()); + } catch {} +} + +export async function reportGhostgetCliRun( + version: string, + environment: Readonly> = process.env, +): Promise { + if (ghostgetTelemetryDisabled(environment)) return; + try { + const install = await installToken(environment); + sendTelemetryPost(JSON.stringify({ + cli: "ghostget", + ...(install === null ? {} : { install }), + v: 1, + version, + })); + } catch {} +} diff --git a/src/usage.ts b/src/usage.ts index 3e5470a9..30efd638 100644 --- a/src/usage.ts +++ b/src/usage.ts @@ -236,4 +236,10 @@ Optional updates and support: HRANESS_SUPPORT_AUDIENCE=agent|human|off selects presentation (default: agent). Human presentation requires interactive stderr. Off suppresses due offers too. HRANESS_SUPPORT=off disables ambient support; support dismiss opts out locally. + +Telemetry: + One aggregate run ping reports the product name and version to Hraness + Accounts; no arguments, paths, account data, or output ever leave the + machine. A locally minted install token joins daily-active counts only. + HRANESS_TELEMETRY=off or GHOSTGET_TELEMETRY=off disables it entirely. `; From 4e0c67e29d8b7126fcebe182296d0567b5366fe2 Mon Sep 17 00:00:00 2001 From: 0thernet Date: Sat, 26 Sep 2026 15:48:20 -0400 Subject: [PATCH 2/4] pack: admit src/telemetry.ts; re-measure the 599-file budget --- package.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/package.json b/package.json index ea4b77b0..5a91a0d0 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,6 @@ "name": "@hraness/ghostget", "version": "0.18.38", "description": "Ghostget gives your AI agent named web actions: read a page, archive one media item, or use a connected account, without credentials or a browser to steer.", - "license": "MIT", "type": "module", "sideEffects": [ @@ -546,6 +545,7 @@ "src/state-helper.bunfig.toml", "src/state-helper.ts", "src/storage.ts", + "src/telemetry.ts", "src/transport-policy.ts", "src/usage.ts", "src/version.ts", @@ -687,7 +687,6 @@ "@hraness/site-footer": "github:hraness/site-footer#v0.19.2", "@hraness/ui": "github:hraness/ui#v0.5.18", "@hraness/web-discovery": "github:hraness/web-discovery#v0.8.0", - "@informalsystems/quint": "0.32.0", "@resvg/resvg-js": "2.6.2", "@steipete/sweet-cookie": "0.4.3", From 16019490ea68908f16c8d489b0725aee14a10bc0 Mon Sep 17 00:00:00 2001 From: 0thernet Date: Sat, 26 Sep 2026 16:12:56 -0400 Subject: [PATCH 3/4] telemetry: lazy-import and skip static help/version invocations --- src/cli.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 7a8c5f0f..b0c541d7 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -2,7 +2,6 @@ import { ghostgetUsage } from "./usage"; import { terminalIntro } from "./cli-intro"; -import { reportGhostgetCliRun } from "./telemetry"; import { GHOSTGET_VERSION } from "./version"; import type { GhostgetCatalogCommand, @@ -295,7 +294,14 @@ if (import.meta.main) { // Set once for this executable and its children; programmatic calls never // mutate inherited state or compete to restore a process-global variable. process.env.GHOSTGET_CLI_DEPTH = String(depth === null ? 8 : Math.min(depth + 1, 8)); - // Aggregate run telemetry reports the product name and version only. - if (depth === 0) void reportGhostgetCliRun(GHOSTGET_VERSION, process.env); + // Aggregate run telemetry reports the product name and version only, and + // stays lazy: static help/version output never mints state or reaches out. + const args = process.argv.slice(2); + const staticOnly = args.length === 0 || args.every((arg) => arg === "--help" || arg === "-h" || arg === "--version" || arg === "-V"); + if (depth === 0 && !staticOnly) { + void import("./telemetry") + .then((telemetry) => telemetry.reportGhostgetCliRun(GHOSTGET_VERSION, process.env)) + .catch(() => {}); + } await runGhostgetCliProcess(undefined, undefined, undefined, undefined, undefined, undefined, depth === 0); } From 2187e7015516d8e879f9e816f30ef22b908d0a27 Mon Sep 17 00:00:00 2001 From: 0thernet Date: Sat, 26 Sep 2026 16:43:53 -0400 Subject: [PATCH 4/4] budget: re-measure the 599-file pack on current main --- scripts/npm-release-workflow.test.ts | 26 +++++++++++++------------- scripts/package-budget.ts | 20 +++++++++++++++----- 2 files changed, 28 insertions(+), 18 deletions(-) diff --git a/scripts/npm-release-workflow.test.ts b/scripts/npm-release-workflow.test.ts index 4b2d91b1..5ccb5877 100644 --- a/scripts/npm-release-workflow.test.ts +++ b/scripts/npm-release-workflow.test.ts @@ -1220,7 +1220,7 @@ describe("npm publication contract", () => { (MAX_UNPACKED_BYTES + MAX_PACKED_ENTRIES * 1_023 + 1_024) / 512, ) * 512, ); - expect(MAX_PACKAGE_TAR_BYTES).toBe(24_175_104); + expect(MAX_PACKAGE_TAR_BYTES).toBe(24_180_224); expect(MAX_PACKAGE_TAR_BYTES % 512).toBe(0); expect(artifact).toContain("maxOutputLength: MAX_PACKAGE_TAR_BYTES"); expect(artifact).not.toContain("const maximumTarBytes"); @@ -1421,13 +1421,13 @@ describe("npm publication contract", () => { expect(budget).toContain("47c0114ba631b314fa5bea489eb79e29a77bb7e06321c4088725b6b238dfe81a"); expect(Object.isFrozen(repairPackageMeasurement)).toBeTrue(); expect(repairPackageMeasurement).toMatchObject({ - archiveSha256: "a6f33eb557bfe7b5521c3bb884075270cadb0b7935f41d2015952343ea3fcd32", - packedBytes: 12_039_276, unpackedBytes: 23_561_480, entryCount: 598, + archiveSha256: "2ecb6cc258f61709554ca37f66fb58a2cd00ccb1157850fb6d200962127dbe24", + packedBytes: 12_028_110, unpackedBytes: 23_565_669, entryCount: 599, packedPlatformProjection: 12_387, packedPortabilityAllowance: 4_096, payloadPlatformProjection: 353, payloadAllowance: 65, }); - expect(MAX_PACKED_BYTES).toBe(12_055_759); - expect(MAX_PACKED_BYTES).toBe(12_039_276 + 12_387 + 4_096); + expect(MAX_PACKED_BYTES).toBe(12_044_593); + expect(MAX_PACKED_BYTES).toBe(12_028_110 + 12_387 + 4_096); expect(budget).toContain("12,004,806 + 12,387 + 4,096 ="); expect(budget).toContain("12,003,367 + 12,387 + 4,096 ="); expect(budget).toContain("23,462,195 + 353 + 65 = 23,462,613"); @@ -1518,8 +1518,8 @@ describe("npm publication contract", () => { expect(budget).toContain("11,696,091 + 4,096 = 11,700,187"); expect(budget).toContain("35449445752 attempt 1, package job 105913938839"); expect(budget).toContain("exactly 596 files"); - expect(MAX_PACKED_ENTRIES).toBe(598); - expect(MAX_PACKED_FILES).toBe(598); + expect(MAX_PACKED_ENTRIES).toBe(599); + expect(MAX_PACKED_FILES).toBe(599); expect(budget).toContain("Ghostget 0.18.6 same-boot setup-cleanup candidate over main edbe567"); expect(budget).toContain("11,656,173"); expect(budget).toContain("22,513,450 payload bytes across exactly 557 files"); @@ -1542,7 +1542,7 @@ describe("npm publication contract", () => { expect(budget).toContain("23,029,751 + 353 + 65 = 23,030,169"); expect(budget).toContain("23,193,728 + 65 = 23,193,793"); expect(budget).toContain("47684b3e2eb5cf3ed07fbb520aade8c7251d993f75262fbf1af627d9081a1a5f"); - expect(MAX_UNPACKED_BYTES).toBe(23_561_898); + expect(MAX_UNPACKED_BYTES).toBe(23_566_087); expect(budget).toContain("23,037,873 + 65 = 23,037,938"); expect(budget).toContain("f9f3ab38a682690ceaa2699a7309997512030f0fa500a9dc29dcd108123dc41f"); expect(budget).toContain("23,038,557 + 65 = 23,038,622"); @@ -1575,7 +1575,7 @@ describe("npm publication contract", () => { expect(budget).toContain("01875f12ab73a49d6c7d6bf520dc3d318db816addee2fa7981889f35c958cf7c"); expect(budget).toContain("b12909f08f7c19460ced56e30619f4860a1183f4b0106170c07837dae577a937"); expect(budget).toContain("0b212ac291218528dcf979370110a36f10850e046ca90a536057d9a44e807d1d"); - expect(MAX_UNPACKED_BYTES).toBe(23_561_480 + 353 + 65); + expect(MAX_UNPACKED_BYTES).toBe(23_565_669 + 353 + 65); expect(budget).toContain("22,794,052 + 65 = 22,794,117"); expect(budget).toContain("c482efe748f880e3717727d6d39fd92a68953e6eea766642b329ba47ae772d80"); expect(budget).toContain("22,759,423 + 65 = 22,759,488"); @@ -1609,10 +1609,10 @@ describe("npm publication contract", () => { expect(Object.isFrozen(range)).toBe(true); } expect(packageArtifactBudget).toEqual({ - entryCount: { min: 598, max: 598 }, - fileCount: { min: 598, max: 598 }, - packedBytes: { min: 1_600_000, max: 12_055_759 }, - unpackedBytes: { min: 9_000_000, max: 23_561_898 }, + entryCount: { min: 599, max: 599 }, + fileCount: { min: 599, max: 599 }, + packedBytes: { min: 1_600_000, max: 12_044_593 }, + unpackedBytes: { min: 9_000_000, max: 23_566_087 }, }); }); diff --git a/scripts/package-budget.ts b/scripts/package-budget.ts index 6b28760e..842b9cc1 100644 --- a/scripts/package-budget.ts +++ b/scripts/package-budget.ts @@ -1787,15 +1787,25 @@ // a6f33eb557bfe7b5521c3bb884075270cadb0b7935f41d2015952343ea3fcd32. // Carry the same projections and allowances: 12,039,276 + 12,387 + 4,096 = // 12,055,759 packed; 23,561,480 + 353 + 65 = 23,561,898 unpacked. +// Aggregate CLI run telemetry adds src/telemetry.ts and its bounded opt-out +// install-token wiring in the CLI entrypoint: one new packed source file and +// its rebuilt chunks over the merged base, 599 files total. A clean +// `npm pack --ignore-scripts` with npm 11.19.0 on darwin arm64 measured +// 12,028,110 packed bytes and 23,565,669 unpacked bytes; archive SHA-256 +// 2ecb6cc258f61709554ca37f66fb58a2cd00ccb1157850fb6d200962127dbe24. Carry +// the same projections and allowances: 12,028,110 + 12,387 + 4,096 = +// 12,044,593 packed; 23,565,669 + 353 + 65 = 23,566,087 unpacked. Required +// Linux CI and canonical Release must independently measure and admit their +// exact archives. export const repairPackageMeasurement = Object.freeze({ - scope: "Run-intent dispatch arbitration and full scoped-read admission", + scope: "Aggregate CLI run telemetry adds src/telemetry.ts and its CLI entrypoint wiring", command: "npm pack --ignore-scripts", npmVersion: "11.19.0", platform: "darwin-arm64", - archiveSha256: "a6f33eb557bfe7b5521c3bb884075270cadb0b7935f41d2015952343ea3fcd32", - packedBytes: 12_039_276, - unpackedBytes: 23_561_480, - entryCount: 598, + archiveSha256: "2ecb6cc258f61709554ca37f66fb58a2cd00ccb1157850fb6d200962127dbe24", + packedBytes: 12_028_110, + unpackedBytes: 23_565_669, + entryCount: 599, packedPlatformProjection: 12_387, packedPortabilityAllowance: 4_096, payloadPlatformProjection: 353,