diff --git a/packages/gittensory-mcp/bin/gittensory-mcp.js b/packages/gittensory-mcp/bin/gittensory-mcp.js index bba9e658d1..2094c404c3 100755 --- a/packages/gittensory-mcp/bin/gittensory-mcp.js +++ b/packages/gittensory-mcp/bin/gittensory-mcp.js @@ -1,4 +1,5 @@ #!/usr/bin/env node +import { createHash } from "node:crypto"; import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { delimiter, dirname, join } from "node:path"; @@ -1272,12 +1273,21 @@ function isCacheableDecisionPack(payload, login) { return payload?.status === "ready" && typeof payload.login === "string" && payload.login.toLowerCase() === login.toLowerCase(); } -function decisionPackCachePath(login) { - const key = Buffer.from(`${apiUrl}\0${currentApiVersion}\0${login.toLowerCase()}`).toString("base64url"); +function decisionPackAuthCacheKey() { + const token = getApiToken(); + if (!token) return null; + return createHash("sha256").update(token).digest("base64url"); +} + +function decisionPackCachePath(login, authCacheKey = decisionPackAuthCacheKey()) { + if (!authCacheKey) return null; + const key = Buffer.from(`${apiUrl}\0${currentApiVersion}\0${login.toLowerCase()}\0${authCacheKey}`).toString("base64url"); return join(decisionPackCacheDir, `${key}.json`); } function writeDecisionPackCache(login, payload) { + const authCacheKey = decisionPackAuthCacheKey(); + if (!authCacheKey) return { status: "skipped", reason: "missing_auth" }; const cachedAt = new Date().toISOString(); const sanitizedPayload = sanitizeDecisionPackForCache(payload); const entry = { @@ -1285,6 +1295,7 @@ function writeDecisionPackCache(login, payload) { apiVersion: typeof payload.apiVersion === "string" ? payload.apiVersion : currentApiVersion, packageVersion, apiUrl, + authCacheKey, login: login.toLowerCase(), cachedAt, payload: sanitizedPayload, @@ -1293,30 +1304,35 @@ function writeDecisionPackCache(login, payload) { const serialized = `${JSON.stringify(entry, null, 2)}\n`; if (Buffer.byteLength(serialized, "utf8") > decisionPackCacheMaxBytes) return { status: "skipped", reason: "too_large" }; mkdirSync(decisionPackCacheDir, { recursive: true, mode: 0o700 }); - writeFileSync(decisionPackCachePath(login), serialized, { mode: 0o600 }); + const path = decisionPackCachePath(login, authCacheKey); + if (!path) return { status: "skipped", reason: "missing_auth" }; + writeFileSync(path, serialized, { mode: 0o600 }); pruneDecisionPackCache(); return { status: "stored", cachedAt }; } function readDecisionPackCache(login) { - const path = decisionPackCachePath(login); - if (!existsSync(path)) return null; + const authCacheKey = decisionPackAuthCacheKey(); + const path = decisionPackCachePath(login, authCacheKey); + if (!path || !existsSync(path)) return null; try { const entry = JSON.parse(readFileSync(path, "utf8")); - if (!isCompatibleDecisionPackCacheEntry(entry, login)) return null; + if (!isCompatibleDecisionPackCacheEntry(entry, login, authCacheKey)) return null; return entry; } catch { return null; } } -function isCompatibleDecisionPackCacheEntry(entry, login) { +function isCompatibleDecisionPackCacheEntry(entry, login, authCacheKey = decisionPackAuthCacheKey()) { return ( entry && typeof entry === "object" && entry.schemaVersion === decisionPackCacheSchemaVersion && entry.apiVersion === currentApiVersion && entry.apiUrl === apiUrl && + typeof entry.authCacheKey === "string" && + entry.authCacheKey === authCacheKey && typeof entry.cachedAt === "string" && typeof entry.login === "string" && entry.login.toLowerCase() === login.toLowerCase() && diff --git a/test/unit/mcp-cli.test.ts b/test/unit/mcp-cli.test.ts index 227bd2008e..2adee019b1 100644 --- a/test/unit/mcp-cli.test.ts +++ b/test/unit/mcp-cli.test.ts @@ -362,6 +362,8 @@ describe("gittensory-mcp CLI", () => { expect(online).toMatchObject({ status: "ready", source: "snapshot" }); const cacheText = readDecisionPackCacheText(tempDir); + expect(cacheText).toMatch(/"authCacheKey":/); + expect(cacheText).not.toContain("session-token"); expect(cacheText).not.toMatch(/must stay local|wallet-value|hotkey-value|\/tmp\/source/i); await new Promise((resolve) => server?.close(() => resolve())); @@ -404,7 +406,7 @@ describe("gittensory-mcp CLI", () => { GITTENSORY_API_URL: url, GITTENSORY_TOKEN: "session-token", GITTENSORY_CONFIG_DIR: tempDir, - GITTENSORY_API_TIMEOUT_MS: "100", + GITTENSORY_API_TIMEOUT_MS: "1000", }; await runAsync(["decision-pack", "--login", "JSONbored", "--json"], env); @@ -415,7 +417,7 @@ describe("gittensory-mcp CLI", () => { await new Promise((resolve) => server?.close(() => resolve())); server = null; - await expect(runAsync(["decision-pack", "--login", "JSONbored", "--json"], env)).rejects.toThrow(/fetch failed|ECONNREFUSED|aborted/i); + await expect(runAsync(["decision-pack", "--login", "JSONbored", "--json"], env)).rejects.toThrow(/fetch failed|ECONNREFUSED|AbortError|aborted/i); const cleared = JSON.parse(run(["cache", "clear", "--json"], env)) as { status: string; removed: number }; expect(cleared).toMatchObject({ status: "cleared", removed: 1 }); @@ -423,6 +425,27 @@ describe("gittensory-mcp CLI", () => { expect(cacheStatus.entries).toBe(0); }); + it("does not use stale decision-pack cache created by a different local token", async () => { + tempDir = mkdtempSync(join(tmpdir(), "gittensory-cli-")); + const fixtureOptions: { decisionPackStatus?: number } = {}; + const url = await startFixtureServer(fixtureOptions); + const env = { + GITTENSORY_API_URL: url, + GITTENSORY_TOKEN: "session-token", + GITTENSORY_CONFIG_DIR: tempDir, + }; + + await runAsync(["decision-pack", "--login", "JSONbored", "--json"], env); + fixtureOptions.decisionPackStatus = 429; + + await expect( + runAsync(["decision-pack", "--login", "JSONbored", "--json"], { + ...env, + GITTENSORY_TOKEN: "different-session-token", + }), + ).rejects.toThrow(/Gittensory API 429/); + }); + it("does not use stale decision-pack cache for authorization failures", async () => { tempDir = mkdtempSync(join(tmpdir(), "gittensory-cli-")); const fixtureOptions: { decisionPackStatus?: number } = {};