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
3 changes: 1 addition & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
26 changes: 13 additions & 13 deletions scripts/npm-release-workflow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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");
Expand All @@ -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");
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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 },
});
});

Expand Down
20 changes: 15 additions & 5 deletions scripts/package-budget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
9 changes: 9 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -294,5 +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, 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);
}
102 changes: 102 additions & 0 deletions src/telemetry.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, string | undefined>>,
): boolean {
return environment.HRANESS_TELEMETRY === "off"
|| environment.GHOSTGET_TELEMETRY === "off";
}

function telemetryConfigDirectory(
environment: Readonly<Record<string, string | undefined>>,
): 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<Record<string, string | undefined>>,
): Promise<string | null> {
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<Record<string, string | undefined>> = process.env,
): Promise<void> {
if (ghostgetTelemetryDisabled(environment)) return;
try {
const install = await installToken(environment);
sendTelemetryPost(JSON.stringify({
cli: "ghostget",
...(install === null ? {} : { install }),
v: 1,
version,
}));
} catch {}
}
6 changes: 6 additions & 0 deletions src/usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
`;
Loading