Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 23 additions & 7 deletions packages/gittensory-mcp/bin/gittensory-mcp.js
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -1272,19 +1273,29 @@ 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 = {
schemaVersion: decisionPackCacheSchemaVersion,
apiVersion: typeof payload.apiVersion === "string" ? payload.apiVersion : currentApiVersion,
packageVersion,
apiUrl,
authCacheKey,
login: login.toLowerCase(),
cachedAt,
payload: sanitizedPayload,
Expand All @@ -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() &&
Expand Down
27 changes: 25 additions & 2 deletions test/unit/mcp-cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>((resolve) => server?.close(() => resolve()));
Expand Down Expand Up @@ -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);
Expand All @@ -415,14 +417,35 @@ describe("gittensory-mcp CLI", () => {
await new Promise<void>((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 });
const cacheStatus = JSON.parse(run(["cache", "status", "--json"], env)) as { entries: number };
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 } = {};
Expand Down