@aixle/insights 0.2.4-staging → 0.2.6-staging

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -102,7 +102,27 @@ After `init` succeeds:
102
102
 
103
103
  ## Multi-org
104
104
 
105
- If your account has several organization memberships, `init` mints ingest tokens for the **oldest** membership unless you scope to a different org:
105
+ If your account belongs to a **single** organization, `init` binds that org automatically no flag, zero friction.
106
+
107
+ If your account belongs to **more than one** organization, `init` needs to know which org to bind. It resolves the target in this order:
108
+
109
+ 1. `--organization-id <uuid>` flag (or `DB90_ORGANIZATION_ID` env var), or
110
+ 2. your **Default Organization** preference from the web app.
111
+
112
+ If neither is set, `init` **does not** guess. It prints the organizations you belong to and exits non-zero without saving credentials:
113
+
114
+ ```
115
+ Multiple organizations found — choose one to bind this install:
116
+ - Acme Corp (b1e2... ) — owner
117
+ - Contoso (c3d4... ) — member
118
+ Then either:
119
+ 1. Re-run init with --organization-id <uuid> (completes device login again), or
120
+ 2. Set a Default Organization in web Preferences, then re-run init.
121
+ ```
122
+
123
+ Because credentials are only persisted on success, re-running `init` means completing the Keycloak device login again.
124
+
125
+ To scope explicitly:
106
126
 
107
127
  ```bash
108
128
  npx -y @aixle/insights init \
@@ -113,6 +133,10 @@ npx -y @aixle/insights init \
113
133
 
114
134
  You can also set `DB90_ORGANIZATION_ID=<uuid>` in your shell environment, or pin it via `mcpServers.aixle-insights.env` in `~/.claude.json`. The CLI flag overrides the env var when both are set.
115
135
 
136
+ Once `init` succeeds it reports the bound org (`Credentials saved (organization <uuid>).`), and `aixle-insights health` (or the `aixle_insights_status` MCP tool) shows the bound `organization_id`.
137
+
138
+ > **Preferences caveat — this is non-obvious.** The web app's "current org" (the one you appear to be viewing) is normally the **last-used** org, remembered in your browser's `localStorage`. That is **not** the same as your **Default Organization** preference, which is what `init` reads. A multi-org user who has switched orgs in the UI but never explicitly set **Default Organization** in web Preferences has no server-side preference for `init` to use — so `init` will hit the org-selection error above until you either set the Default Organization preference or pass `--organization-id`.
139
+
116
140
  ## First run and backfill
117
141
 
118
142
  Before you run `init`, the MCP server is a deliberate no-op: it does not read, buffer, or send anything, and it does not create any state files. Nothing is lost during this window — Claude Code's transcripts and Cursor's local telemetry stores are unaffected by whether `@aixle/insights` is watching them.
@@ -162,6 +186,12 @@ Optional `~/.aixle-insights/config.json` accepts Cursor line-cost overrides (per
162
186
  }
163
187
  ```
164
188
 
189
+ The file is optional — an absent `config.json` is the normal case and is silent. A file that is
190
+ present but unusable is ignored entirely (every override falls back to its default) and a
191
+ `config_parse_failed` line is written to `mcp.log`. That covers malformed JSON and valid JSON that
192
+ isn't an object, including a **top-level array** — a common mistake when writing per-model rates.
193
+ Nothing is printed to the terminal, so check the log if an override appears to have no effect.
194
+
165
195
  ## Security
166
196
 
167
197
  `@aixle/insights` enforces HTTPS for any remote host. Plaintext `http://` is allowed only for loopback (`localhost`, `127.0.0.0/8`, `[::1]`) so that local-dev flows against `make up` continue to work without friction.
@@ -188,6 +218,22 @@ The runtime gate exists because `init`'s two gates only run once, at login time.
188
218
 
189
219
  The Keycloak issuer URL (`--keycloak-url` / `KEYCLOAK_ISSUER`) is **not** TLS-gated by this package. Same threat model, different ticket — tracked separately. For now, use HTTPS for any remote Keycloak issuer; the OIDC device-flow library will fail the request if the cert is invalid, but it will not refuse to attempt plaintext.
190
220
 
221
+ ### Local store integrity
222
+
223
+ Transport security covers data in flight. The other half of the threat model is what the package
224
+ reads back off the local machine: credentials (keychain or file), `config.json`, and state files are
225
+ all attacker-writable if the account is compromised, so none of them is trusted on read.
226
+
227
+ Each is validated every time it is loaded. A payload that fails to parse, or that parses but does
228
+ not match the expected shape, is **rejected** and the caller falls back to its documented default —
229
+ no credentials, no config overrides, fresh state. Every rejection is recorded in `mcp.log`, so a
230
+ corrupted or tampered store is distinguishable from one that was never created; before this, both
231
+ were silent and looked identical to a fresh install. See
232
+ [Diagnostics](#diagnostics) for the event names.
233
+
234
+ Log fields carry only the file path (or the keychain service name) and a short machine-readable
235
+ reason. File contents, keychain payloads, and tokens are never logged.
236
+
191
237
  ## Cursor hook forwarder (opt-in)
192
238
 
193
239
  `aixle-insights init --hooks --tool-name cursor` installs a Node script as a Cursor hook (`~/.cursor/hooks.json`). The script appends redacted hook payloads to `~/.aixle-insights/hooks-queue.ndjson`; the background sync drains the queue on its next cycle and POSTs the events with accurate per-turn model attribution. Requires a Cursor restart after install. To remove, run `aixle-insights uninstall-hooks` and restart Cursor again.
@@ -225,6 +271,35 @@ aixle-insights verify-hooks # JSON: hooks installed + queue depth
225
271
 
226
272
  `mcp.log` (rotates at 5 MiB to `mcp.log.1`) under the app home directory captures operational events. Inside Claude Code, the **`aixle_insights_status`** MCP tool returns the same diagnostic structure as `aixle-insights health`.
227
273
 
274
+ ### Local-store integrity events
275
+
276
+ These four are the only signal that a local store was present but unusable — `health` and
277
+ `aixle_insights_status` do **not** report them, so `mcp.log` is the sole surface:
278
+
279
+ | Event | Fires when |
280
+ |---|---|
281
+ | `credentials_parse_failed` | `credentials.json` exists but was rejected |
282
+ | `credentials_keytar_parse_failed` | the OS keychain entry exists but was rejected |
283
+ | `config_parse_failed` | `config.json` exists but was rejected |
284
+ | `state_parse_failed` | a state file exists but was rejected |
285
+
286
+ Each carries a `reason` distinguishing the two failure modes:
287
+
288
+ - `invalid_json` — the payload did not parse at all.
289
+ - `invalid_shape` — it parsed, but validation rejected it: credentials with no usable token, a
290
+ `config.json` that is a JSON array, a state file missing `version` / `sessions`, and so on.
291
+
292
+ Three properties are worth relying on:
293
+
294
+ - An **absent** file never warns. That is the everyday case (most users never create a
295
+ `config.json`, and every machine starts with no state file), so a warning always means something
296
+ is actually there and wrong.
297
+ - A **missing or disabled OS keychain** never warns either — falling back to the file is expected
298
+ on headless Linux, CI, and containers, not an error.
299
+ - All four are written to the log **only**, never mirrored to stderr, because stray output on the
300
+ stdio transport corrupts the MCP protocol. Emitting a warning never changes the fallback the
301
+ caller returns.
302
+
228
303
  ## Troubleshooting
229
304
 
230
305
  | Symptom | Most likely cause | Fix |
@@ -236,6 +311,9 @@ aixle-insights verify-hooks # JSON: hooks installed + queue depth
236
311
  | `health` shows `authenticated: true` but `last_result` is `sent: 0, failed: N` cycle after cycle | Same as the 401 row above. `authenticated` only proves the OIDC token was acquired, not that the ingest token still validates server-side. | Re-init as above. |
237
312
  | `last_result` reports `sent: N` but the Events UI shows nothing | The Temporal worker is not running. The ingest endpoint returns HTTP 202 (queued) regardless of worker state. | `make worker` (or check `docker ps` for `db90-worker`). See [LOCAL-DEV.md](./LOCAL-DEV.md) §1. |
238
313
  | `sync_lock_skip {reason: "advisory_lock_held"}` in the log | Another sync cycle is still holding `~/.aixle-insights/state.lock`. | Wait for it to finish; only delete the lock file (`rm -f ~/.aixle-insights/state.lock`) after confirming no `aixle-insights run` process is alive (`pgrep -fa aixle-insights`). |
314
+ | `health` reports `authenticated: false` right after a successful `init`, and `credentials_parse_failed` or `credentials_keytar_parse_failed` is in the log | The credential store exists but was rejected, so it is treated as absent. The `reason` field says whether it failed to parse (`invalid_json`) or parsed into the wrong shape (`invalid_shape`). | Re-run `init`. If you hand-edited `credentials.json` for local testing, remember the keychain is read **first** — see [State + credentials](#state--credentials). |
315
+ | A `config.json` override has no effect, and `config_parse_failed` is in the log | The file is malformed, or is valid JSON that is not an object — a top-level array is the usual mistake. | Fix it to match the shape under [Environment](#environment); until it parses, every override is ignored. |
316
+ | Sync re-sends history that was already delivered, and `state_parse_failed` is in the log | A state file was present but rejected, so sync fell back to fresh state and lost its dedup checkpoints. | This is recovery, not a loop — the next successful cycle writes valid state. Ingest upserts by session, so duplicates are absorbed. Worth investigating what wrote the bad file. |
239
317
  | `aixle-insights --help` doesn't list `--insecure` | You're running an older published version of the package, not the local source. | `which aixle-insights` shows the path. To run local source: `cd packages/tools/aixle-insights && npm run build && npm link`. To return to the published version: `npm unlink -g @aixle/insights && npm install -g @aixle/insights@latest`. |
240
318
  | Not sure whether `aixle-insights` is a `npm link` or a real install | Real installs are regular files; `npm link` is a symlink chain into the repo. | `readlink "$(which aixle-insights)"` shows the link target if any. A linked install will trace back to a path under your monorepo checkout. |
241
319
 
@@ -4,6 +4,7 @@ import { userInfo } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { getAppDir } from "../state.js";
6
6
  import { mcpLog } from "../log.js";
7
+ import { describeReadFailure } from "../lib/parse-error.js";
7
8
  export const KEYTAR_SERVICE = "aixle-insights";
8
9
  const KEYTAR_ACCOUNT = "aixle-insights-ingest-credential";
9
10
  function credentialsPath(appDir) {
@@ -62,15 +63,23 @@ export function loadCredentialsFromFileOnly(appDir = getAppDir()) {
62
63
  const filePath = credentialsPath(appDir);
63
64
  if (!existsSync(filePath))
64
65
  return null;
66
+ let raw;
65
67
  try {
66
- const raw = JSON.parse(readFileSync(filePath, "utf-8"));
67
- return normalizeLoadedCredentials(raw);
68
+ raw = JSON.parse(readFileSync(filePath, "utf-8"));
68
69
  }
69
70
  catch (err) {
70
- // File exists (checked above) but failed to parse/normalize — distinguishes tampering from "never created".
71
- mcpLog.warn("credentials_parse_failed", { path: filePath, error: err instanceof Error ? err.message : String(err) }, false);
71
+ // File exists (checked above) but is not readable/valid JSON — distinguishes tampering from "never created".
72
+ mcpLog.warn("credentials_parse_failed", { path: filePath, ...describeReadFailure(err) }, false);
72
73
  return null;
73
74
  }
75
+ const normalized = normalizeLoadedCredentials(raw);
76
+ if (normalized === null) {
77
+ // Valid JSON, but not a credential shape we accept. `normalizeLoadedCredentials` signals
78
+ // rejection by returning null and never throws, so this cannot surface in the catch
79
+ // above — without this branch a plausible-looking replacement file stays silent. (DB90DV-699)
80
+ mcpLog.warn("credentials_parse_failed", { path: filePath, reason: "invalid_shape" }, false);
81
+ }
82
+ return normalized;
74
83
  }
75
84
  async function tryKeytarGet() {
76
85
  if (keytarDisabled())
@@ -86,15 +95,22 @@ async function tryKeytarGet() {
86
95
  }
87
96
  if (!raw)
88
97
  return null;
98
+ let parsed;
89
99
  try {
90
- const parsed = JSON.parse(raw);
91
- return normalizeLoadedCredentials(parsed);
100
+ parsed = JSON.parse(raw);
92
101
  }
93
102
  catch (err) {
94
- // Keychain entry exists (checked above) but failed to parse/normalize — distinguishes tampering from "no entry".
95
- mcpLog.warn("credentials_keytar_parse_failed", { keytarService: KEYTAR_SERVICE, error: err instanceof Error ? err.message : String(err) }, false);
103
+ // Keychain entry exists (checked above) but is not valid JSON — distinguishes tampering from "no entry".
104
+ mcpLog.warn("credentials_keytar_parse_failed", { keytarService: KEYTAR_SERVICE, ...describeReadFailure(err) }, false);
96
105
  return null;
97
106
  }
107
+ const normalized = normalizeLoadedCredentials(parsed);
108
+ if (normalized === null) {
109
+ // Same shape-rejection hole as the file path above. Fields stay service-only — never the
110
+ // keychain payload. (DB90DV-699)
111
+ mcpLog.warn("credentials_keytar_parse_failed", { keytarService: KEYTAR_SERVICE, reason: "invalid_shape" }, false);
112
+ }
113
+ return normalized;
98
114
  }
99
115
  async function tryKeytarSet(payload) {
100
116
  if (keytarDisabled())
@@ -1,3 +1,12 @@
1
+ export interface OrgOption {
2
+ id: string;
3
+ name: string;
4
+ role: string;
5
+ }
6
+ export declare class OrganizationSelectionRequiredError extends Error {
7
+ readonly organizations: OrgOption[];
8
+ constructor(message: string, organizations: OrgOption[]);
9
+ }
1
10
  export interface ExchangeAccount {
2
11
  ingestToken: string;
3
12
  }
@@ -1,3 +1,11 @@
1
+ export class OrganizationSelectionRequiredError extends Error {
2
+ organizations;
3
+ constructor(message, organizations) {
4
+ super(message);
5
+ this.name = "OrganizationSelectionRequiredError";
6
+ this.organizations = organizations;
7
+ }
8
+ }
1
9
  export async function exchangeIngestToken(params) {
2
10
  const fetchFn = params.fetchImpl ?? fetch;
3
11
  const requestedTools = params.tools?.length ? [...params.tools] : params.toolName ? [params.toolName] : [];
@@ -38,6 +46,21 @@ export async function exchangeIngestToken(params) {
38
46
  throw new Error(`DB90 exchange: invalid JSON (HTTP ${res.status}): ${text.slice(0, 200)}`);
39
47
  }
40
48
  if (!res.ok) {
49
+ const errBody = json;
50
+ if (errBody["error"] === "organization_selection_required") {
51
+ const rawOrgs = Array.isArray(errBody["organizations"]) ? errBody["organizations"] : [];
52
+ // Defensive: skip null/non-object elements so a malformed server body cannot crash init.
53
+ const orgs = rawOrgs
54
+ .filter((o) => typeof o === "object" && o !== null && !Array.isArray(o))
55
+ .filter((o) => typeof o["id"] === "string" && typeof o["name"] === "string")
56
+ .map((o) => ({
57
+ id: o["id"],
58
+ name: o["name"],
59
+ role: String(o["role"] ?? ""),
60
+ }));
61
+ const msg = typeof errBody["message"] === "string" ? errBody["message"] : "Organization selection required.";
62
+ throw new OrganizationSelectionRequiredError(msg, orgs);
63
+ }
41
64
  throw new Error(`DB90 exchange failed (HTTP ${res.status}): ${text.slice(0, 500)}`);
42
65
  }
43
66
  const root = json;
@@ -1,3 +1,4 @@
1
+ import { type OrgOption } from "./exchange.js";
1
2
  import { defaultKeycloakClientId, defaultKeycloakIssuer } from "./keycloak.js";
2
3
  import type { TelemetryToolId } from "./credentials.js";
3
4
  export interface LoginAndPersistOptions {
@@ -29,5 +30,7 @@ export declare function loginAndPersistCredentials(opts: LoginAndPersistOptions)
29
30
  } | {
30
31
  ok: false;
31
32
  error: string;
33
+ code?: "organization_selection_required";
34
+ organizations?: OrgOption[];
32
35
  }>;
33
36
  export { defaultKeycloakIssuer, defaultKeycloakClientId };
package/dist/auth/flow.js CHANGED
@@ -1,4 +1,4 @@
1
- import { exchangeIngestToken } from "./exchange.js";
1
+ import { exchangeIngestToken, OrganizationSelectionRequiredError } from "./exchange.js";
2
2
  import { defaultKeycloakClientId, defaultKeycloakIssuer, obtainKeycloakAccessTokenViaDeviceFlow } from "./keycloak.js";
3
3
  import { loadCredentials, saveStoredCredentials } from "./credentials.js";
4
4
  import { getAppDir } from "../state.js";
@@ -78,10 +78,11 @@ export async function loginAndPersistCredentials(opts) {
78
78
  opts.onSecurityWarning?.(ingestHostSecurity.warning);
79
79
  }
80
80
  const existing = await loadCredentials(appDir);
81
+ const sameOrg = existing?.host === exchanged.ingestHost && existing.organizationId === exchanged.organizationId;
81
82
  const stored = {
82
83
  host: exchanged.ingestHost,
83
84
  organizationId: exchanged.organizationId,
84
- accounts: existing?.host === exchanged.ingestHost ? { ...existing.accounts } : {},
85
+ accounts: sameOrg ? { ...existing.accounts } : {},
85
86
  ...(opts.allowInsecureHttp === true ? { insecureHttpAllowed: true } : {}),
86
87
  };
87
88
  for (const tid of ["claude_code", "cursor"]) {
@@ -92,6 +93,14 @@ export async function loginAndPersistCredentials(opts) {
92
93
  await saveStoredCredentials(stored, appDir);
93
94
  }
94
95
  catch (e) {
96
+ if (e instanceof OrganizationSelectionRequiredError) {
97
+ return {
98
+ ok: false,
99
+ code: "organization_selection_required",
100
+ organizations: e.organizations,
101
+ error: e.message,
102
+ };
103
+ }
95
104
  return { ok: false, error: e instanceof Error ? e.message : String(e) };
96
105
  }
97
106
  return { ok: true, organizationId: exchanged.organizationId };
package/dist/cli.js CHANGED
@@ -185,7 +185,9 @@ init options:
185
185
  --insecure Allow remote http:// hosts for trusted non-production test endpoints only.
186
186
 
187
187
  Multi-org:
188
- Set \`DB90_ORGANIZATION_ID\` to a UUID, or pass \`--organization-id\` on \`init\`, so ingest tokens are minted for that membership instead of the default (oldest) org.
188
+ If you belong to more than one org, pass \`--organization-id <uuid>\` (or set \`DB90_ORGANIZATION_ID\`),
189
+ or set a Default Organization in web Preferences. Without either, \`init\` lists your orgs and exits.
190
+ Single-org users need no flag. Run \`aixle-insights health\` to see the bound organization_id.
189
191
 
190
192
  Credentials:
191
193
  Stored in the OS keychain via keytar when available; otherwise
@@ -270,6 +272,19 @@ export async function runInit(cliArgs, deps) {
270
272
  },
271
273
  });
272
274
  if (!result.ok) {
275
+ if (result.code === "organization_selection_required") {
276
+ runtime.error(result.error);
277
+ if (result.organizations?.length) {
278
+ runtime.error("Multiple organizations found — choose one to bind this install:");
279
+ for (const o of result.organizations) {
280
+ runtime.error(` - ${o.name} (${o.id})${o.role ? ` — ${o.role}` : ""}`);
281
+ }
282
+ }
283
+ runtime.error("Then either:");
284
+ runtime.error(" 1. Re-run init with --organization-id <uuid> (completes device login again), or");
285
+ runtime.error(" 2. Set a Default Organization in web Preferences, then re-run init.");
286
+ return 1;
287
+ }
273
288
  runtime.error(`Auth failed: ${result.error}`);
274
289
  return 1;
275
290
  }
package/dist/health.d.ts CHANGED
@@ -5,6 +5,7 @@ export interface HealthSnapshot {
5
5
  authenticated: boolean;
6
6
  configured: boolean;
7
7
  host: string | null;
8
+ organization_id: string | null;
8
9
  ingest_tools: TelemetryToolId[];
9
10
  app_dir: string;
10
11
  log_path: string;
package/dist/health.js CHANGED
@@ -70,6 +70,7 @@ export async function buildHealthSnapshot() {
70
70
  authenticated: false,
71
71
  configured: false,
72
72
  host: null,
73
+ organization_id: null,
73
74
  ingest_tools: [],
74
75
  app_dir: appDir,
75
76
  log_path: logPath,
@@ -94,6 +95,7 @@ export async function buildHealthSnapshot() {
94
95
  authenticated: true,
95
96
  configured: true,
96
97
  host: creds.host,
98
+ organization_id: creds.organizationId ?? null,
97
99
  ingest_tools,
98
100
  app_dir: appDir,
99
101
  log_path: logPath,
@@ -114,6 +116,7 @@ export async function buildHealthSnapshot() {
114
116
  authenticated: false,
115
117
  configured: false,
116
118
  host: null,
119
+ organization_id: null,
117
120
  ingest_tools: [],
118
121
  app_dir: appDir,
119
122
  log_path: logPath,
@@ -150,6 +153,7 @@ export function healthSnapshotToStatusPayload(snapshot) {
150
153
  authenticated: snapshot.authenticated,
151
154
  configured: snapshot.configured,
152
155
  host: snapshot.host,
156
+ organization_id: snapshot.organization_id,
153
157
  ingest_tools: snapshot.ingest_tools,
154
158
  needs_init: needsInit,
155
159
  onboarding_message: onboardingMessage,
@@ -172,6 +176,7 @@ export function formatHealthForCli(snapshot) {
172
176
  lines.push(`authenticated: ${snapshot.authenticated}`);
173
177
  lines.push(`configured: ${snapshot.configured}`);
174
178
  lines.push(`host: ${snapshot.host ?? "(none)"}`);
179
+ lines.push(`organization_id: ${snapshot.organization_id ?? "(none)"}`);
175
180
  lines.push(`ingest_tools: ${snapshot.ingest_tools.length ? snapshot.ingest_tools.join(", ") : "(none)"}`);
176
181
  lines.push(`state_file_paths:`);
177
182
  if (snapshot.state_file_paths.length === 0) {
@@ -12,8 +12,8 @@ export interface BaseConfig {
12
12
  * Load a connector's `config.json` from disk. Returns `{}` on missing or
13
13
  * malformed files — callers fall back to env vars / CLI flags / defaults.
14
14
  *
15
- * @param configDir Directory containing `config.json`, typically the
16
- * connector's `APP_DIR` (`~/.db90-claude` / `~/.db90-cursor`).
15
+ * @param configDir Directory containing `config.json`, typically the app home directory
16
+ * (`~/.aixle-insights`, or `AIXLE_INSIGHTS_HOME` when set).
17
17
  * @param parsePricing Optional callback that extracts a connector-specific
18
18
  * pricing shape from the raw parsed JSON. Returns
19
19
  * `undefined` when the pricing block is missing or invalid.
@@ -1,12 +1,13 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { mcpLog } from "../log.js";
4
+ import { describeReadFailure } from "./parse-error.js";
4
5
  /**
5
6
  * Load a connector's `config.json` from disk. Returns `{}` on missing or
6
7
  * malformed files — callers fall back to env vars / CLI flags / defaults.
7
8
  *
8
- * @param configDir Directory containing `config.json`, typically the
9
- * connector's `APP_DIR` (`~/.db90-claude` / `~/.db90-cursor`).
9
+ * @param configDir Directory containing `config.json`, typically the app home directory
10
+ * (`~/.aixle-insights`, or `AIXLE_INSIGHTS_HOME` when set).
10
11
  * @param parsePricing Optional callback that extracts a connector-specific
11
12
  * pricing shape from the raw parsed JSON. Returns
12
13
  * `undefined` when the pricing block is missing or invalid.
@@ -16,29 +17,36 @@ import { mcpLog } from "../log.js";
16
17
  */
17
18
  export function loadBaseConfig(configDir, parsePricing) {
18
19
  const configPath = join(configDir, "config.json");
20
+ let parsed;
19
21
  try {
20
- const parsed = JSON.parse(readFileSync(configPath, "utf-8"));
21
- if (typeof parsed === "object" && parsed !== null) {
22
- const obj = parsed;
23
- const result = {
24
- token: typeof obj.token === "string" ? obj.token : undefined,
25
- host: typeof obj.host === "string" ? obj.host : undefined,
26
- project_id: typeof obj.project_id === "string" ? obj.project_id : undefined,
27
- };
28
- if (parsePricing) {
29
- const pricing = parsePricing(obj);
30
- if (pricing !== undefined)
31
- result.pricing = pricing;
32
- }
33
- return result;
34
- }
22
+ parsed = JSON.parse(readFileSync(configPath, "utf-8"));
35
23
  }
36
24
  catch (err) {
37
25
  const code = err?.code;
38
26
  if (code !== "ENOENT") {
39
- // Config file exists but failed to parse — distinguishes tampering from "never created".
40
- mcpLog.warn("config_parse_failed", { path: configPath, error: err instanceof Error ? err.message : String(err) }, false);
27
+ // Config file exists but is not valid JSON — distinguishes tampering from "never created".
28
+ // ENOENT stays silent: this file is optional and most users never create it.
29
+ mcpLog.warn("config_parse_failed", { path: configPath, ...describeReadFailure(err) }, false);
41
30
  }
31
+ return {};
32
+ }
33
+ // Valid JSON, but not a config object. Arrays are rejected explicitly because
34
+ // `typeof [] === "object"` would otherwise let them reach the happy path and be handed
35
+ // to `parsePricing`. Previously every non-object fell through silently. (DB90DV-699)
36
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
37
+ mcpLog.warn("config_parse_failed", { path: configPath, reason: "invalid_shape" }, false);
38
+ return {};
39
+ }
40
+ const obj = parsed;
41
+ const result = {
42
+ token: typeof obj.token === "string" ? obj.token : undefined,
43
+ host: typeof obj.host === "string" ? obj.host : undefined,
44
+ project_id: typeof obj.project_id === "string" ? obj.project_id : undefined,
45
+ };
46
+ if (parsePricing) {
47
+ const pricing = parsePricing(obj);
48
+ if (pricing !== undefined)
49
+ result.pricing = pricing;
42
50
  }
43
- return {};
51
+ return result;
44
52
  }
@@ -0,0 +1,21 @@
1
+ export type ReadFailureReason = "invalid_json" | "unreadable";
2
+ /**
3
+ * Classifies a caught error from `JSON.parse(readFileSync(...))` (or a parsed keychain
4
+ * payload) into a log-safe reason + error string.
5
+ *
6
+ * V8's `JSON.parse` throws a `SyntaxError` whose `.message` can embed a prefix (or, for a
7
+ * short enough input, the entirety) of the unparsed content — e.g.
8
+ * `JSON.parse("example_local_fixture_1234567890")` produces
9
+ * `Unexpected token 'e', "example_lo"... is not valid JSON`. Logging that message would
10
+ * leak exactly the secret content the parse-failure events exist to describe without
11
+ * exposing (see `credentials_parse_failed` / `credentials_keytar_parse_failed` /
12
+ * `config_parse_failed` / `state_parse_failed`). So for a `SyntaxError` this reports only
13
+ * the error name, never `.message`.
14
+ *
15
+ * Any other error (fs I/O — `EACCES`, `EISDIR`, etc.) is reported as `unreadable` using its
16
+ * errno `code`, which never contains file content and is more actionable than a bare name.
17
+ */
18
+ export declare function describeReadFailure(err: unknown): {
19
+ reason: ReadFailureReason;
20
+ error: string;
21
+ };
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Classifies a caught error from `JSON.parse(readFileSync(...))` (or a parsed keychain
3
+ * payload) into a log-safe reason + error string.
4
+ *
5
+ * V8's `JSON.parse` throws a `SyntaxError` whose `.message` can embed a prefix (or, for a
6
+ * short enough input, the entirety) of the unparsed content — e.g.
7
+ * `JSON.parse("example_local_fixture_1234567890")` produces
8
+ * `Unexpected token 'e', "example_lo"... is not valid JSON`. Logging that message would
9
+ * leak exactly the secret content the parse-failure events exist to describe without
10
+ * exposing (see `credentials_parse_failed` / `credentials_keytar_parse_failed` /
11
+ * `config_parse_failed` / `state_parse_failed`). So for a `SyntaxError` this reports only
12
+ * the error name, never `.message`.
13
+ *
14
+ * Any other error (fs I/O — `EACCES`, `EISDIR`, etc.) is reported as `unreadable` using its
15
+ * errno `code`, which never contains file content and is more actionable than a bare name.
16
+ */
17
+ export function describeReadFailure(err) {
18
+ if (err instanceof SyntaxError) {
19
+ return { reason: "invalid_json", error: "SyntaxError" };
20
+ }
21
+ const code = err?.code;
22
+ if (code)
23
+ return { reason: "unreadable", error: code };
24
+ return { reason: "unreadable", error: err instanceof Error ? err.name : "unknown_error" };
25
+ }
package/dist/state.js CHANGED
@@ -3,6 +3,7 @@ import { join } from "node:path";
3
3
  import { homedir } from "node:os";
4
4
  import { createHash, randomBytes } from "node:crypto";
5
5
  import { mcpLog } from "./log.js";
6
+ import { describeReadFailure } from "./lib/parse-error.js";
6
7
  export function getAppDir() {
7
8
  const override = process.env["AIXLE_INSIGHTS_HOME"]?.trim();
8
9
  if (override && override.length > 0)
@@ -90,48 +91,50 @@ export function migrateLegacyState(dir, host, token) {
90
91
  }
91
92
  export function readState(dir, host, token) {
92
93
  const filePath = stateFilePath(dir ?? getAppDir(), host, token);
94
+ let parsed;
93
95
  try {
94
- const parsed = JSON.parse(readFileSync(filePath, "utf-8"));
95
- if (typeof parsed === "object" && parsed !== null) {
96
- const p = parsed;
97
- if (typeof p.version === "number" &&
98
- typeof p.sessions === "object" &&
99
- p.sessions !== null) {
100
- const lastRecentCommitHashes = Array.isArray(p.lastRecentCommitHashes)
101
- ? p.lastRecentCommitHashes.filter((h) => typeof h === "string")
102
- : undefined;
103
- const out = {
104
- version: p.version,
105
- sessions: p.sessions,
106
- };
107
- if (lastRecentCommitHashes !== undefined) {
108
- out.lastRecentCommitHashes = lastRecentCommitHashes;
109
- }
110
- if ("mcp_operator" in p) {
111
- const mcp = parseMcpOperator(p.mcp_operator);
112
- if (mcp)
113
- out.mcp_operator = mcp;
114
- }
115
- if ("rate_limited_until" in p) {
116
- if (typeof p.rate_limited_until === "string") {
117
- out.rate_limited_until = p.rate_limited_until;
118
- }
119
- else if (p.rate_limited_until === null) {
120
- out.rate_limited_until = null;
121
- }
122
- }
123
- return out;
124
- }
125
- }
96
+ parsed = JSON.parse(readFileSync(filePath, "utf-8"));
126
97
  }
127
98
  catch (err) {
128
99
  const code = err?.code;
129
100
  if (code !== "ENOENT") {
130
- // State file exists but failed to parse — distinguishes tampering from "never created".
131
- mcpLog.warn("state_parse_failed", { path: filePath, error: err instanceof Error ? err.message : String(err) }, false);
101
+ // State file exists but is not valid JSON — distinguishes tampering from "never created".
102
+ // ENOENT stays silent: that is the normal first-run case.
103
+ mcpLog.warn("state_parse_failed", { path: filePath, ...describeReadFailure(err) }, false);
104
+ }
105
+ return { version: 1, sessions: {} };
106
+ }
107
+ const p = typeof parsed === "object" && parsed !== null ? parsed : null;
108
+ if (p === null || typeof p.version !== "number" || typeof p.sessions !== "object" || p.sessions === null) {
109
+ // Valid JSON, wrong shape. This fallback discards every dedup checkpoint and causes a full
110
+ // re-send, so it is the most consequential of the four to have been silent. (DB90DV-699)
111
+ mcpLog.warn("state_parse_failed", { path: filePath, reason: "invalid_shape" }, false);
112
+ return { version: 1, sessions: {} };
113
+ }
114
+ const lastRecentCommitHashes = Array.isArray(p.lastRecentCommitHashes)
115
+ ? p.lastRecentCommitHashes.filter((h) => typeof h === "string")
116
+ : undefined;
117
+ const out = {
118
+ version: p.version,
119
+ sessions: p.sessions,
120
+ };
121
+ if (lastRecentCommitHashes !== undefined) {
122
+ out.lastRecentCommitHashes = lastRecentCommitHashes;
123
+ }
124
+ if ("mcp_operator" in p) {
125
+ const mcp = parseMcpOperator(p.mcp_operator);
126
+ if (mcp)
127
+ out.mcp_operator = mcp;
128
+ }
129
+ if ("rate_limited_until" in p) {
130
+ if (typeof p.rate_limited_until === "string") {
131
+ out.rate_limited_until = p.rate_limited_until;
132
+ }
133
+ else if (p.rate_limited_until === null) {
134
+ out.rate_limited_until = null;
132
135
  }
133
136
  }
134
- return { version: 1, sessions: {} };
137
+ return out;
135
138
  }
136
139
  /** Atomic write: write to a temp file then rename over the target. */
137
140
  export function writeState(state, dir, host, token) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aixle/insights",
3
- "version": "0.2.4-staging",
3
+ "version": "0.2.6-staging",
4
4
  "description": "stdio MCP server for AI coding-assistant telemetry — Claude transcript sync + Cursor SQLite ingest.",
5
5
  "type": "module",
6
6
  "bin": {