@aixle/insights 0.2.0 → 0.2.2-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 +38 -9
- package/dist/auth/credentials.d.ts +7 -1
- package/dist/auth/credentials.js +71 -14
- package/dist/auth/exchange.d.ts +1 -1
- package/dist/auth/exchange.js +1 -1
- package/dist/auth/flow.d.ts +8 -1
- package/dist/auth/flow.js +27 -5
- package/dist/auth/keycloak.d.ts +1 -1
- package/dist/auth/keycloak.js +20 -1
- package/dist/cli.d.ts +5 -3
- package/dist/cli.js +69 -21
- package/dist/collect-cursor-payloads.d.ts +4 -3
- package/dist/collect-cursor-payloads.js +8 -5
- package/dist/cursor-checkpoints.d.ts +2 -2
- package/dist/cursor-payload-contract.d.ts +5 -5
- package/dist/cursor-payload-contract.js +6 -0
- package/dist/cursor-settings.d.ts +9 -4
- package/dist/cursor-settings.js +80 -10
- package/dist/health.d.ts +3 -1
- package/dist/health.js +13 -1
- package/dist/hooks/cursor-hooks-mapper.d.ts +3 -3
- package/dist/hooks/cursor-hooks-mapper.js +1 -1
- package/dist/hooks/cursor-hooks-reader.d.ts +2 -0
- package/dist/hooks/cursor-hooks-reader.js +10 -3
- package/dist/install/cursor.d.ts +34 -0
- package/dist/install/cursor.js +193 -0
- package/dist/install/index.d.ts +6 -4
- package/dist/install/index.js +6 -1
- package/dist/lib/client.d.ts +7 -0
- package/dist/lib/client.js +17 -0
- package/dist/lib/config.js +7 -2
- package/dist/lib/project-resolver.d.ts +5 -4
- package/dist/lib/project-resolver.js +57 -11
- package/dist/lib/repo-path-safety.d.ts +35 -0
- package/dist/lib/repo-path-safety.js +102 -0
- package/dist/lib/spawn-arg-safety.d.ts +25 -0
- package/dist/lib/spawn-arg-safety.js +49 -0
- package/dist/lib/transport-security.d.ts +1 -0
- package/dist/lib/transport-security.js +1 -1
- package/dist/readers/claude.d.ts +54 -6
- package/dist/readers/claude.js +154 -2
- package/dist/readers/cursor.d.ts +10 -7
- package/dist/readers/cursor.js +113 -17
- package/dist/risk-scanner.js +7 -0
- package/dist/server.d.ts +20 -3
- package/dist/server.js +101 -67
- package/dist/state.js +7 -2
- package/dist/sync.d.ts +13 -2
- package/dist/sync.js +86 -58
- package/package.json +6 -2
package/README.md
CHANGED
|
@@ -98,7 +98,7 @@ After `init` succeeds:
|
|
|
98
98
|
|
|
99
99
|
1. **Restart Claude Code** — it discovers the new MCP server on the next launch.
|
|
100
100
|
2. Open Claude Code; confirm `/mcp` lists **aixle-insights**.
|
|
101
|
-
3. During a Claude session invoke the **`
|
|
101
|
+
3. During a Claude session invoke the **`aixle_insights_status`** MCP tool (or `aixle-insights health` from the shell) to see connectivity + last sync metadata.
|
|
102
102
|
|
|
103
103
|
## Multi-org
|
|
104
104
|
|
|
@@ -113,6 +113,14 @@ npx -y @aixle/insights init \
|
|
|
113
113
|
|
|
114
114
|
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
115
|
|
|
116
|
+
## First run and backfill
|
|
117
|
+
|
|
118
|
+
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.
|
|
119
|
+
|
|
120
|
+
The moment `init` succeeds, this package's per-credential state starts from empty (no watermark, no dedupe checkpoint). That means **the very first sync after `init` treats "everything currently on disk" as new** — Claude transcript JSONL files and Cursor SQLite history alike — and sends all of it, not just activity from that point forward. There is no separate "backfill mode" to opt into; it is simply what an empty watermark means the first time `run --once` or the background sync loop executes.
|
|
121
|
+
|
|
122
|
+
If the MCP has been installed but never connected, `aixle-insights health` (or the `status` MCP tool) reports `needs_init: true` with a human-readable `onboarding_message` explaining exactly this — install-but-uninitialized is not a state you need to worry about losing data in.
|
|
123
|
+
|
|
116
124
|
## Commands
|
|
117
125
|
|
|
118
126
|
| Command | What it does |
|
|
@@ -158,20 +166,23 @@ Optional `~/.aixle-insights/config.json` accepts Cursor line-cost overrides (per
|
|
|
158
166
|
|
|
159
167
|
`@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.
|
|
160
168
|
|
|
161
|
-
###
|
|
169
|
+
### Three gates
|
|
162
170
|
|
|
163
171
|
| Gate | Where it fires | What it checks |
|
|
164
172
|
|---|---|---|
|
|
165
173
|
| CLI `--host` gate | `runInit()` at the top of `aixle-insights init`, before any network call to Keycloak | The `--host` value the user typed |
|
|
166
174
|
| Post-exchange `ingestHost` gate | `auth/flow.ts`, immediately after the OIDC-for-ingest-token exchange returns, before persisting credentials to the keychain | The `ingestHost` returned by the server, in case it differs from `--host` |
|
|
175
|
+
| Runtime send/lookup gate | `lib/client.ts`'s `postEvent` and `lib/project-resolver.ts`'s `lookupProjectByRemote`, immediately before every ingest POST and project-attribution GET | The `host` loaded from stored credentials, on **every** sync cycle — not just at `init` |
|
|
176
|
+
|
|
177
|
+
All three gates use the same pure utility, `evaluateTransportSecurity()` in `src/lib/transport-security.ts`. The first two rejecting aborts `init` with exit code 1 and a single-line error naming the offending host. The third rejecting drops that send/lookup (logged via `console.error`, no retry) without aborting the whole sync cycle — a single tampered credential shouldn't crash background sync, it should just refuse to leak the token.
|
|
167
178
|
|
|
168
|
-
|
|
179
|
+
The runtime gate exists because `init`'s two gates only run once, at login time. If `~/.aixle-insights/credentials.json` is edited afterward (by hand, by malware, or by disk corruption) to point at a plaintext `http://` remote, nothing previously re-checked the scheme before every subsequent sync sent the bearer token — DB90DV-539 closed that gap.
|
|
169
180
|
|
|
170
|
-
### `--insecure` (init-only)
|
|
181
|
+
### `--insecure` (init-only, consent persists to runtime)
|
|
171
182
|
|
|
172
|
-
`aixle-insights init --insecure --host http://<remote>` downgrades
|
|
183
|
+
`aixle-insights init --insecure --host http://<remote>` downgrades the first two gates from "reject" to "warn + continue." It is intended only for trusted non-production test endpoints (e.g. a self-hosted staging on a private network without a TLS cert).
|
|
173
184
|
|
|
174
|
-
`--insecure` is rejected on the `run` subcommand by design — the long-running MCP server should never run insecurely
|
|
185
|
+
`--insecure` is rejected on the `run` subcommand by design — the long-running MCP server should never run insecurely, and since `run` is normally spawned non-interactively (by Claude Code, via `~/.claude.json`), there is no ergonomic way to pass a flag to it per-cycle anyway. Instead, when `init --insecure` is used, that consent is recorded as `insecureHttpAllowed: true` on the stored credential (`StoredCredentials.insecureHttpAllowed` in `auth/credentials.ts`) and every later `run` reads it back — so a legitimately-approved trusted HTTP endpoint keeps syncing normally. A `credentials.json` that has an `http://` remote host **without** this flag set (e.g. because someone hand-edited the file after the fact, bypassing `init` entirely) is rejected by the runtime gate on every send.
|
|
175
186
|
|
|
176
187
|
### What is NOT gated
|
|
177
188
|
|
|
@@ -184,9 +195,25 @@ The Keycloak issuer URL (`--keycloak-url` / `KEYCLOAK_ISSUER`) is **not** TLS-ga
|
|
|
184
195
|
## State + credentials
|
|
185
196
|
|
|
186
197
|
- **App home directory**: `~/.aixle-insights/` (override with `AIXLE_INSIGHTS_HOME`).
|
|
187
|
-
- **Credentials**: OS keychain
|
|
198
|
+
- **Credentials**: the OS keychain is the source of truth. On read, the keychain is consulted **first**; the file is only a fallback when the keychain is unavailable, empty, or explicitly disabled (`DB90_MCP_DISABLE_KEYTAR`). On write, credentials go to the keychain and the file is removed when the keychain write succeeds.
|
|
188
199
|
- **State files**: `state-<hostname>-<token-hash>.json` per credential, plus `state.lock` advisory lock, `mcp.log` rotating diagnostic log, optional `hooks-queue.ndjson`.
|
|
189
200
|
|
|
201
|
+
### Credential storage by OS
|
|
202
|
+
|
|
203
|
+
The secure store is the platform's native keychain, accessed via `keytar` (service `aixle-insights`):
|
|
204
|
+
|
|
205
|
+
| OS | Secure store | Availability | Fallback file protection |
|
|
206
|
+
| --- | --- | --- | --- |
|
|
207
|
+
| **macOS** | Keychain | reliably present | `credentials.json` written `chmod 0600` |
|
|
208
|
+
| **Windows** | Credential Manager | reliably present | `credentials.json` best-effort locked via `icacls` (Node `chmod` cannot set NTFS ACLs) |
|
|
209
|
+
| **Linux** | Secret Service (libsecret / GNOME Keyring / KWallet) | **often absent** on headless servers, minimal Docker images, and CI — no D-Bus secret service | `credentials.json` written `chmod 0600` |
|
|
210
|
+
|
|
211
|
+
Notes:
|
|
212
|
+
|
|
213
|
+
- **Windows**: don't set `DB90_MCP_DISABLE_KEYTAR` — Credential Manager is reliably present, and the plaintext fallback file cannot be locked down as tightly as the keychain. The `icacls` hardening is best-effort defense-in-depth.
|
|
214
|
+
- **Linux**: when no Secret Service is running (common in headless/CI/container contexts), the tool degrades to the `chmod 0600` fallback file **by design** — this is the one environment where the file path is routinely exercised, and POSIX permissions protect it there.
|
|
215
|
+
- A stale `credentials.json` sitting alongside a populated keychain entry is logged as drift (`credentials_file_shadowed_by_keychain` in `mcp.log`) and ignored in favour of the keychain.
|
|
216
|
+
|
|
190
217
|
The internal state-file shape is implementation-detail; don't depend on it from outside this package.
|
|
191
218
|
|
|
192
219
|
## Diagnostics
|
|
@@ -196,13 +223,14 @@ aixle-insights health # connectivity + last sync metadata
|
|
|
196
223
|
aixle-insights verify-hooks # JSON: hooks installed + queue depth
|
|
197
224
|
```
|
|
198
225
|
|
|
199
|
-
`mcp.log` (rotates at 5 MiB to `mcp.log.1`) under the app home directory captures operational events. Inside Claude Code, the **`
|
|
226
|
+
`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`.
|
|
200
227
|
|
|
201
228
|
## Troubleshooting
|
|
202
229
|
|
|
203
230
|
| Symptom | Most likely cause | Fix |
|
|
204
231
|
|---|---|---|
|
|
205
232
|
| `Error: DB90 API host <name> uses remote plaintext HTTP.` | You passed `--host http://<remote>` without `--insecure`. | Use `https://...`, or add `--insecure` if you know the endpoint is trusted and non-production. |
|
|
233
|
+
| `Blocked event send — DB90 ingest host <name> uses remote plaintext HTTP.` (or `Blocked project lookup — ...`) in `mcp.log` / console during `run` | `credentials.json` (or the keychain entry) has an `http://` remote `host` without a recorded `--insecure` consent — most likely because it was edited outside of `init`. | Re-run `aixle-insights init --host https://... ` (or `init --insecure --host http://...` if the endpoint is genuinely trusted non-prod) to re-establish credentials with an explicit, recorded decision. |
|
|
206
234
|
| `Auth failed: fetch failed` during `init` | The `--keycloak-url` host doesn't resolve (NXDOMAIN), is behind a VPN, or the TLS cert is bad. | Verify with `curl -sS https://<host>/realms/<realm>/.well-known/openid-configuration`. For DB90 staging, the canonical Keycloak URL is embedded in the SPA — `curl https://<APP_HOST> \| grep keycloakUrl` extracts the current value. |
|
|
207
235
|
| `Failed to post event: HTTP 401 Unauthorized` repeated for every turn | Your saved ingest token has been rotated, revoked, or invalidated by a server redeploy. The ingest token is distinct from the Keycloak access token that `health` reports as `authenticated: true`. | Reset the keychain entry and re-run `init`: `security delete-generic-password -s "aixle-insights" -a "aixle-insights-ingest-credential"` then `rm -f ~/.aixle-insights/credentials.json` then `aixle-insights init --host ... --keycloak-url ...`. State files are **not** deleted, so already-sent sessions stay deduped. |
|
|
208
236
|
| `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. |
|
|
@@ -246,7 +274,8 @@ After the script reports success: **quit and reopen Claude Code / Cursor** so ea
|
|
|
246
274
|
|
|
247
275
|
## Requirements
|
|
248
276
|
|
|
249
|
-
- Node.js
|
|
277
|
+
- Node.js >= 20.19.0 — matches `engines.node` in `package.json`, and
|
|
278
|
+
`src/test/supply-chain-contract.test.ts` fails if the two drift apart.
|
|
250
279
|
- macOS / Linux / Windows. On Windows, the package writes a `cmd /c npx …` wrapper in `~/.claude.json` so Claude Code can spawn the MCP server reliably.
|
|
251
280
|
- `better-sqlite3` is a native module. After a Node upgrade, if SQLite reads start failing, rebuild it from the tools workspace:
|
|
252
281
|
|
|
@@ -5,6 +5,12 @@ export interface StoredCredentials {
|
|
|
5
5
|
host: string;
|
|
6
6
|
organizationId?: string;
|
|
7
7
|
accounts: Partial<Record<TelemetryToolId, string>>;
|
|
8
|
+
/**
|
|
9
|
+
* Set only when the user explicitly passed `init --insecure` for this host.
|
|
10
|
+
* There is no `run --insecure` flag (by design — see README § Security), so
|
|
11
|
+
* runtime sync honors this persisted consent instead of re-prompting.
|
|
12
|
+
*/
|
|
13
|
+
insecureHttpAllowed?: boolean;
|
|
8
14
|
}
|
|
9
15
|
export declare const KEYTAR_SERVICE = "aixle-insights";
|
|
10
16
|
/** Returns true when at least one tool has a non-empty token. */
|
|
@@ -12,7 +18,7 @@ export declare function credentialsHaveAnyToken(creds: StoredCredentials): boole
|
|
|
12
18
|
export declare function pickProjectLookupToken(creds: StoredCredentials): string | null;
|
|
13
19
|
/** Read credentials from disk only (tests / fallback). */
|
|
14
20
|
export declare function loadCredentialsFromFileOnly(appDir?: string): StoredCredentials | null;
|
|
15
|
-
/** Prefer OS keychain when keytar works;
|
|
21
|
+
/** Prefer the OS keychain when keytar works; fall back to `credentials.json`. */
|
|
16
22
|
export declare function loadCredentials(appDir?: string): Promise<StoredCredentials | null>;
|
|
17
23
|
/**
|
|
18
24
|
* Persist multi-tool ingest tokens for one host namespace.
|
package/dist/auth/credentials.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
1
2
|
import { chmodSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { userInfo } from "node:os";
|
|
2
4
|
import { join } from "node:path";
|
|
3
5
|
import { getAppDir } from "../state.js";
|
|
6
|
+
import { mcpLog } from "../log.js";
|
|
4
7
|
export const KEYTAR_SERVICE = "aixle-insights";
|
|
5
8
|
const KEYTAR_ACCOUNT = "aixle-insights-ingest-credential";
|
|
6
9
|
function credentialsPath(appDir) {
|
|
@@ -45,6 +48,7 @@ function normalizeLoadedCredentials(raw) {
|
|
|
45
48
|
host,
|
|
46
49
|
accounts: out,
|
|
47
50
|
organizationId: typeof org === "string" ? org : undefined,
|
|
51
|
+
...(o.insecureHttpAllowed === true ? { insecureHttpAllowed: true } : {}),
|
|
48
52
|
};
|
|
49
53
|
}
|
|
50
54
|
const token = o.token;
|
|
@@ -62,22 +66,33 @@ export function loadCredentialsFromFileOnly(appDir = getAppDir()) {
|
|
|
62
66
|
const raw = JSON.parse(readFileSync(filePath, "utf-8"));
|
|
63
67
|
return normalizeLoadedCredentials(raw);
|
|
64
68
|
}
|
|
65
|
-
catch {
|
|
69
|
+
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);
|
|
66
72
|
return null;
|
|
67
73
|
}
|
|
68
74
|
}
|
|
69
75
|
async function tryKeytarGet() {
|
|
70
76
|
if (keytarDisabled())
|
|
71
77
|
return null;
|
|
78
|
+
let raw;
|
|
72
79
|
try {
|
|
73
80
|
const keytar = await import("keytar");
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
81
|
+
raw = await keytar.default.getPassword(KEYTAR_SERVICE, KEYTAR_ACCOUNT);
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
// Keytar unavailable (native module missing/unbuilt, no Secret Service, etc.) — silent fallback to file, same as tryKeytarSet.
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
if (!raw)
|
|
88
|
+
return null;
|
|
89
|
+
try {
|
|
77
90
|
const parsed = JSON.parse(raw);
|
|
78
91
|
return normalizeLoadedCredentials(parsed);
|
|
79
92
|
}
|
|
80
|
-
catch {
|
|
93
|
+
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);
|
|
81
96
|
return null;
|
|
82
97
|
}
|
|
83
98
|
}
|
|
@@ -100,8 +115,32 @@ async function tryKeytarDelete() {
|
|
|
100
115
|
const keytar = await import("keytar");
|
|
101
116
|
await keytar.default.deletePassword(KEYTAR_SERVICE, KEYTAR_ACCOUNT);
|
|
102
117
|
}
|
|
118
|
+
catch (err) {
|
|
119
|
+
// Non-fatal, but a stale keychain entry can mislead later loads — record it.
|
|
120
|
+
mcpLog.warn("keytar_delete_failed", { keytarService: KEYTAR_SERVICE, error: err instanceof Error ? err.message : String(err) }, false);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Best-effort NTFS ACL lock-down for the fallback credentials file on Windows.
|
|
125
|
+
*
|
|
126
|
+
* Node's file `mode` / `chmodSync` only toggle the read-only bit on Windows — they do NOT
|
|
127
|
+
* map to NTFS ACLs — so the POSIX `0o600` we set on other platforms has no real effect there.
|
|
128
|
+
* We shell out to the built-in `icacls` to drop inherited ACEs and grant only the current user
|
|
129
|
+
* full control. Mirrors the POSIX `chmod` best-effort: any failure is swallowed (the user-profile
|
|
130
|
+
* directory already blocks cross-user reads, and Windows Credential Manager — the preferred store
|
|
131
|
+
* via keytar — makes this fallback file rare in the first place).
|
|
132
|
+
*/
|
|
133
|
+
function restrictWindowsAclBestEffort(filePath) {
|
|
134
|
+
try {
|
|
135
|
+
const user = userInfo().username;
|
|
136
|
+
if (!user)
|
|
137
|
+
return;
|
|
138
|
+
execFileSync("icacls", [filePath, "/inheritance:r", "/grant:r", `${user}:F`], {
|
|
139
|
+
stdio: "ignore",
|
|
140
|
+
});
|
|
141
|
+
}
|
|
103
142
|
catch {
|
|
104
|
-
//
|
|
143
|
+
// best-effort, non-fatal — same posture as the POSIX chmod
|
|
105
144
|
}
|
|
106
145
|
}
|
|
107
146
|
function writeFileCredential(appDir, creds) {
|
|
@@ -112,12 +151,16 @@ function writeFileCredential(appDir, creds) {
|
|
|
112
151
|
host: creds.host,
|
|
113
152
|
organizationId: creds.organizationId,
|
|
114
153
|
accounts: { ...creds.accounts },
|
|
154
|
+
...(creds.insecureHttpAllowed ? { insecureHttpAllowed: true } : {}),
|
|
115
155
|
};
|
|
116
156
|
writeFileSync(filePath, `${JSON.stringify(body, null, 2)}\n`, {
|
|
117
157
|
encoding: "utf-8",
|
|
118
158
|
mode: 0o600,
|
|
119
159
|
});
|
|
120
|
-
if (process.platform
|
|
160
|
+
if (process.platform === "win32") {
|
|
161
|
+
restrictWindowsAclBestEffort(filePath);
|
|
162
|
+
}
|
|
163
|
+
else {
|
|
121
164
|
try {
|
|
122
165
|
chmodSync(filePath, 0o600);
|
|
123
166
|
}
|
|
@@ -132,19 +175,27 @@ function removeFileCredential(appDir) {
|
|
|
132
175
|
try {
|
|
133
176
|
unlinkSync(filePath);
|
|
134
177
|
}
|
|
135
|
-
catch {
|
|
136
|
-
//
|
|
178
|
+
catch (err) {
|
|
179
|
+
// A stale plaintext credentials.json left behind here is exactly the drift loadCredentials warns about.
|
|
180
|
+
mcpLog.warn("credentials_file_remove_failed", { path: filePath, error: err instanceof Error ? err.message : String(err) }, false);
|
|
137
181
|
}
|
|
138
182
|
}
|
|
139
183
|
}
|
|
140
|
-
/** Prefer OS keychain when keytar works;
|
|
184
|
+
/** Prefer the OS keychain when keytar works; fall back to `credentials.json`. */
|
|
141
185
|
export async function loadCredentials(appDir = getAppDir()) {
|
|
186
|
+
const fromKeytar = await tryKeytarGet();
|
|
187
|
+
if (fromKeytar) {
|
|
188
|
+
// Keychain is the source of truth; a lingering file is stale and silently shadowed it before this fix.
|
|
189
|
+
// Warn-only (log file, no stderr mirror): drift is recorded without spamming the background sync loop,
|
|
190
|
+
// and the next saveStoredCredentials removes the file. We do not mutate disk on a read.
|
|
191
|
+
if (existsSync(credentialsPath(appDir))) {
|
|
192
|
+
mcpLog.warn("credentials_file_shadowed_by_keychain", { path: credentialsPath(appDir), keytarService: KEYTAR_SERVICE }, false);
|
|
193
|
+
}
|
|
194
|
+
return fromKeytar;
|
|
195
|
+
}
|
|
142
196
|
const fromFile = loadCredentialsFromFileOnly(appDir);
|
|
143
197
|
if (fromFile)
|
|
144
198
|
return fromFile;
|
|
145
|
-
const fromKeytar = await tryKeytarGet();
|
|
146
|
-
if (fromKeytar)
|
|
147
|
-
return fromKeytar;
|
|
148
199
|
return null;
|
|
149
200
|
}
|
|
150
201
|
/**
|
|
@@ -154,7 +205,13 @@ export async function saveStoredCredentials(creds, appDir = getAppDir()) {
|
|
|
154
205
|
if (!credentialsHaveAnyToken(creds)) {
|
|
155
206
|
throw new Error("saveStoredCredentials requires at least one account token");
|
|
156
207
|
}
|
|
157
|
-
const payload = JSON.stringify({
|
|
208
|
+
const payload = JSON.stringify({
|
|
209
|
+
version: 2,
|
|
210
|
+
host: creds.host,
|
|
211
|
+
organizationId: creds.organizationId,
|
|
212
|
+
accounts: { ...creds.accounts },
|
|
213
|
+
...(creds.insecureHttpAllowed ? { insecureHttpAllowed: true } : {}),
|
|
214
|
+
});
|
|
158
215
|
const keytarOk = await tryKeytarSet(payload);
|
|
159
216
|
if (keytarOk) {
|
|
160
217
|
removeFileCredential(appDir);
|
package/dist/auth/exchange.d.ts
CHANGED
|
@@ -11,7 +11,7 @@ export interface ExchangeResult {
|
|
|
11
11
|
}
|
|
12
12
|
type ExchangeToolId = "claude_code" | "cursor";
|
|
13
13
|
export declare function exchangeIngestToken(params: {
|
|
14
|
-
|
|
14
|
+
apiHost: string;
|
|
15
15
|
keycloakAccessToken: string;
|
|
16
16
|
/** Legacy single-tool mint. Omit when tools is provided. */
|
|
17
17
|
toolName?: ExchangeToolId;
|
package/dist/auth/exchange.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export async function exchangeIngestToken(params) {
|
|
2
2
|
const fetchFn = params.fetchImpl ?? fetch;
|
|
3
3
|
const requestedTools = params.tools?.length ? [...params.tools] : params.toolName ? [params.toolName] : [];
|
|
4
|
-
const base = params.
|
|
4
|
+
const base = params.apiHost.replace(/\/$/, "");
|
|
5
5
|
const url = `${base}/api/v1/integrations/mcp/exchange`;
|
|
6
6
|
const body = {};
|
|
7
7
|
if (params.tools?.length) {
|
package/dist/auth/flow.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { defaultKeycloakClientId, defaultKeycloakIssuer } from "./keycloak.js";
|
|
2
2
|
import type { TelemetryToolId } from "./credentials.js";
|
|
3
3
|
export interface LoginAndPersistOptions {
|
|
4
|
-
|
|
4
|
+
apiHost: string;
|
|
5
5
|
keycloakIssuer: string;
|
|
6
6
|
/** @deprecated Prefer `tools`; kept for callers that mint a single ingest account. */
|
|
7
7
|
toolName?: string;
|
|
@@ -12,6 +12,8 @@ export interface LoginAndPersistOptions {
|
|
|
12
12
|
clientId?: string;
|
|
13
13
|
appDir?: string;
|
|
14
14
|
allowInsecureHttp?: boolean;
|
|
15
|
+
/** Skip the "already authenticated" short-circuit and re-run the device flow. */
|
|
16
|
+
force?: boolean;
|
|
15
17
|
onSecurityWarning?: (message: string) => void;
|
|
16
18
|
onVisitInstructions?: (verification_uri: string, user_code: string) => void;
|
|
17
19
|
fetchImpl?: typeof fetch;
|
|
@@ -19,6 +21,11 @@ export interface LoginAndPersistOptions {
|
|
|
19
21
|
export declare function loginAndPersistCredentials(opts: LoginAndPersistOptions): Promise<{
|
|
20
22
|
ok: true;
|
|
21
23
|
organizationId: string;
|
|
24
|
+
} | {
|
|
25
|
+
ok: true;
|
|
26
|
+
alreadyAuthenticated: true;
|
|
27
|
+
host: string;
|
|
28
|
+
organizationId?: string;
|
|
22
29
|
} | {
|
|
23
30
|
ok: false;
|
|
24
31
|
error: string;
|
package/dist/auth/flow.js
CHANGED
|
@@ -3,15 +3,36 @@ import { defaultKeycloakClientId, defaultKeycloakIssuer, obtainKeycloakAccessTok
|
|
|
3
3
|
import { loadCredentials, saveStoredCredentials } from "./credentials.js";
|
|
4
4
|
import { getAppDir } from "../state.js";
|
|
5
5
|
import { evaluateTransportSecurity } from "../lib/transport-security.js";
|
|
6
|
+
function normalizeHostForComparison(host) {
|
|
7
|
+
return host.replace(/\/$/, "");
|
|
8
|
+
}
|
|
9
|
+
function credentialsCoverRequestedTools(creds, requestedTools) {
|
|
10
|
+
return requestedTools.every((tool) => {
|
|
11
|
+
const token = creds.accounts[tool];
|
|
12
|
+
return typeof token === "string" && token.length > 0;
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
function credentialsMatchRequestedScope(creds, opts, requestedTools) {
|
|
16
|
+
if (normalizeHostForComparison(creds.host) !== normalizeHostForComparison(opts.apiHost))
|
|
17
|
+
return false;
|
|
18
|
+
if (opts.exchangeOrganizationId && creds.organizationId !== opts.exchangeOrganizationId)
|
|
19
|
+
return false;
|
|
20
|
+
return credentialsCoverRequestedTools(creds, requestedTools);
|
|
21
|
+
}
|
|
6
22
|
export async function loginAndPersistCredentials(opts) {
|
|
23
|
+
const appDir = opts.appDir ?? getAppDir();
|
|
24
|
+
const requestedTools = opts.tools ?? ((opts.toolName ?? "claude_code") === "cursor" ? ["cursor"] : ["claude_code"]);
|
|
25
|
+
if (!opts.force) {
|
|
26
|
+
const existing = await loadCredentials(appDir);
|
|
27
|
+
if (existing && credentialsMatchRequestedScope(existing, opts, requestedTools)) {
|
|
28
|
+
return { ok: true, alreadyAuthenticated: true, host: existing.host, organizationId: existing.organizationId };
|
|
29
|
+
}
|
|
30
|
+
}
|
|
7
31
|
const issuer = opts.keycloakIssuer.trim();
|
|
8
32
|
if (!issuer) {
|
|
9
33
|
return { ok: false, error: "Keycloak issuer is empty; set KEYCLOAK_ISSUER or pass --keycloak-url." };
|
|
10
34
|
}
|
|
11
35
|
const clientId = opts.clientId?.trim() || defaultKeycloakClientId();
|
|
12
|
-
const appDir = opts.appDir ?? getAppDir();
|
|
13
|
-
const requestedTools = opts.tools ??
|
|
14
|
-
((opts.toolName ?? "claude_code") === "cursor" ? ["cursor"] : ["claude_code"]);
|
|
15
36
|
let accessToken;
|
|
16
37
|
try {
|
|
17
38
|
accessToken = await obtainKeycloakAccessTokenViaDeviceFlow({
|
|
@@ -28,7 +49,7 @@ export async function loginAndPersistCredentials(opts) {
|
|
|
28
49
|
try {
|
|
29
50
|
if (requestedTools.length > 1) {
|
|
30
51
|
exchanged = await exchangeIngestToken({
|
|
31
|
-
|
|
52
|
+
apiHost: opts.apiHost,
|
|
32
53
|
keycloakAccessToken: accessToken,
|
|
33
54
|
tools: requestedTools,
|
|
34
55
|
deviceLabel: opts.deviceLabel,
|
|
@@ -38,7 +59,7 @@ export async function loginAndPersistCredentials(opts) {
|
|
|
38
59
|
}
|
|
39
60
|
else {
|
|
40
61
|
exchanged = await exchangeIngestToken({
|
|
41
|
-
|
|
62
|
+
apiHost: opts.apiHost,
|
|
42
63
|
keycloakAccessToken: accessToken,
|
|
43
64
|
toolName: requestedTools[0],
|
|
44
65
|
deviceLabel: opts.deviceLabel,
|
|
@@ -61,6 +82,7 @@ export async function loginAndPersistCredentials(opts) {
|
|
|
61
82
|
host: exchanged.ingestHost,
|
|
62
83
|
organizationId: exchanged.organizationId,
|
|
63
84
|
accounts: existing?.host === exchanged.ingestHost ? { ...existing.accounts } : {},
|
|
85
|
+
...(opts.allowInsecureHttp === true ? { insecureHttpAllowed: true } : {}),
|
|
64
86
|
};
|
|
65
87
|
for (const tid of ["claude_code", "cursor"]) {
|
|
66
88
|
const acc = exchanged.accounts[tid];
|
package/dist/auth/keycloak.d.ts
CHANGED
|
@@ -31,5 +31,5 @@ export declare function obtainKeycloakAccessTokenViaDeviceFlow(params: {
|
|
|
31
31
|
onInstructions?: (verification_uri: string, user_code: string) => void;
|
|
32
32
|
fetchImpl?: typeof fetch;
|
|
33
33
|
}): Promise<string>;
|
|
34
|
-
export declare function defaultKeycloakIssuer(): string;
|
|
34
|
+
export declare function defaultKeycloakIssuer(ingestHost?: string): string;
|
|
35
35
|
export declare function defaultKeycloakClientId(): string;
|
package/dist/auth/keycloak.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* RFC 8628 OAuth 2.0 Device Authorization Grant against Keycloak OIDC endpoints.
|
|
3
3
|
*/
|
|
4
4
|
import { createHash, randomBytes } from "node:crypto";
|
|
5
|
+
import { isLoopbackHost } from "../lib/transport-security.js";
|
|
5
6
|
function normalizeIssuer(issuer) {
|
|
6
7
|
return issuer.replace(/\/$/, "");
|
|
7
8
|
}
|
|
@@ -154,13 +155,31 @@ export async function obtainKeycloakAccessTokenViaDeviceFlow(params) {
|
|
|
154
155
|
fetchImpl: params.fetchImpl,
|
|
155
156
|
});
|
|
156
157
|
}
|
|
157
|
-
export function defaultKeycloakIssuer() {
|
|
158
|
+
export function defaultKeycloakIssuer(ingestHost) {
|
|
158
159
|
const fromEnv = process.env["DB90_KEYCLOAK_ISSUER"]?.trim() ||
|
|
159
160
|
process.env["KEYCLOAK_ISSUER"]?.trim();
|
|
160
161
|
if (fromEnv)
|
|
161
162
|
return fromEnv.replace(/\/$/, "");
|
|
162
163
|
const useLocalDefault = ["1", "true", "yes"].includes(process.env["DB90_MCP_USE_LOCAL_KEYCLOAK_DEFAULT"]?.toLowerCase() ?? "");
|
|
163
164
|
if (useLocalDefault || process.env["NODE_ENV"] === "development") {
|
|
165
|
+
if (ingestHost) {
|
|
166
|
+
let hostname;
|
|
167
|
+
try {
|
|
168
|
+
hostname = new URL(ingestHost).hostname;
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
hostname = ingestHost;
|
|
172
|
+
}
|
|
173
|
+
if (!isLoopbackHost(hostname)) {
|
|
174
|
+
const reason = useLocalDefault
|
|
175
|
+
? `DB90_MCP_USE_LOCAL_KEYCLOAK_DEFAULT=${process.env["DB90_MCP_USE_LOCAL_KEYCLOAK_DEFAULT"]}`
|
|
176
|
+
: `NODE_ENV=development`;
|
|
177
|
+
console.error(`[aixle-insights] Warning: ${reason} would redirect Keycloak authentication to ` +
|
|
178
|
+
`http://localhost:8080, but the ingest host "${ingestHost}" is not localhost. ` +
|
|
179
|
+
`Ignoring the local Keycloak default. Set DB90_KEYCLOAK_ISSUER explicitly.`);
|
|
180
|
+
return "";
|
|
181
|
+
}
|
|
182
|
+
}
|
|
164
183
|
return "http://localhost:8080/realms/db90";
|
|
165
184
|
}
|
|
166
185
|
return "";
|
package/dist/cli.d.ts
CHANGED
|
@@ -7,8 +7,9 @@ import { migrateLegacyState, getAppDir } from "./state.js";
|
|
|
7
7
|
import { syncTelemetryTools } from "./sync.js";
|
|
8
8
|
import { mergePricing } from "./pricing.js";
|
|
9
9
|
import { type InstallClaudeUserMcpOptions, type InstallResult } from "./install/claude.js";
|
|
10
|
+
import { type InstallCursorUserMcpOptions } from "./install/cursor.js";
|
|
10
11
|
export interface Args {
|
|
11
|
-
command: "init" | "health" | "run" | "help" | "uninstall-hooks" | "verify-hooks";
|
|
12
|
+
command: "init" | "health" | "run" | "help" | "uninstall-hooks" | "verify-hooks" | "uninstall-cursor-mcp";
|
|
12
13
|
help: boolean;
|
|
13
14
|
once: boolean;
|
|
14
15
|
/** With `run --once`: ignore Cursor watermarks and commit hash dedupe. */
|
|
@@ -39,12 +40,13 @@ interface InitDeps {
|
|
|
39
40
|
defaultKeycloakIssuer: typeof defaultKeycloakIssuer;
|
|
40
41
|
getAppDir: typeof getAppDir;
|
|
41
42
|
installClaudeUserMcp: (options: InstallClaudeUserMcpOptions) => InstallResult;
|
|
43
|
+
installCursorUserMcp: (options: InstallCursorUserMcpOptions) => InstallResult;
|
|
42
44
|
log: (message: string) => void;
|
|
43
45
|
error: (message: string) => void;
|
|
44
46
|
}
|
|
45
47
|
/** Matches DB90 Rails `McpController` UUID check for `X-Organization-ID` (RFC 4122 variant). */
|
|
46
|
-
export declare const
|
|
47
|
-
export declare function
|
|
48
|
+
export declare const ORGANIZATION_UUID_PATTERN: RegExp;
|
|
49
|
+
export declare function isValidOrganizationUuid(value: string): boolean;
|
|
48
50
|
export declare function parseArgs(argv: string[]): Args;
|
|
49
51
|
export declare function runInit(cliArgs: Args, deps?: Partial<InitDeps>): Promise<number>;
|
|
50
52
|
export declare function runOnce(deps?: Partial<RunOnceDeps>, options?: {
|