Skip to content
Draft
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
55 changes: 41 additions & 14 deletions apps/desktop/src/main/computer-use.ts
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,7 @@ export function buildOpencodeMcpEntry(): OpencodeMcpEntry {
export interface McpEntryUrl {
url: string;
type: 'http';
headers?: Record<string, string>;
}

/**
Expand All @@ -390,43 +391,58 @@ export interface McpEntryUrl {
*/
export interface GeminiMcpEntryUrl {
url: string;
headers?: Record<string, string>;
}

/** opencode format for a remote MCP server. */
export interface OpencodeMcpEntryUrl {
type: 'remote';
url: string;
enabled: boolean;
headers?: Record<string, string>;
}

/**
* Returns a URL-based MCP entry for claude-code settings (streamable HTTP).
* Uses `type: 'http'` per the MCP spec β€” claude-code requires this exact key.
* Do NOT use this for Gemini; Gemini requires a different format (url only).
*/
export function buildMcpEntryUrl(url: string): McpEntryUrl {
export function buildMcpEntryUrl(url: string, authToken?: string | null): McpEntryUrl {
if (authToken) {
return { type: 'http', url, headers: { Authorization: `Bearer ${authToken}` } };
}
return { type: 'http', url };
}

/**
* Returns a URL-based MCP entry for Gemini CLI settings.
* Gemini only accepts `{ url }` β€” any extra key causes a validation error.
*/
export function buildGeminiMcpEntryUrl(url: string): GeminiMcpEntryUrl {
export function buildGeminiMcpEntryUrl(url: string, authToken?: string | null): GeminiMcpEntryUrl {
if (authToken) {
return { url, headers: { Authorization: `Bearer ${authToken}` } };
}
return { url };
}

/** Returns a URL-based MCP entry for opencode (remote server). */
export function buildOpencodeMcpEntryUrl(url: string): OpencodeMcpEntryUrl {
export function buildOpencodeMcpEntryUrl(url: string, authToken?: string | null): OpencodeMcpEntryUrl {
if (authToken) {
return { type: 'remote', url, enabled: true, headers: { Authorization: `Bearer ${authToken}` } };
}
return { type: 'remote', url, enabled: true };
}

/**
* Returns the codex `-c` args to configure a remote MCP server by URL.
* Example: `-c mcp_servers.tday-computer-use.url=http://127.0.0.1:PORT/mcp`
* Includes an Authorization header if an auth token is provided.
*/
export function codexMcpCliArgsUrl(url: string): string[] {
return ['-c', `mcp_servers.${MCP_SERVER_KEY}.url=${url}`];
export function codexMcpCliArgsUrl(url: string, authToken?: string | null): string[] {
const args = ['-c', `mcp_servers.${MCP_SERVER_KEY}.url=${url}`];
if (authToken) {
args.push('-c', `mcp_servers.${MCP_SERVER_KEY}.headers.Authorization=Bearer ${authToken}`);
}
return args;
}

// ── claude-code injection (per-session, no cleanup needed) ───────────────────
Expand Down Expand Up @@ -486,9 +502,10 @@ export function applyClaudeCodeMcpUrl(
sessionSettings: Record<string, unknown>,
url: string,
isAnthropicBackend = true,
authToken?: string | null,
): void {
const existing = (sessionSettings.mcpServers as Record<string, unknown>) ?? {};
sessionSettings.mcpServers = { ...existing, [MCP_SERVER_KEY]: buildMcpEntryUrl(url) };
sessionSettings.mcpServers = { ...existing, [MCP_SERVER_KEY]: buildMcpEntryUrl(url, authToken) };

// Inject skill as custom instructions.
const existingInstructions = typeof sessionSettings.customInstructions === 'string'
Expand Down Expand Up @@ -648,11 +665,11 @@ export function injectGeminiMcp(home?: string): () => void {
* Use this when NativecoreService is running in HTTP mode.
* Returns a cleanup function; must be called when the PTY exits.
*/
export function injectGeminiMcpUrl(url: string, home?: string): () => void {
export function injectGeminiMcpUrl(url: string, home?: string, authToken?: string | null): () => void {
const dir = join(home ?? homedir(), '.gemini');
const filePath = join(dir, 'settings.json');
// Gemini CLI only accepts { url } β€” no type/transport field allowed.
return injectMcpToFile(filePath, dir, 'mcpServers', buildGeminiMcpEntryUrl(url) as unknown as Record<string, unknown>);
return injectMcpToFile(filePath, dir, 'mcpServers', buildGeminiMcpEntryUrl(url, authToken) as unknown as Record<string, unknown>);
}

/**
Expand All @@ -678,7 +695,7 @@ export function injectOpencodeMcp(home?: string): () => void {
* Use this when NativecoreService is running in HTTP mode.
* Returns a cleanup function; must be called when the PTY exits.
*/
export function injectOpencodeMcpUrl(url: string, home?: string): () => void {
export function injectOpencodeMcpUrl(url: string, home?: string, authToken?: string | null): () => void {
const xdgBase = process.env['XDG_CONFIG_HOME'] ?? join(home ?? homedir(), '.config');
const dir = join(xdgBase, 'opencode');
const filePath = join(dir, 'opencode.json');
Expand All @@ -687,7 +704,7 @@ export function injectOpencodeMcpUrl(url: string, home?: string): () => void {
filePath,
dir,
'mcp',
buildOpencodeMcpEntryUrl(url) as unknown as Record<string, unknown>,
buildOpencodeMcpEntryUrl(url, authToken) as unknown as Record<string, unknown>,
);
}

Expand Down Expand Up @@ -800,10 +817,14 @@ export function injectPiMcp(): { extensionPath: string; env: Record<string, stri
*
* Cleanup is a no-op here; the caller in index.ts calls NativecoreService.release().
*/
export function injectPiMcpUrl(url: string): { extensionPath: string; env: Record<string, string> } {
export function injectPiMcpUrl(url: string, authToken?: string | null): { extensionPath: string; env: Record<string, string> } {
const env: Record<string, string> = { TDAY_DEVTOOLS_URL: url };
if (authToken) {
env['TDAY_DEVTOOLS_AUTH_TOKEN'] = authToken;
}
return {
extensionPath: bridgeExtensionPath(),
env: { TDAY_DEVTOOLS_URL: url },
env,
};
}

Expand Down Expand Up @@ -1034,6 +1055,7 @@ function writeProxyError(
*/
export function startMcpSessionProxy(
nativecoreOrigin: string,
authToken?: string,
): Promise<{ proxyBaseUrl: string; stop: () => void }> {
let sessionId: string | null = null;
let cachedInitBody: Buffer | null = null;
Expand All @@ -1055,11 +1077,14 @@ export function startMcpSessionProxy(
): Promise<{ status: number; resHeaders: http.IncomingHttpHeaders; body: Buffer }> =>
new Promise((resolve, reject) => {
const targetUrl = `${nativecoreOrigin}${path}`;
const headers: Record<string, string> = { ...reqHeaders, 'content-length': String(body.length) };
// Inject auth header for every upstream request when a token is configured.
if (authToken) headers['authorization'] = `Bearer ${authToken}`;
const req = http.request(
targetUrl,
{
method,
headers: { ...reqHeaders, 'content-length': String(body.length) },
headers,
agent: upstreamAgent,
},
(res) => {
Expand Down Expand Up @@ -1093,6 +1118,8 @@ export function startMcpSessionProxy(
fwdHeaders[lk] = Array.isArray(v) ? v.join(', ') : (v ?? '');
}
fwdHeaders['host'] = new URL(nativecoreOrigin).host;
// Inject auth header for every upstream request when a token is configured.
if (authToken) fwdHeaders['authorization'] = `Bearer ${authToken}`;

// Non-POST (GET for SSE notifications, DELETE for session close): pipe directly
if (method !== 'POST') {
Expand Down
16 changes: 13 additions & 3 deletions apps/desktop/src/main/cron.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,12 +223,22 @@ export class CronScheduler {
const delay = nextDate.getTime() - Date.now();
updateJobStats(job.id, { nextRunAt: nextDate.getTime() });

// setTimeout delay is stored as a 32-bit signed integer, so values above
// 2^31 - 1 ms (~24.8 days) wrap to negative and fire immediately.
// When the next run is further away, cap the delay and reschedule at
// expiry without firing, so we converge on the true fire time.
const MAX_TIMEOUT_MS = 2 ** 31 - 1;
const clampedDelay = Math.min(Math.max(0, delay), MAX_TIMEOUT_MS);
const willFireNow = delay <= MAX_TIMEOUT_MS;

const timer = setTimeout(() => {
this.timers.delete(job.id);
this.fireFn(job);
// Immediately reschedule for the next occurrence.
if (willFireNow && Date.now() >= nextDate.getTime()) {
this.fireFn(job);
}
// Immediately reschedule for the next (or same, if clamped) occurrence.
this.scheduleOne(job);
}, Math.max(0, delay));
}, clampedDelay);

this.timers.set(job.id, timer);
}
Expand Down
30 changes: 28 additions & 2 deletions apps/desktop/src/main/gateway/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,12 +58,38 @@ export function sessionKeyFromRequest(req: IncomingMessage): string {
return '';
}

// ─── Bounded LRU-style map ────────────────────────────────────────────────────

/**
* A Map that evicts the oldest entry when it grows beyond `maxSize`.
* Insertion order is used as the eviction order (least-recently-inserted first).
*/
class BoundedMap<K, V> extends Map<K, V> {
constructor(private readonly maxSize: number) { super(); }

override set(key: K, value: V): this {
// If the key already exists, delete it first so the insertion order is
// updated (most-recently-used semantics).
if (this.has(key)) this.delete(key);
super.set(key, value);
// Each set() adds at most one entry, so a single eviction step suffices.
if (this.size > this.maxSize) {
const oldest = this.keys().next().value;
if (oldest !== undefined) this.delete(oldest);
}
return this;
}
}

// Maximum number of conversation / thinking-state entries to keep in memory.
const MAX_CONVERSATION_ENTRIES = 100;

// ─── Adapter ─────────────────────────────────────────────────────────────────

export class CodexDeepSeekAnthropicAdapter implements GatewayAdapter {
private readonly proxies = new Map<string, { server: Server; baseUrl: string }>();
private readonly conversations = new Map<string, AMessage[]>();
private readonly thinkingStates = new Map<string, ThinkingState>();
private readonly conversations = new BoundedMap<string, AMessage[]>(MAX_CONVERSATION_ENTRIES);
private readonly thinkingStates = new BoundedMap<string, ThinkingState>(MAX_CONVERSATION_ENTRIES);
/** Maps agentId β†’ last-known cwd for usage attribution. */
private readonly cwdByAgentId = new Map<string, string>();

Expand Down
5 changes: 4 additions & 1 deletion apps/desktop/src/main/gateway/bridge/input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -359,7 +359,10 @@ export function convertInput(

// user (or anything else with content)
{
const r = role || 'user';
// Only 'user' and 'assistant' are valid Anthropic message roles.
// Anything else (e.g. 'system', 'tool', unknown strings) defaults to 'user'
// to prevent a 400 error from the Anthropic API.
const r: 'user' | 'assistant' = role === 'assistant' ? 'assistant' : 'user';
const blocks = contentBlocksFromContent(item.content);
if (blocks.length === 0) { pendingSummary = undefined; continue; }
messages.push({ role: r, content: blocks });
Expand Down
40 changes: 32 additions & 8 deletions apps/desktop/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,12 @@ function registerIpc(): void {

// PTY spawn
ipcMain.handle(IPC.ptySpawn, async (event, req: SpawnRequest) => {
// Validate tabId early β€” it is used to construct file paths and must not
// contain path-traversal sequences (e.g. '../', '/', null bytes).
if (!/^[\w-]+$/.test(req.tabId)) {
throw new Error(`[tday] Invalid tabId β€” must match [a-zA-Z0-9_-]: "${req.tabId}"`);
}

const existing = ptys.get(req.tabId);
if (existing) {
try { existing.kill(); } catch { /* already dead */ }
Expand Down Expand Up @@ -294,7 +300,8 @@ function registerIpc(): void {
console.warn('[tday] NativecoreService unavailable for pi, falling back to stdio:', e);
}
if (piCuUrl) {
const { extensionPath, env: cuEnv } = injectPiMcpUrl(piCuUrl);
const piAuthToken = NativecoreService.getAuthToken();
const { extensionPath, env: cuEnv } = injectPiMcpUrl(piCuUrl, piAuthToken);
Object.assign(env, cuEnv);
args = [...args, '--extension', extensionPath];
computerUseCleanup = () => NativecoreService.release();
Expand Down Expand Up @@ -410,6 +417,7 @@ function registerIpc(): void {

const claudeDir = join(homedir(), '.claude');
const globalSettingsPath = join(claudeDir, 'settings.json');

// Per-session temp settings file β€” unique per tab, never collides.
const sessionSettingsPath = join(claudeDir, `tday-session-${req.tabId}.json`);
// Separate MCP config file passed via --mcp-config (mcpServers in --settings is ignored by claude-code).
Expand All @@ -433,7 +441,8 @@ function registerIpc(): void {
console.warn('[tday] NativecoreService unavailable for claude-code, falling back to stdio:', e);
}
if (cuNativecoreUrl) {
applyClaudeCodeMcpUrl(sessionSettings, cuNativecoreUrl, isAnthropicBackend);
const ccAuthToken = NativecoreService.getAuthToken();
applyClaudeCodeMcpUrl(sessionSettings, cuNativecoreUrl, isAnthropicBackend, ccAuthToken);
computerUseCleanup = () => NativecoreService.release();
} else {
applyClaudeCodeMcp(sessionSettings, isAnthropicBackend);
Expand Down Expand Up @@ -513,7 +522,8 @@ function registerIpc(): void {
console.warn('[tday] NativecoreService unavailable for claude-code (no provider), falling back to stdio:', e);
}
if (cuUrl) {
applyClaudeCodeMcpUrl(sessionSettings, cuUrl, true);
const ccAuthToken2 = NativecoreService.getAuthToken();
applyClaudeCodeMcpUrl(sessionSettings, cuUrl, true, ccAuthToken2);
computerUseCleanup = () => NativecoreService.release();
} else {
applyClaudeCodeMcp(sessionSettings, true);
Expand Down Expand Up @@ -555,7 +565,8 @@ function registerIpc(): void {
console.warn('[tday] NativecoreService unavailable for gemini, falling back to stdio:', e);
}
if (cuUrl) {
const cleanup = injectGeminiMcpUrl(cuUrl);
const geminiAuthToken = NativecoreService.getAuthToken();
const cleanup = injectGeminiMcpUrl(cuUrl, undefined, geminiAuthToken);
computerUseCleanup = () => { cleanup(); NativecoreService.release(); };
} else {
computerUseCleanup = injectGeminiMcp();
Expand All @@ -570,7 +581,8 @@ function registerIpc(): void {
console.warn('[tday] NativecoreService unavailable for opencode, falling back to stdio:', e);
}
if (cuUrl) {
const cleanup = injectOpencodeMcpUrl(cuUrl);
const opencodeAuthToken = NativecoreService.getAuthToken();
const cleanup = injectOpencodeMcpUrl(cuUrl, undefined, opencodeAuthToken);
computerUseCleanup = () => { cleanup(); NativecoreService.release(); };
} else {
computerUseCleanup = injectOpencodeMcp();
Expand All @@ -590,8 +602,9 @@ function registerIpc(): void {
try {
await NativecoreService.addRef();
const nativecoreUrl = NativecoreService.getUrl();
const codexAuthToken = NativecoreService.getAuthToken();
if (!nativecoreUrl) throw new Error('NativecoreService not ready');
const mcpProxy = await startMcpSessionProxy(nativecoreUrl);
const mcpProxy = await startMcpSessionProxy(nativecoreUrl, codexAuthToken ?? undefined);
mcpProxyStop = mcpProxy.stop;
// codex MCP URL = proxy base + the /mcp path that nativecore serves
args.push(...codexMcpCliArgsUrl(mcpProxy.proxyBaseUrl + '/mcp'));
Expand Down Expand Up @@ -895,10 +908,21 @@ function registerIpc(): void {
ipcMain.handle(IPC.coworkerReset, (_e, id: string) => resetBuiltinCoworker(id));
ipcMain.handle(IPC.coworkerFetchUrl, async (_e, rawUrl: string): Promise<string> => {
const trimmed = rawUrl.trim();
// Local file path: read directly
// Local file path: restrict reads to user-owned config/project directories.
// Disallow absolute paths that could read arbitrary system files.
if (trimmed.startsWith('/') || /^[A-Za-z]:[/\\]/.test(trimmed)) {
const { readFileSync } = await import('fs');
return readFileSync(trimmed, 'utf8');
const { resolve: resolvePath } = await import('path');
const { homedir } = await import('os');

const resolved = resolvePath(trimmed);
const home = homedir();
// Only allow reading files inside the user's home directory to prevent
// reading sensitive system files such as /etc/passwd or SSH keys.
if (!resolved.startsWith(home + '/') && !resolved.startsWith(home + '\\')) {
throw new Error(`Reading files outside the home directory is not allowed: ${resolved}`);
}
return readFileSync(resolved, 'utf8');
}
const url = normalizeGitHubUrl(trimmed);
const res = await fetch(url);
Expand Down
Loading