Skip to content
Closed
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { afterEach, describe, expect, it } from 'bun:test';
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
getCredentialCachePath,
readCredentialCache,
} from './credential-cache';

let workspaceRoot: string | undefined;

function makeWorkspace(): string {
workspaceRoot = mkdtempSync(join(tmpdir(), 'session-credential-cache-'));
return workspaceRoot;
}

function writeCredentialCache(root: string, sourceSlug: string, content: string): void {
const sourceDir = join(root, 'sources', sourceSlug);
mkdirSync(sourceDir, { recursive: true });
writeFileSync(join(sourceDir, '.credential-cache.json'), content);
}

afterEach(() => {
if (workspaceRoot) {
rmSync(workspaceRoot, { recursive: true, force: true });
workspaceRoot = undefined;
}
});

describe('credential cache path validation', () => {
it('resolves cache paths through the validated source directory helper', () => {
const root = '/tmp/workspace';

expect(getCredentialCachePath(root, 'craft-kb')).toBe(
join(root, 'sources', 'craft-kb', '.credential-cache.json')
);
});

it('rejects unsafe source slugs before reading from the filesystem', () => {
const root = makeWorkspace();
const escapedDir = join(root, 'sessions');
mkdirSync(escapedDir, { recursive: true });
writeFileSync(
join(escapedDir, '.credential-cache.json'),
JSON.stringify({ value: 'escaped-token' })
);

expect(() => getCredentialCachePath(root, '../sessions')).toThrow(
'Invalid source slug: "../sessions"'
);
expect(() => readCredentialCache(root, '../sessions')).toThrow(
'Invalid source slug: "../sessions"'
);
});

it('reads valid, unexpired credential cache entries', () => {
const root = makeWorkspace();
writeCredentialCache(root, 'github', JSON.stringify({ value: 'token' }));

expect(readCredentialCache(root, 'github')).toBe('token');
});

it('returns null for missing, expired, or empty cache entries without logging misses', () => {
const root = makeWorkspace();
const messages: string[] = [];
writeCredentialCache(
root,
'expired',
JSON.stringify({ value: 'old-token', expiresAt: Date.now() - 1000 })
);
writeCredentialCache(root, 'empty', JSON.stringify({ value: '' }));

expect(readCredentialCache(root, 'missing', (message) => messages.push(message))).toBeNull();
expect(readCredentialCache(root, 'expired')).toBeNull();
expect(readCredentialCache(root, 'empty')).toBeNull();
expect(messages).toEqual([]);
});

it('logs unreadable cache content without swallowing slug validation errors', () => {
const root = makeWorkspace();
const messages: string[] = [];
writeCredentialCache(root, 'github', '{not-json');

expect(readCredentialCache(root, 'github', (message) => messages.push(message))).toBeNull();
expect(messages).toHaveLength(1);
expect(messages[0]).toContain('Failed to read credential cache for source "github"');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { getSourcePath } from '@craft-agent/session-tools-core';

/**
* Credential cache entry format (matches main process format).
* Written by Electron main process, read by this server.
*/
interface CredentialCacheEntry {
value: string;
expiresAt?: number;
}

export type CredentialCacheReadErrorLogger = (message: string) => void;

function isMissingFileError(error: unknown): boolean {
return (
error instanceof Error &&
'code' in error &&
(error as NodeJS.ErrnoException).code === 'ENOENT'
);
}

/**
* Get the path to a source's credential cache file.
* The main process writes decrypted credentials to these files.
*/
export function getCredentialCachePath(workspaceRootPath: string, sourceSlug: string): string {
return join(getSourcePath(workspaceRootPath, sourceSlug), '.credential-cache.json');
}

/**
* Read credentials from the cache file for a source.
* Returns null if the cache doesn't exist, is unreadable, or is expired.
*
* Invalid source slugs throw before any filesystem access. That keeps slug
* validation failures distinct from ordinary cache misses or corrupt JSON.
*/
export function readCredentialCache(
workspaceRootPath: string,
sourceSlug: string,
onReadError?: CredentialCacheReadErrorLogger,
): string | null {
const cachePath = getCredentialCachePath(workspaceRootPath, sourceSlug);

try {
const content = readFileSync(cachePath, 'utf-8');
const cache = JSON.parse(content) as CredentialCacheEntry;

if (cache.expiresAt && Date.now() > cache.expiresAt) {
return null;
}

return cache.value || null;
} catch (error) {
if (isMissingFileError(error)) {
return null;
}

onReadError?.(
`Failed to read credential cache for source ${JSON.stringify(sourceSlug)}: ${
error instanceof Error ? error.message : String(error)
}`
);
return null;
}
}
64 changes: 15 additions & 49 deletions packages/desktop/packages/session-mcp-server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
import { existsSync, readFileSync, readdirSync, statSync, writeFileSync, mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { isDeveloperFeedbackEnabled } from '@craft-agent/shared/feature-flags';
import { readCredentialCache } from './credential-cache';
// Import from session-tools-core
import {
type SessionToolContext,
Expand Down Expand Up @@ -75,66 +76,31 @@ function sendCallback(callback: CallbackMessage): void {
console.error(`__CALLBACK__${JSON.stringify(callback)}`);
}

// ============================================================
// Credential Cache Access
// ============================================================

/**
* Credential cache entry format (matches main process format).
* Written by Electron main process, read by this server.
*/
interface CredentialCacheEntry {
value: string;
expiresAt?: number;
}

/**
* Get the path to a source's credential cache file.
* The main process writes decrypted credentials to these files.
*/
function getCredentialCachePath(workspaceRootPath: string, sourceSlug: string): string {
return join(workspaceRootPath, 'sources', sourceSlug, '.credential-cache.json');
}

/**
* Read credentials from the cache file for a source.
* Returns null if the cache doesn't exist or is expired.
*/
function readCredentialCache(workspaceRootPath: string, sourceSlug: string): string | null {
const cachePath = getCredentialCachePath(workspaceRootPath, sourceSlug);

try {
if (!existsSync(cachePath)) {
return null;
}

const content = readFileSync(cachePath, 'utf-8');
const cache = JSON.parse(content) as CredentialCacheEntry;

// Check expiry if set
if (cache.expiresAt && Date.now() > cache.expiresAt) {
return null;
}

return cache.value || null;
} catch {
return null;
}
}

/**
* Create a credential manager that reads from credential cache files.
* This allows the session-mcp-server to access credentials without keychain access.
*/
function createCredentialManager(workspaceRootPath: string): CredentialManagerInterface {
const logCredentialCacheReadError = (message: string) => {
console.error(message);
};

return {
hasValidCredentials: async (source: LoadedSource): Promise<boolean> => {
const token = readCredentialCache(workspaceRootPath, source.config.slug);
const token = readCredentialCache(
workspaceRootPath,
source.config.slug,
logCredentialCacheReadError,
);
return token !== null;
},

getToken: async (source: LoadedSource): Promise<string | null> => {
return readCredentialCache(workspaceRootPath, source.config.slug);
return readCredentialCache(
workspaceRootPath,
source.config.slug,
logCredentialCacheReadError,
);
},

refresh: async (_source: LoadedSource): Promise<string | null> => {
Expand Down
Loading