@aixle/insights 0.2.0 → 0.2.1
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 +91 -15
- package/dist/auth/credentials.d.ts +7 -1
- package/dist/auth/credentials.js +90 -17
- package/dist/auth/flow.js +1 -0
- package/dist/cli.js +1 -1
- package/dist/collect-cursor-payloads.d.ts +1 -0
- package/dist/collect-cursor-payloads.js +8 -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 +1 -1
- package/dist/health.js +1 -1
- package/dist/hooks/cursor-hooks-reader.d.ts +2 -0
- package/dist/hooks/cursor-hooks-reader.js +10 -3
- package/dist/lib/client.d.ts +7 -0
- package/dist/lib/client.js +17 -0
- package/dist/lib/config.d.ts +2 -2
- package/dist/lib/config.js +32 -19
- package/dist/lib/parse-error.d.ts +21 -0
- package/dist/lib/parse-error.js +25 -0
- 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/readers/claude.d.ts +59 -6
- package/dist/readers/claude.js +184 -3
- package/dist/readers/cursor.d.ts +6 -3
- package/dist/readers/cursor.js +125 -18
- package/dist/risk-scanner.js +7 -0
- package/dist/server.d.ts +3 -3
- package/dist/server.js +77 -66
- package/dist/state.js +42 -34
- package/dist/sync.d.ts +11 -0
- package/dist/sync.js +93 -60
- package/package.json +7 -3
package/dist/risk-scanner.js
CHANGED
|
@@ -12,6 +12,10 @@ const CATEGORIES = {
|
|
|
12
12
|
weight: 3,
|
|
13
13
|
patterns: [
|
|
14
14
|
/\b\d{3}-\d{2}-\d{4}\b/g, // SSN
|
|
15
|
+
/* eslint-disable-next-line security/detect-unsafe-regex -- Flagged for
|
|
16
|
+
`{3}` inside `(?:…)?`. Every branch is a fixed-length digit run
|
|
17
|
+
anchored by \b, so the match is bounded and cannot backtrack
|
|
18
|
+
super-linearly. */
|
|
15
19
|
/\b(?:4[0-9]{12}(?:[0-9]{3})?|5[1-5][0-9]{14}|3[47][0-9]{13}|3(?:0[0-5]|[68][0-9])[0-9]{11}|6(?:011|5[0-9]{2})[0-9]{12}|(?:2131|1800|35\d{3})\d{11})\b/g, // Credit card
|
|
16
20
|
],
|
|
17
21
|
},
|
|
@@ -19,6 +23,9 @@ const CATEGORIES = {
|
|
|
19
23
|
weight: 1,
|
|
20
24
|
patterns: [
|
|
21
25
|
/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/gi, // Email
|
|
26
|
+
/* eslint-disable-next-line security/detect-unsafe-regex -- Flagged for
|
|
27
|
+
`?` nested in `?`. All quantified groups are fixed-length digit or
|
|
28
|
+
separator classes anchored by \b; matching is bounded. */
|
|
22
29
|
/\b(?:\+?1[-.\s]?)?(?:\([0-9]{3}\)|[0-9]{3})[-.\s]?[0-9]{3}[-.\s]?[0-9]{4}\b/g, // Phone
|
|
23
30
|
],
|
|
24
31
|
},
|
package/dist/server.d.ts
CHANGED
|
@@ -7,8 +7,8 @@ export declare const SYNC_NOW_INPUT_SCHEMA: z.ZodObject<{
|
|
|
7
7
|
cursor: "cursor";
|
|
8
8
|
}>>>;
|
|
9
9
|
}, z.core.$strict>;
|
|
10
|
-
/** Structured status for `
|
|
11
|
-
export declare function
|
|
10
|
+
/** Structured status for `aixle_insights_status` — tolerates missing/malformed credentials and state. */
|
|
11
|
+
export declare function buildAixleInsightsStatusPayload(): Promise<Record<string, unknown>>;
|
|
12
12
|
/** In-process MCP server instance (stdio not attached). */
|
|
13
|
-
export declare function
|
|
13
|
+
export declare function createAixleInsightsMcpServer(): McpServer;
|
|
14
14
|
export declare function startServer(): Promise<void>;
|
package/dist/server.js
CHANGED
|
@@ -27,6 +27,12 @@ export const SYNC_NOW_INPUT_SCHEMA = z
|
|
|
27
27
|
}
|
|
28
28
|
})
|
|
29
29
|
.strict();
|
|
30
|
+
const AUTHENTICATE_INPUT_SCHEMA = z.object({
|
|
31
|
+
keycloakUrl: z.string().optional(),
|
|
32
|
+
clientId: z.string().optional(),
|
|
33
|
+
});
|
|
34
|
+
/** DB90DV-569: `db90_*` names are deprecated aliases kept for one release for existing callers. */
|
|
35
|
+
const DEPRECATED_ALIAS_NOTE = "(Deprecated — use `{name}` instead; kept temporarily for backward compatibility, see DB90DV-569.) ";
|
|
30
36
|
function jsonContent(value) {
|
|
31
37
|
return {
|
|
32
38
|
content: [
|
|
@@ -70,22 +76,22 @@ async function getProjectResolutionForSync(creds) {
|
|
|
70
76
|
if (cachedProjectResolution?.key === cacheKey) {
|
|
71
77
|
return cachedProjectResolution.value;
|
|
72
78
|
}
|
|
73
|
-
const result = await resolveProjectId(undefined, undefined, creds.host, token, false);
|
|
79
|
+
const result = await resolveProjectId(undefined, undefined, creds.host, token, false, creds.insecureHttpAllowed === true);
|
|
74
80
|
mcpLog.info("project_attribution_resolved", { project_id: result.projectId, source: result.source }, false);
|
|
75
81
|
if (result.source !== "none") {
|
|
76
82
|
cachedProjectResolution = { key: cacheKey, value: result };
|
|
77
83
|
}
|
|
78
84
|
return result;
|
|
79
85
|
}
|
|
80
|
-
/** Structured status for `
|
|
81
|
-
export async function
|
|
86
|
+
/** Structured status for `aixle_insights_status` — tolerates missing/malformed credentials and state. */
|
|
87
|
+
export async function buildAixleInsightsStatusPayload() {
|
|
82
88
|
const snapshot = await buildHealthSnapshot();
|
|
83
89
|
return healthSnapshotToStatusPayload(snapshot);
|
|
84
90
|
}
|
|
85
91
|
async function executeSync(parsed) {
|
|
86
92
|
const creds = await loadCredentials();
|
|
87
93
|
if (!creds || !credentialsHaveAnyToken(creds)) {
|
|
88
|
-
mcpLog.warn("credential_validation_failed", { source: "
|
|
94
|
+
mcpLog.warn("credential_validation_failed", { source: "aixle_insights_sync_now", reason: "missing_credentials" }, false);
|
|
89
95
|
return { ok: false, error: "missing_credentials" };
|
|
90
96
|
}
|
|
91
97
|
migrateAllLegacyState(creds);
|
|
@@ -104,79 +110,84 @@ async function executeSync(parsed) {
|
|
|
104
110
|
});
|
|
105
111
|
return { ok: syncResultOk(result), result };
|
|
106
112
|
}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
}, async (input) => {
|
|
118
|
-
try {
|
|
119
|
-
const parsed = SYNC_NOW_INPUT_SCHEMA.parse(input ?? {});
|
|
120
|
-
return jsonContent(await executeSync(parsed));
|
|
121
|
-
}
|
|
122
|
-
catch (err) {
|
|
123
|
-
if (err instanceof z.ZodError) {
|
|
124
|
-
return jsonContent({
|
|
125
|
-
ok: false,
|
|
126
|
-
error: "validation_error",
|
|
127
|
-
details: err.flatten(),
|
|
128
|
-
});
|
|
129
|
-
}
|
|
113
|
+
async function statusHandler() {
|
|
114
|
+
return jsonContent(await buildAixleInsightsStatusPayload());
|
|
115
|
+
}
|
|
116
|
+
async function syncNowHandler(input) {
|
|
117
|
+
try {
|
|
118
|
+
const parsed = SYNC_NOW_INPUT_SCHEMA.parse(input ?? {});
|
|
119
|
+
return jsonContent(await executeSync(parsed));
|
|
120
|
+
}
|
|
121
|
+
catch (err) {
|
|
122
|
+
if (err instanceof z.ZodError) {
|
|
130
123
|
return jsonContent({
|
|
131
124
|
ok: false,
|
|
132
|
-
error:
|
|
133
|
-
|
|
134
|
-
}
|
|
135
|
-
});
|
|
136
|
-
server.registerTool("db90_authenticate", {
|
|
137
|
-
description: "Starts Keycloak device login and returns the visit URL/code for the user. Use aixle-insights init for the full terminal flow that saves credentials.",
|
|
138
|
-
inputSchema: z.object({
|
|
139
|
-
keycloakUrl: z.string().optional(),
|
|
140
|
-
clientId: z.string().optional(),
|
|
141
|
-
}),
|
|
142
|
-
}, async (input) => {
|
|
143
|
-
try {
|
|
144
|
-
const args = input;
|
|
145
|
-
const kc = (args.keycloakUrl?.trim() || defaultKeycloakIssuer()).trim();
|
|
146
|
-
if (!kc) {
|
|
147
|
-
return jsonContent({
|
|
148
|
-
ok: false,
|
|
149
|
-
error: "keycloakUrl or KEYCLOAK_ISSUER / DB90_KEYCLOAK_ISSUER is required",
|
|
150
|
-
});
|
|
151
|
-
}
|
|
152
|
-
const clientId = args.clientId?.trim() || defaultKeycloakClientId();
|
|
153
|
-
const device = await startDeviceAuthorization({
|
|
154
|
-
issuer: kc,
|
|
155
|
-
clientId,
|
|
156
|
-
});
|
|
157
|
-
return jsonContent({
|
|
158
|
-
ok: true,
|
|
159
|
-
verificationUri: device.verification_uri,
|
|
160
|
-
verificationUriComplete: device.verification_uri_complete ?? null,
|
|
161
|
-
userCode: device.user_code,
|
|
162
|
-
expiresIn: device.expires_in,
|
|
163
|
-
interval: device.interval ?? 5,
|
|
164
|
-
issuer: kc,
|
|
165
|
-
clientId,
|
|
166
|
-
message: `Visit ${device.verification_uri} and enter code ${device.user_code}`,
|
|
125
|
+
error: "validation_error",
|
|
126
|
+
details: err.flatten(),
|
|
167
127
|
});
|
|
168
128
|
}
|
|
169
|
-
|
|
129
|
+
return jsonContent({
|
|
130
|
+
ok: false,
|
|
131
|
+
error: err instanceof Error ? err.message : String(err),
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
async function authenticateHandler(args) {
|
|
136
|
+
try {
|
|
137
|
+
const kc = (args.keycloakUrl?.trim() || defaultKeycloakIssuer()).trim();
|
|
138
|
+
if (!kc) {
|
|
170
139
|
return jsonContent({
|
|
171
140
|
ok: false,
|
|
172
|
-
error:
|
|
141
|
+
error: "keycloakUrl or KEYCLOAK_ISSUER / DB90_KEYCLOAK_ISSUER is required",
|
|
173
142
|
});
|
|
174
143
|
}
|
|
175
|
-
|
|
144
|
+
const clientId = args.clientId?.trim() || defaultKeycloakClientId();
|
|
145
|
+
const device = await startDeviceAuthorization({
|
|
146
|
+
issuer: kc,
|
|
147
|
+
clientId,
|
|
148
|
+
});
|
|
149
|
+
return jsonContent({
|
|
150
|
+
ok: true,
|
|
151
|
+
verificationUri: device.verification_uri,
|
|
152
|
+
verificationUriComplete: device.verification_uri_complete ?? null,
|
|
153
|
+
userCode: device.user_code,
|
|
154
|
+
expiresIn: device.expires_in,
|
|
155
|
+
interval: device.interval ?? 5,
|
|
156
|
+
issuer: kc,
|
|
157
|
+
clientId,
|
|
158
|
+
message: `Visit ${device.verification_uri} and enter code ${device.user_code}`,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
catch (err) {
|
|
162
|
+
return jsonContent({
|
|
163
|
+
ok: false,
|
|
164
|
+
error: err instanceof Error ? err.message : String(err),
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
/** In-process MCP server instance (stdio not attached). */
|
|
169
|
+
export function createAixleInsightsMcpServer() {
|
|
170
|
+
const server = new McpServer({ name: SERVER_NAME, version: SERVER_VERSION }, { capabilities: { tools: {} } });
|
|
171
|
+
const statusDescription = "Returns Aixle Insights MCP connectivity and last sync metadata from disk (credentials + state). No arguments.";
|
|
172
|
+
server.registerTool("aixle_insights_status", { description: statusDescription }, statusHandler);
|
|
173
|
+
server.registerTool("db90_status", { description: DEPRECATED_ALIAS_NOTE.replace("{name}", "aixle_insights_status") + statusDescription }, statusHandler);
|
|
174
|
+
const syncNowDescription = "Runs one DB90 ingest sync cycle for enabled tools immediately (matches background cadence). " +
|
|
175
|
+
"Optional `tools` subset filter: omit to sync every tool credential you have authenticated (Claude transcripts + Cursor telemetry).";
|
|
176
|
+
server.registerTool("aixle_insights_sync_now", { description: syncNowDescription, inputSchema: SYNC_NOW_INPUT_SCHEMA }, syncNowHandler);
|
|
177
|
+
server.registerTool("db90_sync_now", {
|
|
178
|
+
description: DEPRECATED_ALIAS_NOTE.replace("{name}", "aixle_insights_sync_now") + syncNowDescription,
|
|
179
|
+
inputSchema: SYNC_NOW_INPUT_SCHEMA,
|
|
180
|
+
}, syncNowHandler);
|
|
181
|
+
const authenticateDescription = "Starts Keycloak device login and returns the visit URL/code for the user. Use aixle-insights init for the full terminal flow that saves credentials.";
|
|
182
|
+
server.registerTool("aixle_insights_authenticate", { description: authenticateDescription, inputSchema: AUTHENTICATE_INPUT_SCHEMA }, authenticateHandler);
|
|
183
|
+
server.registerTool("db90_authenticate", {
|
|
184
|
+
description: DEPRECATED_ALIAS_NOTE.replace("{name}", "aixle_insights_authenticate") + authenticateDescription,
|
|
185
|
+
inputSchema: AUTHENTICATE_INPUT_SCHEMA,
|
|
186
|
+
}, authenticateHandler);
|
|
176
187
|
return server;
|
|
177
188
|
}
|
|
178
189
|
export async function startServer() {
|
|
179
|
-
const server =
|
|
190
|
+
const server = createAixleInsightsMcpServer();
|
|
180
191
|
const transport = new StdioServerTransport();
|
|
181
192
|
await server.connect(transport);
|
|
182
193
|
let intervalId;
|
package/dist/state.js
CHANGED
|
@@ -2,6 +2,8 @@ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { createHash, randomBytes } from "node:crypto";
|
|
5
|
+
import { mcpLog } from "./log.js";
|
|
6
|
+
import { describeReadFailure } from "./lib/parse-error.js";
|
|
5
7
|
export function getAppDir() {
|
|
6
8
|
const override = process.env["AIXLE_INSIGHTS_HOME"]?.trim();
|
|
7
9
|
if (override && override.length > 0)
|
|
@@ -89,44 +91,50 @@ export function migrateLegacyState(dir, host, token) {
|
|
|
89
91
|
}
|
|
90
92
|
export function readState(dir, host, token) {
|
|
91
93
|
const filePath = stateFilePath(dir ?? getAppDir(), host, token);
|
|
94
|
+
let parsed;
|
|
92
95
|
try {
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
: undefined;
|
|
102
|
-
const out = {
|
|
103
|
-
version: p.version,
|
|
104
|
-
sessions: p.sessions,
|
|
105
|
-
};
|
|
106
|
-
if (lastRecentCommitHashes !== undefined) {
|
|
107
|
-
out.lastRecentCommitHashes = lastRecentCommitHashes;
|
|
108
|
-
}
|
|
109
|
-
if ("mcp_operator" in p) {
|
|
110
|
-
const mcp = parseMcpOperator(p.mcp_operator);
|
|
111
|
-
if (mcp)
|
|
112
|
-
out.mcp_operator = mcp;
|
|
113
|
-
}
|
|
114
|
-
if ("rate_limited_until" in p) {
|
|
115
|
-
if (typeof p.rate_limited_until === "string") {
|
|
116
|
-
out.rate_limited_until = p.rate_limited_until;
|
|
117
|
-
}
|
|
118
|
-
else if (p.rate_limited_until === null) {
|
|
119
|
-
out.rate_limited_until = null;
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
return out;
|
|
123
|
-
}
|
|
96
|
+
parsed = JSON.parse(readFileSync(filePath, "utf-8"));
|
|
97
|
+
}
|
|
98
|
+
catch (err) {
|
|
99
|
+
const code = err?.code;
|
|
100
|
+
if (code !== "ENOENT") {
|
|
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);
|
|
124
104
|
}
|
|
105
|
+
return { version: 1, sessions: {} };
|
|
125
106
|
}
|
|
126
|
-
|
|
127
|
-
|
|
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;
|
|
135
|
+
}
|
|
128
136
|
}
|
|
129
|
-
return
|
|
137
|
+
return out;
|
|
130
138
|
}
|
|
131
139
|
/** Atomic write: write to a temp file then rename over the target. */
|
|
132
140
|
export function writeState(state, dir, host, token) {
|
package/dist/sync.d.ts
CHANGED
|
@@ -24,6 +24,8 @@ export interface SyncOptions {
|
|
|
24
24
|
verbose: boolean;
|
|
25
25
|
projectId: string | null;
|
|
26
26
|
projectIdSource?: ProjectResolution["source"];
|
|
27
|
+
/** Mirrors StoredCredentials.insecureHttpAllowed — set when `init --insecure` was used for this host. */
|
|
28
|
+
allowInsecureHttp?: boolean;
|
|
27
29
|
pricing: PricingTable;
|
|
28
30
|
appDir?: string;
|
|
29
31
|
transcriptBaseDirs?: string[];
|
|
@@ -65,6 +67,15 @@ export declare function getSyncTelemetry(): {
|
|
|
65
67
|
lastResult: SyncResult | null;
|
|
66
68
|
recentErrors: string[];
|
|
67
69
|
};
|
|
70
|
+
/**
|
|
71
|
+
* The repo path for a Cursor payload, normalized and safe to hand to project
|
|
72
|
+
* resolution. Returns an absolute, `..`-collapsed path or undefined.
|
|
73
|
+
*
|
|
74
|
+
* `workspace_folder` comes from the workspace's own `workspace.json` and
|
|
75
|
+
* `workspace` from a composer `uri.fsPath` or the `state.vscdb` path — all
|
|
76
|
+
* untrusted. When the preferred field is unusable we fall through to the other
|
|
77
|
+
* rather than giving up. See DB90DV-547.
|
|
78
|
+
*/
|
|
68
79
|
export declare function cursorRepoPathFromPayload(payload: CursorDb90Payload): string | undefined;
|
|
69
80
|
/**
|
|
70
81
|
* Parallel multi-tool cycle under the global advisory ingest lock (`state.lock`).
|
package/dist/sync.js
CHANGED
|
@@ -11,6 +11,7 @@ import { postEvent, postEvents } from "./client.js";
|
|
|
11
11
|
import { getCostWarning } from "./pricing.js";
|
|
12
12
|
import { acquireSyncLock } from "./lock.js";
|
|
13
13
|
import { getGitRemoteForPath, lookupProjectByRemote, } from "./lib/index.js";
|
|
14
|
+
import { isRepoPathWithinRoot, normalizeRepoPathCandidate, } from "./lib/repo-path-safety.js";
|
|
14
15
|
import { mcpLog } from "./log.js";
|
|
15
16
|
/** Prefix for Claude Code session keys in shared MCP state. */
|
|
16
17
|
export const CLAUDE_STATE_PREFIX = "claude_code:";
|
|
@@ -102,9 +103,9 @@ function explicitProjectId(projectId, projectIdSource) {
|
|
|
102
103
|
? projectId
|
|
103
104
|
: undefined;
|
|
104
105
|
}
|
|
105
|
-
async function resolveProjectIdForRepoPathCached(repoPath, host, token, verbose, cache) {
|
|
106
|
-
const normalized = repoPath
|
|
107
|
-
if (
|
|
106
|
+
async function resolveProjectIdForRepoPathCached(repoPath, host, token, verbose, cache, allowInsecureHttp = false) {
|
|
107
|
+
const normalized = normalizeRepoPathCandidate(repoPath);
|
|
108
|
+
if (normalized === null)
|
|
108
109
|
return null;
|
|
109
110
|
if (cache.has(normalized))
|
|
110
111
|
return cache.get(normalized) ?? null;
|
|
@@ -113,25 +114,35 @@ async function resolveProjectIdForRepoPathCached(repoPath, host, token, verbose,
|
|
|
113
114
|
cache.set(normalized, null);
|
|
114
115
|
return null;
|
|
115
116
|
}
|
|
116
|
-
const result = await lookupProjectByRemote(canonicalRemote, host, token, verbose);
|
|
117
|
+
const result = await lookupProjectByRemote(canonicalRemote, host, token, verbose, allowInsecureHttp);
|
|
117
118
|
const projectId = result && typeof result === "object" && "project_id" in result ? result.project_id : null;
|
|
118
119
|
cache.set(normalized, projectId);
|
|
119
120
|
return projectId;
|
|
120
121
|
}
|
|
122
|
+
/**
|
|
123
|
+
* The repo path for a Cursor payload, normalized and safe to hand to project
|
|
124
|
+
* resolution. Returns an absolute, `..`-collapsed path or undefined.
|
|
125
|
+
*
|
|
126
|
+
* `workspace_folder` comes from the workspace's own `workspace.json` and
|
|
127
|
+
* `workspace` from a composer `uri.fsPath` or the `state.vscdb` path — all
|
|
128
|
+
* untrusted. When the preferred field is unusable we fall through to the other
|
|
129
|
+
* rather than giving up. See DB90DV-547.
|
|
130
|
+
*/
|
|
121
131
|
export function cursorRepoPathFromPayload(payload) {
|
|
122
132
|
const metadata = payload.metadata;
|
|
123
133
|
if (!metadata)
|
|
124
134
|
return undefined;
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
135
|
+
for (const candidate of [metadata.workspace_folder, metadata.workspace]) {
|
|
136
|
+
if (typeof candidate !== "string")
|
|
137
|
+
continue;
|
|
138
|
+
const normalized = normalizeRepoPathCandidate(candidate);
|
|
139
|
+
if (normalized !== null)
|
|
140
|
+
return normalized;
|
|
130
141
|
}
|
|
131
142
|
return undefined;
|
|
132
143
|
}
|
|
133
144
|
async function runClaudeSlice(options) {
|
|
134
|
-
const { token, host, dryRun, verbose, projectId, pricing } = options;
|
|
145
|
+
const { token, host, dryRun, verbose, projectId, pricing, allowInsecureHttp = false } = options;
|
|
135
146
|
const appDir = options.appDir ?? getAppDir();
|
|
136
147
|
const backoffKey = credentialStateKey(host, token);
|
|
137
148
|
const errors = [];
|
|
@@ -194,21 +205,27 @@ async function runClaudeSlice(options) {
|
|
|
194
205
|
mcpLog.info("sync_noise_skip", { tool: "claude_code", reason: "local_command_noise" }, false);
|
|
195
206
|
continue;
|
|
196
207
|
}
|
|
197
|
-
// When scopeDir is set, skip turns from other directories.
|
|
208
|
+
// When scopeDir is set, skip turns from other directories. `turn.cwd` is an
|
|
209
|
+
// arbitrary string from a transcript JSONL, so a plain prefix match would
|
|
210
|
+
// accept `<scopeDir>/../../elsewhere` (DB90DV-547).
|
|
198
211
|
if (scopeDir) {
|
|
199
|
-
const cwd = turn.cwd
|
|
200
|
-
const inScope = cwd && (cwd
|
|
212
|
+
const cwd = normalizeRepoPathCandidate(turn.cwd);
|
|
213
|
+
const inScope = cwd !== null && isRepoPathWithinRoot(cwd, scopeDir);
|
|
201
214
|
if (!inScope) {
|
|
202
215
|
totalSkipped++;
|
|
203
216
|
if (verbose) {
|
|
204
|
-
console.log(`[verbose] Skipping Claude turn ${turn.turnId} — cwd=${cwd ?? "(none)"} not under scopeDir=${scopeDir}`);
|
|
217
|
+
console.log(`[verbose] Skipping Claude turn ${turn.turnId} — cwd=${turn.cwd ?? "(none)"} not under scopeDir=${scopeDir}`);
|
|
205
218
|
}
|
|
206
219
|
continue;
|
|
207
220
|
}
|
|
208
221
|
}
|
|
209
222
|
const sKey = sessionStateKey(turn.turnId);
|
|
210
223
|
const known = state.sessions[sKey];
|
|
211
|
-
|
|
224
|
+
// A turn keeps its turnId as Claude appends more tool_use blocks to it, so a
|
|
225
|
+
// plain "already known → skip" would drop derivatives appended after an earlier
|
|
226
|
+
// mid-turn sync. Skip only when the content fingerprint is unchanged; otherwise
|
|
227
|
+
// re-emit so the newly appended tool uses are sent (DB90DV-259).
|
|
228
|
+
if (known && known.contentHash && known.contentHash === turn.contentHash) {
|
|
212
229
|
totalSkipped++;
|
|
213
230
|
if (verbose) {
|
|
214
231
|
console.log(`[verbose] Skipping already-synced Claude turn ${turn.turnId}`);
|
|
@@ -222,13 +239,16 @@ async function runClaudeSlice(options) {
|
|
|
222
239
|
// effectively one network call per unique cwd per sync.
|
|
223
240
|
const resolvedProjectId = scopeDir
|
|
224
241
|
? (projectId ??
|
|
225
|
-
(await resolveProjectIdForRepoPathCached(turn.cwd, host, token, verbose, projectLookupCache)) ??
|
|
242
|
+
(await resolveProjectIdForRepoPathCached(turn.cwd, host, token, verbose, projectLookupCache, allowInsecureHttp)) ??
|
|
226
243
|
undefined)
|
|
227
244
|
: (explicitProject ??
|
|
228
|
-
(await resolveProjectIdForRepoPathCached(turn.cwd, host, token, verbose, projectLookupCache)) ??
|
|
245
|
+
(await resolveProjectIdForRepoPathCached(turn.cwd, host, token, verbose, projectLookupCache, allowInsecureHttp)) ??
|
|
229
246
|
undefined);
|
|
230
|
-
const
|
|
231
|
-
if (
|
|
247
|
+
const payloads = mapClaudeTranscriptTurn(turn, { projectId: resolvedProjectId, pricing });
|
|
248
|
+
if (!payloads?.length)
|
|
249
|
+
continue;
|
|
250
|
+
const parentPayload = payloads[0];
|
|
251
|
+
if (verbose && parentPayload?.cost_usd === null) {
|
|
232
252
|
if (!turn.model) {
|
|
233
253
|
if (turn.tokensIn > 0 || turn.tokensOut > 0) {
|
|
234
254
|
console.warn(`[warn] Claude turn ${turn.turnId} has usage but no model — cost_usd will be null`);
|
|
@@ -241,45 +261,54 @@ async function runClaudeSlice(options) {
|
|
|
241
261
|
}
|
|
242
262
|
}
|
|
243
263
|
if (dryRun) {
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
264
|
+
for (const payload of payloads) {
|
|
265
|
+
console.log(`[dry-run] Would send Claude ${payload.event_type} ${payload.metadata.session_id}:`);
|
|
266
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
267
|
+
}
|
|
268
|
+
totalSent += payloads.length;
|
|
247
269
|
continue;
|
|
248
270
|
}
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
mcpLog.error("sync_ingest_final_failure", { tool: "claude_code", session_id: turn.turnId }, true);
|
|
279
|
-
if (shouldStopForBackoff) {
|
|
271
|
+
let allOk = true;
|
|
272
|
+
for (const payload of payloads) {
|
|
273
|
+
if (verbose) {
|
|
274
|
+
console.log(`[verbose] Sending Claude ${payload.event_type} ${payload.metadata.session_id}`);
|
|
275
|
+
}
|
|
276
|
+
const ok = await postEvent(payload, host, token, {
|
|
277
|
+
allowInsecureHttp,
|
|
278
|
+
on429: (retryAfter, quotaExceeded) => {
|
|
279
|
+
const currentBackoff = backoffUntilByCredential.get(backoffKey);
|
|
280
|
+
const nextBackoff = new Date(Math.max(currentBackoff?.getTime() ?? 0, Date.now() + retryAfter * 1000));
|
|
281
|
+
backoffUntilByCredential.set(backoffKey, nextBackoff);
|
|
282
|
+
shouldStopForBackoff = true;
|
|
283
|
+
const reason = quotaExceeded ? "Monthly quota exceeded" : "Rate limited";
|
|
284
|
+
mcpLog.warn("sync_rate_limit_pause", {
|
|
285
|
+
tool: "claude_code",
|
|
286
|
+
retry_until: nextBackoff.toISOString(),
|
|
287
|
+
quota_exceeded: quotaExceeded,
|
|
288
|
+
}, true);
|
|
289
|
+
console.warn(`[aixle-insights] ${reason}. Pausing until ${nextBackoff.toISOString()}.`);
|
|
290
|
+
// Persist backoff to state so it survives process restarts
|
|
291
|
+
state = { ...state, rate_limited_until: nextBackoff.toISOString() };
|
|
292
|
+
writeState(state, appDir, host, token);
|
|
293
|
+
},
|
|
294
|
+
});
|
|
295
|
+
if (!ok) {
|
|
296
|
+
allOk = false;
|
|
297
|
+
totalFailed++;
|
|
298
|
+
errors.push(`Failed to post Claude ${payload.event_type} for turn ${turn.turnId}`);
|
|
299
|
+
mcpLog.error("sync_ingest_final_failure", { tool: "claude_code", session_id: turn.turnId }, true);
|
|
280
300
|
break;
|
|
281
301
|
}
|
|
282
302
|
}
|
|
303
|
+
if (allOk) {
|
|
304
|
+
totalSent += payloads.length;
|
|
305
|
+
state = markSessionSent(state, sKey, turn.fileSize, turn.contentHash);
|
|
306
|
+
writeState(state, appDir, host, token);
|
|
307
|
+
}
|
|
308
|
+
else if (shouldStopForBackoff) {
|
|
309
|
+
break;
|
|
310
|
+
}
|
|
311
|
+
// Non-backoff failure: continue to next turn (failed turn will retry next sync)
|
|
283
312
|
}
|
|
284
313
|
const result = { sent: totalSent, failed: totalFailed, skipped: totalSkipped };
|
|
285
314
|
const currentBackoff = backoffUntilByCredential.get(backoffKey);
|
|
@@ -290,7 +319,7 @@ async function runClaudeSlice(options) {
|
|
|
290
319
|
return result;
|
|
291
320
|
}
|
|
292
321
|
async function runCursorSlice(params) {
|
|
293
|
-
const { token, host, dryRun, verbose, projectId, projectIdSource, projectLookupToken, appDir, cursorBaseDir, cursorTranscriptProjectDirs, scopeDir, fullScan = false, cursorPricing = resolveCursorPricing(undefined, appDir), } = params;
|
|
322
|
+
const { token, host, dryRun, verbose, projectId, projectIdSource, projectLookupToken, allowInsecureHttp = false, appDir, cursorBaseDir, cursorTranscriptProjectDirs, scopeDir, fullScan = false, cursorPricing = resolveCursorPricing(undefined, appDir), } = params;
|
|
294
323
|
const backoffKey = credentialStateKey(host, token);
|
|
295
324
|
// Read state first so we can restore a persisted rate-limit backoff from a prior process.
|
|
296
325
|
const stateBefore = readState(appDir, host, token);
|
|
@@ -331,6 +360,7 @@ async function runCursorSlice(params) {
|
|
|
331
360
|
host,
|
|
332
361
|
token,
|
|
333
362
|
projectLookupToken: lookupToken,
|
|
363
|
+
allowInsecureHttp,
|
|
334
364
|
verbose,
|
|
335
365
|
cursorBaseDir,
|
|
336
366
|
cursorTranscriptProjectDirs,
|
|
@@ -341,11 +371,11 @@ async function runCursorSlice(params) {
|
|
|
341
371
|
const inScope = [];
|
|
342
372
|
for (const payload of group.payloads) {
|
|
343
373
|
const ws = cursorRepoPathFromPayload(payload);
|
|
344
|
-
if (ws && (ws
|
|
374
|
+
if (ws && isRepoPathWithinRoot(ws, scopeDir)) {
|
|
345
375
|
// Same fallback as Claude: when the pre-resolved projectId is null, do a
|
|
346
376
|
// per-payload lookup from the payload's workspace. Cache dedupes by path.
|
|
347
377
|
const resolved = projectId ??
|
|
348
|
-
(await resolveProjectIdForRepoPathCached(ws, host, lookupToken, verbose, projectLookupCache));
|
|
378
|
+
(await resolveProjectIdForRepoPathCached(ws, host, lookupToken, verbose, projectLookupCache, allowInsecureHttp));
|
|
349
379
|
if (resolved)
|
|
350
380
|
payload.project_id = resolved;
|
|
351
381
|
else
|
|
@@ -365,7 +395,7 @@ async function runCursorSlice(params) {
|
|
|
365
395
|
continue;
|
|
366
396
|
for (const payload of group.payloads) {
|
|
367
397
|
const repoPath = cursorRepoPathFromPayload(payload);
|
|
368
|
-
const resolvedProjectId = await resolveProjectIdForRepoPathCached(repoPath, host, lookupToken, verbose, projectLookupCache);
|
|
398
|
+
const resolvedProjectId = await resolveProjectIdForRepoPathCached(repoPath, host, lookupToken, verbose, projectLookupCache, allowInsecureHttp);
|
|
369
399
|
if (resolvedProjectId)
|
|
370
400
|
payload.project_id = resolvedProjectId;
|
|
371
401
|
else
|
|
@@ -438,7 +468,7 @@ async function runCursorSlice(params) {
|
|
|
438
468
|
totalSkipped++;
|
|
439
469
|
continue;
|
|
440
470
|
}
|
|
441
|
-
const ok = await postEvent(payload, host, token, { on429 });
|
|
471
|
+
const ok = await postEvent(payload, host, token, { on429, allowInsecureHttp });
|
|
442
472
|
if (ok) {
|
|
443
473
|
totalSent++;
|
|
444
474
|
const turnId = payload.metadata.session_id;
|
|
@@ -455,7 +485,7 @@ async function runCursorSlice(params) {
|
|
|
455
485
|
}
|
|
456
486
|
continue;
|
|
457
487
|
}
|
|
458
|
-
const batchResult = await postEvents(group.payloads, host, token, { on429 });
|
|
488
|
+
const batchResult = await postEvents(group.payloads, host, token, { on429, allowInsecureHttp });
|
|
459
489
|
totalSent += batchResult.sent;
|
|
460
490
|
totalFailed += batchResult.failed;
|
|
461
491
|
if (batchResult.failed > 0) {
|
|
@@ -501,10 +531,11 @@ async function runCursorSlice(params) {
|
|
|
501
531
|
state: stateMut,
|
|
502
532
|
host,
|
|
503
533
|
token,
|
|
534
|
+
allowInsecureHttp,
|
|
504
535
|
on429,
|
|
505
536
|
resolveProjectId: explicitProject
|
|
506
537
|
? undefined
|
|
507
|
-
: (workspace) => resolveProjectIdForRepoPathCached(workspace, host, lookupToken, verbose, projectLookupCache),
|
|
538
|
+
: (workspace) => resolveProjectIdForRepoPathCached(workspace, host, lookupToken, verbose, projectLookupCache, allowInsecureHttp),
|
|
508
539
|
verbose,
|
|
509
540
|
});
|
|
510
541
|
totalSent += hooksResult.sent;
|
|
@@ -590,6 +621,7 @@ export async function syncTelemetryTools(options) {
|
|
|
590
621
|
dryRun,
|
|
591
622
|
verbose,
|
|
592
623
|
projectId,
|
|
624
|
+
allowInsecureHttp: credentials.insecureHttpAllowed === true,
|
|
593
625
|
pricing,
|
|
594
626
|
appDir,
|
|
595
627
|
transcriptBaseDirs: options.transcriptBaseDirs,
|
|
@@ -606,6 +638,7 @@ export async function syncTelemetryTools(options) {
|
|
|
606
638
|
projectId,
|
|
607
639
|
projectIdSource,
|
|
608
640
|
projectLookupToken,
|
|
641
|
+
allowInsecureHttp: credentials.insecureHttpAllowed === true,
|
|
609
642
|
appDir,
|
|
610
643
|
cursorBaseDir: options.cursorBaseDir,
|
|
611
644
|
cursorTranscriptProjectDirs: options.cursorTranscriptProjectDirs,
|