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
80 changes: 10 additions & 70 deletions apps/server/src/telemetry/Identify.ts
Original file line number Diff line number Diff line change
@@ -1,59 +1,6 @@
import { Effect, FileSystem, Path, Random, Schema } from "effect";
import * as Crypto from "node:crypto";
import { homedir } from "node:os";
import { Effect, FileSystem, Random } from "effect";
import { ServerConfig } from "../config";

const CodexAuthJsonSchema = Schema.Struct({
tokens: Schema.Struct({
account_id: Schema.String,
}),
});

const ClaudeJsonSchema = Schema.Struct({
userID: Schema.String,
});

class IdentifyUserError extends Schema.TaggedErrorClass<IdentifyUserError>()("IdentifyUserError", {
message: Schema.String,
cause: Schema.optional(Schema.Defect),
}) {}

const hash = (value: string) =>
Effect.try({
try: () => Crypto.createHash("sha256").update(value).digest("hex"),
catch: (error) =>
new IdentifyUserError({
message: "Failed to hash identifier",
cause: error,
}),
});

const getCodexAccountId = Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;

const authJsonPath = path.join(homedir(), ".codex", "auth.json");
const authJson = yield* Effect.flatMap(
fileSystem.readFileString(authJsonPath),
Schema.decodeEffect(Schema.fromJsonString(CodexAuthJsonSchema)),
);

return authJson.tokens.account_id;
});

const getClaudeUserId = Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;

const claudeJsonPath = path.join(homedir(), ".claude.json");
const claudeJson = yield* Effect.flatMap(
fileSystem.readFileString(claudeJsonPath),
Schema.decodeEffect(Schema.fromJsonString(ClaudeJsonSchema)),
);

return claudeJson.userID;
});

const upsertAnonymousId = Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const { anonymousIdPath } = yield* ServerConfig;
Expand All @@ -68,29 +15,22 @@ const upsertAnonymousId = Effect.gen(function* () {
),
);

return anonymousId;
const trimmed = anonymousId.trim();
if (trimmed.length > 0) return trimmed;

const randomId = yield* Random.nextUUIDv4;
yield* fileSystem.writeFileString(anonymousIdPath, randomId);
return randomId;
});

/**
* getTelemetryIdentifier - Users are "identified" by finding the first match of the following, then hashing the value.
* 1. ~/.codex/auth.json tokens.account_id
* 2. ~/.claude.json userID
* 3. SYNARA_HOME anonymous-id file (for example ~/.synara/userdata/anonymous-id)
* Returns a random installation-scoped identifier stored in Scient's state directory.
* It never reads or derives identity from connected AI-provider accounts.
*/
export const getTelemetryIdentifier = Effect.gen(function* () {
const codexAccountId = yield* Effect.result(getCodexAccountId);
if (codexAccountId._tag === "Success") {
return yield* hash(codexAccountId.success);
}

const claudeUserId = yield* Effect.result(getClaudeUserId);
if (claudeUserId._tag === "Success") {
return yield* hash(claudeUserId.success);
}

const anonymousId = yield* Effect.result(upsertAnonymousId);
if (anonymousId._tag === "Success") {
return yield* hash(anonymousId.success);
return `installation:${anonymousId.success}`;
}

return null;
Expand Down
43 changes: 31 additions & 12 deletions apps/server/src/telemetry/Layers/AnalyticsService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,14 @@ import { AnalyticsServiceLayerLive } from "./AnalyticsService.ts";
interface RecordedBatchRequest {
readonly path: string;
readonly body: {
readonly batch?: ReadonlyArray<{
readonly event?: string;
readonly schema_version?: number;
readonly source?: string;
readonly events?: ReadonlyArray<{
readonly id?: string;
readonly name?: string;
readonly distinct_id?: string;
readonly occurred_at?: string;
readonly privacy_level?: string;
readonly properties?: {
readonly index?: number;
readonly clientType?: string;
Expand All @@ -25,8 +31,13 @@ interface RecordedBatchRequest {
}

interface RecordedBatchBody {
readonly batch: ReadonlyArray<{
readonly event?: string;
readonly schema_version: number;
readonly source: string;
readonly events: ReadonlyArray<{
readonly id?: string;
readonly name?: string;
readonly distinct_id?: string;
readonly privacy_level?: string;
readonly properties?: {
readonly index?: number;
readonly clientType?: string;
Expand All @@ -46,8 +57,7 @@ it.layer(NodeServices.layer)("AnalyticsService test", (it) => {
const configLayer = ConfigProvider.layer(
ConfigProvider.fromUnknown({
SYNARA_TELEMETRY_ENABLED: true,
SYNARA_POSTHOG_KEY: "phc_test_key",
SYNARA_POSTHOG_HOST: "",
SYNARA_TELEMETRY_ENDPOINT: "/v1/events",
SYNARA_TELEMETRY_FLUSH_BATCH_SIZE: 20,
}),
);
Expand Down Expand Up @@ -88,16 +98,16 @@ it.layer(NodeServices.layer)("AnalyticsService test", (it) => {

const batchRequests = capturedRequests.filter(
(request): request is RecordedBatchRequest & { readonly body: RecordedBatchBody } =>
Array.isArray(request.body?.batch),
Array.isArray(request.body?.events),
);
assert.equal(batchRequests.length, 3);
assert.equal(
batchRequests.every((request) => request.path === "/batch/" || request.path === "/batch"),
batchRequests.every((request) => request.path === "/v1/events"),
true,
);
const deliveredIndexes = batchRequests.flatMap((request) =>
request.body.batch
.filter((event) => event.event === "test.flush.drain")
request.body.events
.filter((event) => event.name === "test.flush.drain")
.map((event) => event.properties?.index)
.filter((index): index is number => typeof index === "number"),
);
Expand All @@ -109,8 +119,17 @@ it.layer(NodeServices.layer)("AnalyticsService test", (it) => {
Array.from({ length: 45 }, (_, index) => index),
);
assert.equal(
batchRequests.every((request) =>
request.body.batch.every((event) => event.properties?.clientType === "cli-web-client"),
batchRequests.every(
(request) =>
request.body.schema_version === 1 &&
request.body.source === "desktop" &&
request.body.events.every(
(event) =>
event.properties?.clientType === "cli-web-client" &&
event.privacy_level === "product" &&
event.distinct_id?.startsWith("installation:") === true &&
typeof event.id === "string",
),
),
true,
);
Expand Down
29 changes: 16 additions & 13 deletions apps/server/src/telemetry/Layers/AnalyticsService.ts
Original file line number Diff line number Diff line change
@@ -1,32 +1,31 @@
/**
* AnalyticsServiceLive - Anonymous PostHog telemetry layer.
* AnalyticsServiceLive - First-party ScientFactory telemetry layer.
*
* Persists a random installation-scoped anonymous id to state dir, buffers
* events in memory, and flushes batches to PostHog over Effect HttpClient.
* events in memory, and flushes batches to the ScientFactory event gateway.
*
* @module AnalyticsServiceLive
*/

import { Config, DateTime, Effect, Layer, Ref } from "effect";
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
import { randomUUID } from "node:crypto";

import { ServerConfig } from "../../config.ts";
import { AnalyticsService, type AnalyticsServiceShape } from "../Services/AnalyticsService.ts";
import { getTelemetryIdentifier } from "../Identify.ts";
import { version } from "../../../package.json" with { type: "json" };

interface BufferedAnalyticsEvent {
readonly id: string;
readonly event: string;
readonly properties?: Readonly<Record<string, unknown>>;
readonly capturedAt: string;
}

const TelemetryEnvConfig = Config.all({
posthogKey: Config.string("SYNARA_POSTHOG_KEY").pipe(
Config.withDefault("phc_XOWci4oZP4VvLiEyrFqkFjP4CZn55mjYYBMREK5Wd6m"),
),
posthogHost: Config.string("SYNARA_POSTHOG_HOST").pipe(
Config.withDefault("https://us.i.posthog.com"),
endpoint: Config.string("SYNARA_TELEMETRY_ENDPOINT").pipe(
Config.withDefault("https://events.scientfactory.com/v1/events"),
),
enabled: Config.boolean("SYNARA_TELEMETRY_ENABLED").pipe(Config.withDefault(true)),
flushBatchSize: Config.number("SYNARA_TELEMETRY_FLUSH_BATCH_SIZE").pipe(Config.withDefault(20)),
Expand All @@ -49,6 +48,7 @@ const makeAnalyticsService = Effect.gen(function* () {
const appended = [
...current,
{
id: randomUUID(),
event,
...(properties ? { properties } : {}),
capturedAt: DateTime.formatIso(now),
Expand All @@ -75,24 +75,27 @@ const makeAnalyticsService = Effect.gen(function* () {
if (!telemetryConfig.enabled || !identifier) return;

const payload = {
api_key: telemetryConfig.posthogKey,
batch: events.map((event) => ({
event: event.event,
schema_version: 1,
source: "desktop",
sent_at: new Date().toISOString(),
events: events.map((event) => ({
id: event.id,
name: event.event,
distinct_id: identifier,
occurred_at: event.capturedAt,
privacy_level: "product",
properties: {
...event.properties,
$process_person_profile: false,
platform: process.platform,
wsl: process.env.WSL_DISTRO_NAME,
arch: process.arch,
synaraCodeVersion: version,
clientType,
},
timestamp: event.capturedAt,
})),
};

yield* HttpClientRequest.post(`${telemetryConfig.posthogHost}/batch/`).pipe(
yield* HttpClientRequest.post(telemetryConfig.endpoint).pipe(
HttpClientRequest.bodyJson(payload),
Effect.flatMap(httpClient.execute),
Effect.flatMap(HttpClientResponse.filterStatusOk),
Expand Down
5 changes: 4 additions & 1 deletion docs/release.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ This document covers build-only native validation, promotion through the protect
- macOS metadata note:
- The build initially emits `latest-mac.yml` for both Intel and Apple Silicon.
- The workflow merges the per-arch macOS metadata, then keeps the merged manifest as `latest-mac.yml` and copies it to `scient-mac.yml` for stable releases.
- The desktop build script repacks the macOS update `.zip` with `ditto`, verifies Electron framework symlinks, extracts the zip, validates the extracted app signature, patches the matching `latest-mac*.yml` hash/size, and removes the stale `.zip.blockmap`.
- The desktop build script gives unsigned early-access apps a complete ad-hoc signature before packaging, repacks the macOS update `.zip` with `ditto`, verifies Electron framework symlinks and both source/extracted app signatures, validates the app inside the final DMG, patches the matching `latest-mac*.yml` hash/size, and removes the stale `.zip.blockmap`.
- macOS updater downloads intentionally use the full zip payload so Squirrel.Mac installs the exact signed archive validated by release build.
- Local smoke test:
- Run `bun run release:smoke:mac-update -- --skip-build --build-version 0.1.5` on macOS after local desktop/server/web dist files exist.
Expand Down Expand Up @@ -137,6 +137,9 @@ Unsigned behavior is platform-specific:
Unknown Publisher or SmartScreen warning before installation continues.
- macOS checks and downloads the update inside Scient, then opens the downloaded
ZIP in Finder. The user must replace Scient in Applications and reopen it.
Early-access bundles are ad-hoc signed so macOS can verify their internal
integrity, but they remain unnotarized and can still show an unidentified
developer warning that the user must explicitly bypass once.
- Linux keeps the existing AppImage behavior.

Never enable unsigned publication while a platform has a partial signing-secret
Expand Down
49 changes: 49 additions & 0 deletions scripts/adhoc-sign-mac-app.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// FILE: adhoc-sign-mac-app.cjs
// Purpose: Gives unsigned early-access macOS bundles a complete ad-hoc signature before packaging.
// Layer: electron-builder afterPack hook
// Depends on: macOS codesign and the staged Electron application bundle.

const { execFileSync, spawnSync } = require("node:child_process");
const { join, resolve } = require("node:path");

const EXPECTED_BUNDLE_IDENTIFIER = "com.scientfactory.scient";

module.exports = async function adhocSignMacApp(context) {
if (context.electronPlatformName !== "darwin") {
return;
}

const productFilename = context.packager.appInfo.productFilename;
const appPath = join(context.appOutDir, `${productFilename}.app`);
const configuredEntitlements = context.packager.platformSpecificBuildOptions.entitlements;
const args = ["--force", "--deep", "--sign", "-", "--options", "runtime"];

if (typeof configuredEntitlements === "string" && configuredEntitlements.length > 0) {
args.push("--entitlements", resolve(context.packager.projectDir, configuredEntitlements));
}
args.push(appPath);

console.log(`Ad-hoc signing unsigned macOS bundle: ${appPath}`);
execFileSync("/usr/bin/codesign", args, { stdio: "inherit" });
execFileSync("/usr/bin/codesign", ["--verify", "--deep", "--strict", "--verbose=2", appPath], {
stdio: "inherit",
});

const detailResult = spawnSync("/usr/bin/codesign", ["-dv", "--verbose=4", appPath], {
encoding: "utf8",
});
if (detailResult.status !== 0) {
throw new Error(
`Could not inspect ad-hoc signature: ${(detailResult.stderr || detailResult.stdout || "").trim()}`,
);
}
const details = `${detailResult.stdout || ""}\n${detailResult.stderr || ""}`;
if (!details.includes(`Identifier=${EXPECTED_BUNDLE_IDENTIFIER}`)) {
throw new Error(
`Ad-hoc signed bundle has the wrong identifier; expected ${EXPECTED_BUNDLE_IDENTIFIER}.`,
);
}
if (!details.includes("Signature=adhoc") || !details.includes("TeamIdentifier=not set")) {
throw new Error("Unsigned macOS bundle was not sealed with the expected ad-hoc identity.");
}
};
25 changes: 24 additions & 1 deletion scripts/build-desktop-artifact-mac-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
MAC_APPSNAP_HELPER_ASAR_EXCLUSION,
MAC_APPSNAP_HELPER_BUNDLE_PATH,
MAC_APPSNAP_HELPER_STAGE_PATH,
MAC_ADHOC_SIGN_HOOK_PATH,
MAC_ENTITLEMENTS_PATH,
MAC_INHERITED_ENTITLEMENTS_PATH,
MICROPHONE_USAGE_DESCRIPTION,
Expand All @@ -18,12 +19,14 @@ describe("createDesktopPlatformBuildConfig", () => {
it("adds explicit microphone entitlements to macOS builds", () => {
const config = createDesktopPlatformBuildConfig({
platform: "mac",
signed: false,
target: "dmg",
});
const mac = config.mac as Record<string, unknown>;
const extendInfo = mac.extendInfo as Record<string, unknown>;

assert.deepStrictEqual(mac.target, ["dmg", "zip"]);
assert.equal(config.afterPack, MAC_ADHOC_SIGN_HOOK_PATH);
assert.equal(mac.icon, "icon.icns");
assert.deepStrictEqual(config.asarUnpack, ["node_modules/node-pty/**"]);
assert.equal(mac.hardenedRuntime, true);
Expand All @@ -37,7 +40,11 @@ describe("createDesktopPlatformBuildConfig", () => {
"apps/desktop/native/appsnap/build/scient-appsnap-helper",
);
assert.equal(MAC_APPSNAP_HELPER_ASAR_EXCLUSION, "!apps/desktop/native/appsnap/build/**");
assert.deepStrictEqual(config.files, ["**/*", MAC_APPSNAP_HELPER_ASAR_EXCLUSION]);
assert.deepStrictEqual(config.files, [
"**/*",
MAC_APPSNAP_HELPER_ASAR_EXCLUSION,
`!${MAC_ADHOC_SIGN_HOOK_PATH}`,
]);
assert.deepStrictEqual(config.extraFiles, [
{
from: "apps/desktop/native/appsnap/build/scient-appsnap-helper",
Expand All @@ -48,6 +55,22 @@ describe("createDesktopPlatformBuildConfig", () => {
assert.equal(extendInfo.NSScreenCaptureUsageDescription, undefined);
});

it("uses ad-hoc sealing only for unsigned macOS bundles", () => {
const unsigned = createDesktopPlatformBuildConfig({
platform: "mac",
signed: false,
target: "dmg",
});
const signed = createDesktopPlatformBuildConfig({
platform: "mac",
signed: true,
target: "dmg",
});

assert.equal(unsigned.afterPack, MAC_ADHOC_SIGN_HOOK_PATH);
assert.equal(signed.afterPack, undefined);
});

it("keeps non-macOS platform configs complete", () => {
const linux = createDesktopPlatformBuildConfig({
platform: "linux",
Expand Down
Loading
Loading