@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 +79 -1
- package/dist/auth/credentials.js +24 -8
- package/dist/auth/exchange.d.ts +9 -0
- package/dist/auth/exchange.js +23 -0
- package/dist/auth/flow.d.ts +3 -0
- package/dist/auth/flow.js +11 -2
- package/dist/cli.js +16 -1
- package/dist/health.d.ts +1 -0
- package/dist/health.js +5 -0
- package/dist/lib/config.d.ts +2 -2
- package/dist/lib/config.js +28 -20
- package/dist/lib/parse-error.d.ts +21 -0
- package/dist/lib/parse-error.js +25 -0
- package/dist/state.js +38 -35
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -102,7 +102,27 @@ After `init` succeeds:
|
|
|
102
102
|
|
|
103
103
|
## Multi-org
|
|
104
104
|
|
|
105
|
-
If your account
|
|
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
|
|
package/dist/auth/credentials.js
CHANGED
|
@@ -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
|
-
|
|
67
|
-
return normalizeLoadedCredentials(raw);
|
|
68
|
+
raw = JSON.parse(readFileSync(filePath, "utf-8"));
|
|
68
69
|
}
|
|
69
70
|
catch (err) {
|
|
70
|
-
// File exists (checked above) but
|
|
71
|
-
mcpLog.warn("credentials_parse_failed", { path: filePath,
|
|
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
|
-
|
|
91
|
-
return normalizeLoadedCredentials(parsed);
|
|
100
|
+
parsed = JSON.parse(raw);
|
|
92
101
|
}
|
|
93
102
|
catch (err) {
|
|
94
|
-
// Keychain entry exists (checked above) but
|
|
95
|
-
mcpLog.warn("credentials_keytar_parse_failed", { keytarService: KEYTAR_SERVICE,
|
|
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())
|
package/dist/auth/exchange.d.ts
CHANGED
|
@@ -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
|
}
|
package/dist/auth/exchange.js
CHANGED
|
@@ -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;
|
package/dist/auth/flow.d.ts
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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
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) {
|
package/dist/lib/config.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
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.
|
package/dist/lib/config.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
|
|
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
|
|
40
|
-
|
|
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
|
-
|
|
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
|
|
131
|
-
|
|
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
|
|
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