diff --git a/.changeset/real-wolves-thank.md b/.changeset/real-wolves-thank.md new file mode 100644 index 00000000000..4dcb1ecdb0d --- /dev/null +++ b/.changeset/real-wolves-thank.md @@ -0,0 +1,17 @@ +--- +'mastracode': minor +--- + +Added pre/post hook wrapping for tool execution via `HookManager`, exported `createAuthStorage` for standalone auth provider initialization, and fixed Anthropic/OpenAI auth routing to use stored credential type as the source of truth. + +**New API: `createAuthStorage`** + +```ts +import { createAuthStorage } from 'mastracode'; + +const authStorage = createAuthStorage(); +// authStorage is now wired into Claude Max and OpenAI Codex providers +``` + +- `disabledTools` config now also filters tools exposed to subagents, preventing bypass through delegation +- Auth routing uses `AuthStorage` credential type (`api_key` vs `oauth`) to correctly route API-key auth vs OAuth bearer auth diff --git a/mastracode/src/agents/__tests__/extra-tools.test.ts b/mastracode/src/agents/__tests__/extra-tools.test.ts index 91eadede4c3..b2b5e62318e 100644 --- a/mastracode/src/agents/__tests__/extra-tools.test.ts +++ b/mastracode/src/agents/__tests__/extra-tools.test.ts @@ -222,6 +222,34 @@ describe('createDynamicTools – denied tool filtering', () => { }); }); +describe('createDynamicTools – disabledTools filtering', () => { + it('should omit disabled built-in tools', () => { + const getDynamicTools = createDynamicTools(undefined, undefined, undefined, [ + 'request_sandbox_access', + 'execute_command', + ]); + + const tools = getDynamicTools({ requestContext: makeRequestContext() }); + expect(tools).not.toHaveProperty('request_sandbox_access'); + expect(tools).not.toHaveProperty('execute_command'); + // web_search is provided by the Anthropic model mock and should survive filtering + expect(tools).toHaveProperty('web_search'); + }); + + it('should omit disabled extraTools', () => { + const myTool = createTool({ + id: 'my_tool', + description: 'A custom tool', + inputSchema: z.object({}), + execute: async () => ({ result: 'custom' }), + }); + + const getDynamicTools = createDynamicTools(undefined, { my_tool: myTool }, undefined, ['my_tool']); + const tools = getDynamicTools({ requestContext: makeRequestContext() }); + expect(tools).not.toHaveProperty('my_tool'); + }); +}); + describe('buildToolGuidance – denied tool filtering', () => { it('should omit guidance for denied tools', () => { const guidance = buildToolGuidance('build', { diff --git a/mastracode/src/agents/__tests__/model.test.ts b/mastracode/src/agents/__tests__/model.test.ts index 35c218dd289..30aee8a2623 100644 --- a/mastracode/src/agents/__tests__/model.test.ts +++ b/mastracode/src/agents/__tests__/model.test.ts @@ -39,6 +39,17 @@ vi.mock('@ai-sdk/anthropic', () => ({ }), })); +// Mock @ai-sdk/openai +vi.mock('@ai-sdk/openai', () => ({ + createOpenAI: vi.fn((_opts: Record) => { + const openai = ((modelId: string) => ({ __provider: 'openai-direct', modelId })) as unknown as { + responses: (modelId: string) => Record; + }; + openai.responses = (modelId: string) => ({ __provider: 'openai-direct', modelId }); + return openai; + }), +})); + // Mock ai SDK's wrapLanguageModel to pass through with a marker vi.mock('ai', () => ({ wrapLanguageModel: vi.fn(({ model }: { model: Record }) => ({ @@ -56,7 +67,8 @@ vi.mock('@mastra/core/llm', () => ({ })); import { opencodeClaudeMaxProvider } from '../../providers/claude-max.js'; -import { resolveModel, getAnthropicApiKey } from '../model.js'; +import { openaiCodexProvider } from '../../providers/openai-codex.js'; +import { resolveModel, getAnthropicApiKey, getOpenAIApiKey } from '../model.js'; describe('resolveModel', () => { const originalEnv = { ...process.env }; @@ -72,18 +84,22 @@ describe('resolveModel', () => { }); describe('anthropic/* models', () => { - it('prefers Claude Max OAuth when logged in, even if API key is present', () => { - process.env.ANTHROPIC_API_KEY = 'sk-test-key-123'; - mockAuthStorageInstance.isLoggedIn.mockImplementation((p: string) => p === 'anthropic'); + it('prefers Claude Max OAuth when stored OAuth credential exists', () => { + mockAuthStorageInstance.get.mockReturnValue({ + type: 'oauth', + access: 'oauth-access-token', + refresh: 'oauth-refresh-token', + expires: Date.now() + 60_000, + }); resolveModel('anthropic/claude-sonnet-4-20250514'); expect(opencodeClaudeMaxProvider).toHaveBeenCalledWith('claude-sonnet-4-20250514'); }); - it('falls back to API key when not logged in via OAuth', () => { - process.env.ANTHROPIC_API_KEY = 'sk-test-key-123'; - mockAuthStorageInstance.isLoggedIn.mockReturnValue(false); + it('uses API key when stored credential is api_key, even if isLoggedIn reports true', () => { + mockAuthStorageInstance.isLoggedIn.mockImplementation((p: string) => p === 'anthropic'); + mockAuthStorageInstance.get.mockReturnValue({ type: 'api_key', key: 'sk-stored-key-456' }); const result = resolveModel('anthropic/claude-sonnet-4-20250514') as Record; @@ -93,6 +109,16 @@ describe('resolveModel', () => { expect(opencodeClaudeMaxProvider).not.toHaveBeenCalled(); }); + it('does not use env API key when no stored Anthropic credential exists', () => { + process.env.ANTHROPIC_API_KEY = 'sk-test-key-123'; + mockAuthStorageInstance.get.mockReturnValue(undefined); + + const result = resolveModel('anthropic/claude-sonnet-4-20250514') as Record; + + expect(result.__provider).toBe('claude-max-oauth'); + expect(opencodeClaudeMaxProvider).toHaveBeenCalledWith('claude-sonnet-4-20250514'); + }); + it('uses stored API key credential when not logged in via OAuth', () => { mockAuthStorageInstance.isLoggedIn.mockReturnValue(false); mockAuthStorageInstance.get.mockReturnValue({ type: 'api_key', key: 'sk-stored-key-456' }); @@ -106,7 +132,6 @@ describe('resolveModel', () => { }); it('falls back to OAuth provider when no auth is configured (to prompt login)', () => { - mockAuthStorageInstance.isLoggedIn.mockReturnValue(false); mockAuthStorageInstance.get.mockReturnValue(undefined); resolveModel('anthropic/claude-sonnet-4-20250514'); @@ -122,14 +147,28 @@ describe('resolveModel', () => { }); describe('openai/* models', () => { - it('uses codex provider when logged in via OAuth', () => { - mockAuthStorageInstance.isLoggedIn.mockReturnValue(true); + it('uses codex provider when stored OAuth credential exists', () => { + mockAuthStorageInstance.get.mockReturnValue({ + type: 'oauth', + access: 'openai-oauth-access-token', + refresh: 'openai-oauth-refresh-token', + expires: Date.now() + 60_000, + }); const result = resolveModel('openai/gpt-4o') as Record; expect(result.__provider).toBe('openai-codex'); + expect(openaiCodexProvider).toHaveBeenCalled(); }); - it('uses model router when not logged in via OAuth', () => { - mockAuthStorageInstance.isLoggedIn.mockReturnValue(false); + it('uses direct OpenAI API key provider when stored API key credential exists', () => { + mockAuthStorageInstance.get.mockReturnValue({ type: 'api_key', key: 'sk-openai-key' }); + const result = resolveModel('openai/gpt-4o') as Record; + expect(result.__provider).toBe('openai-direct'); + expect(result.__wrapped).toBe(true); + expect(result.modelId).toBe('gpt-4o'); + }); + + it('uses model router when no OpenAI auth is configured', () => { + mockAuthStorageInstance.get.mockReturnValue(undefined); const result = resolveModel('openai/gpt-4o') as Record; expect(result.__provider).toBe('model-router'); }); @@ -155,12 +194,7 @@ describe('getAnthropicApiKey', () => { process.env = { ...originalEnv }; }); - it('returns env var when ANTHROPIC_API_KEY is set', () => { - process.env.ANTHROPIC_API_KEY = 'sk-env-key'; - expect(getAnthropicApiKey()).toBe('sk-env-key'); - }); - - it('returns stored API key when no env var is set', () => { + it('returns stored API key when set', () => { mockAuthStorageInstance.get.mockReturnValue({ type: 'api_key', key: 'sk-stored-key' }); expect(getAnthropicApiKey()).toBe('sk-stored-key'); }); @@ -175,9 +209,26 @@ describe('getAnthropicApiKey', () => { expect(getAnthropicApiKey()).toBeUndefined(); }); - it('prefers env var over stored credential', () => { + it('ignores env var when no stored credential exists', () => { process.env.ANTHROPIC_API_KEY = 'sk-env-key'; - mockAuthStorageInstance.get.mockReturnValue({ type: 'api_key', key: 'sk-stored-key' }); - expect(getAnthropicApiKey()).toBe('sk-env-key'); + mockAuthStorageInstance.get.mockReturnValue(undefined); + expect(getAnthropicApiKey()).toBeUndefined(); + }); +}); + +describe('getOpenAIApiKey', () => { + it('returns stored API key when set', () => { + mockAuthStorageInstance.get.mockReturnValue({ type: 'api_key', key: 'sk-openai-key' }); + expect(getOpenAIApiKey()).toBe('sk-openai-key'); + }); + + it('returns undefined when no API key is available', () => { + mockAuthStorageInstance.get.mockReturnValue(undefined); + expect(getOpenAIApiKey()).toBeUndefined(); + }); + + it('returns undefined when stored credential is OAuth type', () => { + mockAuthStorageInstance.get.mockReturnValue({ type: 'oauth', access: 'token', refresh: 'r', expires: 0 }); + expect(getOpenAIApiKey()).toBeUndefined(); }); }); diff --git a/mastracode/src/agents/__tests__/tools.test.ts b/mastracode/src/agents/__tests__/tools.test.ts new file mode 100644 index 00000000000..5b4199e68b0 --- /dev/null +++ b/mastracode/src/agents/__tests__/tools.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createDynamicTools } from '../tools.js'; + +function createRequestContext(state: Record, modeId: string = 'build') { + return { + get(key: string) { + if (key !== 'harness') return undefined; + return { + modeId, + getState: () => state, + }; + }, + } as any; +} + +describe('createDynamicTools', () => { + it('merges extra tools into the exposed tool map', () => { + const customTool = { + description: 'custom', + async execute() { + return { ok: true }; + }, + }; + + const getDynamicTools = createDynamicTools(undefined, { + custom_tool: customTool, + }); + + const allowedTools = getDynamicTools({ + requestContext: createRequestContext({ + projectPath: process.cwd(), + }), + }); + expect(allowedTools.custom_tool).toBeDefined(); + }); + + it('runs pre/post hooks around tool execution', async () => { + const execute = vi.fn(async () => ({ ok: true })); + const hookManager = { + runPreToolUse: vi.fn(async () => ({ allowed: true, results: [], warnings: [] })), + runPostToolUse: vi.fn(async () => ({ allowed: true, results: [], warnings: [] })), + }; + + const getDynamicTools = createDynamicTools( + undefined, + { + custom_tool: { + description: 'custom', + execute, + }, + }, + hookManager as any, + ); + + const tools = getDynamicTools({ + requestContext: createRequestContext({ + projectPath: process.cwd(), + }), + }); + + const input = { foo: 'bar' }; + const output = await tools.custom_tool.execute(input, {}); + + expect(output).toEqual({ ok: true }); + expect(execute).toHaveBeenCalledWith(input, {}); + expect(hookManager.runPreToolUse).toHaveBeenCalledWith('custom_tool', input); + expect(hookManager.runPostToolUse).toHaveBeenCalledWith('custom_tool', input, { ok: true }, false); + }); + + it('blocks tool execution when PreToolUse denies access', async () => { + const execute = vi.fn(async () => ({ ok: true })); + const hookManager = { + runPreToolUse: vi.fn(async () => ({ + allowed: false, + blockReason: 'blocked by policy', + results: [], + warnings: [], + })), + runPostToolUse: vi.fn(async () => ({ allowed: true, results: [], warnings: [] })), + }; + + const getDynamicTools = createDynamicTools( + undefined, + { + custom_tool: { + description: 'custom', + execute, + }, + }, + hookManager as any, + ); + + const tools = getDynamicTools({ + requestContext: createRequestContext({ + projectPath: process.cwd(), + }), + }); + + const result = await tools.custom_tool.execute({ foo: 'bar' }, {}); + expect(result).toEqual({ error: 'blocked by policy' }); + expect(execute).not.toHaveBeenCalled(); + expect(hookManager.runPostToolUse).not.toHaveBeenCalled(); + }); + + it('still runs PostToolUse when tool execution throws', async () => { + const execute = vi.fn(async () => { + throw new Error('boom'); + }); + const hookManager = { + runPreToolUse: vi.fn(async () => ({ allowed: true, results: [], warnings: [] })), + runPostToolUse: vi.fn(async () => ({ allowed: true, results: [], warnings: [] })), + }; + + const getDynamicTools = createDynamicTools( + undefined, + { + custom_tool: { + description: 'custom', + execute, + }, + }, + hookManager as any, + ); + + const tools = getDynamicTools({ + requestContext: createRequestContext({ + projectPath: process.cwd(), + }), + }); + + await expect(tools.custom_tool.execute({ foo: 'bar' }, {})).rejects.toThrow('boom'); + expect(hookManager.runPostToolUse).toHaveBeenCalledWith('custom_tool', { foo: 'bar' }, { error: 'boom' }, true); + }); +}); diff --git a/mastracode/src/agents/model.ts b/mastracode/src/agents/model.ts index 107f68eb5cb..02b9891d8cc 100644 --- a/mastracode/src/agents/model.ts +++ b/mastracode/src/agents/model.ts @@ -1,4 +1,5 @@ import { createAnthropic } from '@ai-sdk/anthropic'; +import { createOpenAI } from '@ai-sdk/openai'; import type { LanguageModelV1 } from '@ai-sdk/provider'; import type { HarnessRequestContext } from '@mastra/core/harness'; import { ModelRouterLanguageModel } from '@mastra/core/llm'; @@ -27,7 +28,8 @@ type ResolvedModel = | ReturnType | ReturnType | ModelRouterLanguageModel - | ReturnType>; + | ReturnType> + | ReturnType>; export function remapOpenAIModelForCodexOAuth(modelId: string): string { if (!modelId.startsWith(OPENAI_PREFIX)) { @@ -49,18 +51,26 @@ export function remapOpenAIModelForCodexOAuth(modelId: string): string { } /** - * Resolve the Anthropic API key from environment or stored credentials. + * Resolve the Anthropic API key from stored credentials. * Returns the key if available, undefined otherwise. */ export function getAnthropicApiKey(): string | undefined { - // Environment variable takes priority - if (process.env.ANTHROPIC_API_KEY) { - return process.env.ANTHROPIC_API_KEY; - } - // Check stored API key credential (set via /apikey or TUI prompt) + // Check stored API key credential (set via /apikey or UI prompt) const storedCred = authStorage.get('anthropic'); - if (storedCred?.type === 'api_key') { - return storedCred.key; + if (storedCred?.type === 'api_key' && storedCred.key.trim().length > 0) { + return storedCred.key.trim(); + } + return undefined; +} + +/** + * Resolve the OpenAI API key from stored credentials. + * Returns the key if available, undefined otherwise. + */ +export function getOpenAIApiKey(): string | undefined { + const storedCred = authStorage.get('openai-codex'); + if (storedCred?.type === 'api_key' && storedCred.key.trim().length > 0) { + return storedCred.key.trim(); } return undefined; } @@ -78,12 +88,22 @@ function anthropicApiKeyProvider(modelId: string, apiKey: string): LanguageModel }); } +/** + * Create an OpenAI model using a direct API key from AuthStorage. + */ +function openaiApiKeyProvider(modelId: string, apiKey: string): LanguageModelV1 { + const openai = createOpenAI({ apiKey }); + return wrapLanguageModel({ + model: openai.responses(modelId), + }); +} + /** * Resolve a model ID to the correct provider instance. * Shared by the main agent, observer, and reflector. * - * - For anthropic/* models: Prefers Claude Max OAuth, falls back to direct API key - * - For openai/* models with OAuth: Uses OpenAI Codex OAuth provider + * - For anthropic/* models: Uses stored OAuth credentials when present, otherwise direct API key + * - For openai/* models: Uses OAuth when configured, otherwise direct API key from AuthStorage * - For moonshotai/* models: Uses Moonshot AI Anthropic-compatible endpoint * - For all other providers: Uses Mastra's model router (models.dev gateway) */ @@ -124,22 +144,42 @@ export function resolveModel( })(modelId.substring('moonshotai/'.length)); } else if (isAnthropicModel) { const bareModelId = modelId.substring('anthropic/'.length); - // Primary path: Claude Max OAuth - if (authStorage.isLoggedIn('anthropic')) { + const storedCred = authStorage.get('anthropic'); + + // Primary path: explicit OAuth credential + if (storedCred?.type === 'oauth') { return opencodeClaudeMaxProvider(bareModelId); } - // Fallback: direct API key (env var or stored credential) + + // Secondary path: explicit stored API key credential + if (storedCred?.type === 'api_key' && storedCred.key.trim().length > 0) { + return anthropicApiKeyProvider(bareModelId, storedCred.key.trim()); + } + + // Fallback: direct API key from AuthStorage const apiKey = getAnthropicApiKey(); if (apiKey) { return anthropicApiKeyProvider(bareModelId, apiKey); } // No auth configured — attempt OAuth provider which will prompt login return opencodeClaudeMaxProvider(bareModelId); - } else if (isOpenAIModel && authStorage.isLoggedIn('openai-codex')) { - const resolvedModelId = options?.remapForCodexOAuth ? remapOpenAIModelForCodexOAuth(modelId) : modelId; - return openaiCodexProvider(resolvedModelId.substring(OPENAI_PREFIX.length), { - thinkingLevel: options?.thinkingLevel, - }); + } else if (isOpenAIModel) { + const bareModelId = modelId.substring(OPENAI_PREFIX.length); + const storedCred = authStorage.get('openai-codex'); + + if (storedCred?.type === 'oauth') { + const resolvedModelId = options?.remapForCodexOAuth ? remapOpenAIModelForCodexOAuth(modelId) : modelId; + return openaiCodexProvider(resolvedModelId.substring(OPENAI_PREFIX.length), { + thinkingLevel: options?.thinkingLevel, + }); + } + + const apiKey = getOpenAIApiKey(); + if (apiKey) { + return openaiApiKeyProvider(bareModelId, apiKey); + } + + return new ModelRouterLanguageModel(modelId); } else { return new ModelRouterLanguageModel(modelId); } diff --git a/mastracode/src/agents/tools.ts b/mastracode/src/agents/tools.ts index 3386e2a015a..dc402979c14 100644 --- a/mastracode/src/agents/tools.ts +++ b/mastracode/src/agents/tools.ts @@ -2,13 +2,55 @@ import { createAnthropic } from '@ai-sdk/anthropic'; import { createOpenAI } from '@ai-sdk/openai'; import type { HarnessRequestContext } from '@mastra/core/harness'; import type { RequestContext } from '@mastra/core/request-context'; +import type { HookManager } from '../hooks'; import type { McpManager } from '../mcp'; import type { stateSchema } from '../schema'; import { createWebSearchTool, createWebExtractTool, hasTavilyKey, requestSandboxAccessTool } from '../tools'; +/** Minimal shape for tools passed to createDynamicTools. */ +interface ToolLike { + execute?: (input: unknown, context?: unknown) => Promise | unknown; + [key: string]: unknown; +} + +function wrapToolWithHooks(toolName: string, tool: ToolLike, hookManager?: HookManager): ToolLike { + if (!hookManager || typeof tool?.execute !== 'function') { + return tool; + } + + return { + ...tool, + async execute(input: unknown, toolContext: unknown) { + const preResult = await hookManager.runPreToolUse(toolName, input); + if (!preResult.allowed) { + return { + error: preResult.blockReason ?? `Blocked by PreToolUse hook for tool "${toolName}"`, + }; + } + + let output: unknown; + let toolError = false; + try { + output = await tool.execute(input, toolContext); + return output; + } catch (error) { + toolError = true; + output = { + error: error instanceof Error ? error.message : String(error), + }; + throw error; + } finally { + await hookManager.runPostToolUse(toolName, input, output, toolError).catch(() => undefined); + } + }, + }; +} + export function createDynamicTools( mcpManager?: McpManager, - extraTools?: Record | ((ctx: { requestContext: RequestContext }) => Record), + extraTools?: Record | ((ctx: { requestContext: RequestContext }) => Record), + hookManager?: HookManager, + disabledTools?: string[], ) { return function getDynamicTools({ requestContext }: { requestContext: RequestContext }) { const ctx = requestContext.get('harness') as HarnessRequestContext | undefined; @@ -21,7 +63,7 @@ export function createDynamicTools( // Filesystem, grep, glob, edit, write, execute_command, and process // management tools are now provided by the workspace (see workspace.ts). // Only tools without a workspace equivalent remain here. - const tools: Record = { + const tools: Record = { request_access: requestSandboxAccessTool, }; @@ -50,6 +92,13 @@ export function createDynamicTools( } } + // Remove tools explicitly disabled via config so the model never sees them. + if (disabledTools?.length) { + for (const toolName of disabledTools) { + delete tools[toolName]; + } + } + // Remove tools that have a per-tool 'deny' policy so the model never sees them. const permissionRules = state?.permissionRules; if (permissionRules?.tools) { @@ -60,6 +109,10 @@ export function createDynamicTools( } } + for (const [toolName, tool] of Object.entries(tools)) { + tools[toolName] = wrapToolWithHooks(toolName, tool, hookManager); + } + return tools; }; } diff --git a/mastracode/src/index.ts b/mastracode/src/index.ts index 1d79a6a3990..55ea826bc11 100644 --- a/mastracode/src/index.ts +++ b/mastracode/src/index.ts @@ -66,7 +66,19 @@ export interface MastraCodeConfig { /** Override or extend subagent definitions. Default: explore/plan/execute */ subagents?: HarnessSubagent[]; /** Extra tools merged into the dynamic tool set. Can be a static record or a function that receives requestContext. */ - extraTools?: Record | ((ctx: { requestContext: RequestContext }) => Record); + extraTools?: + | Record< + string, + { execute?: (input: unknown, context?: unknown) => Promise | unknown; [key: string]: unknown } + > + | ((ctx: { + requestContext: RequestContext; + }) => Record< + string, + { execute?: (input: unknown, context?: unknown) => Promise | unknown; [key: string]: unknown } + >); + /** Tools removed from the dynamic tool set before exposure to the model */ + disabledTools?: string[]; /** Custom storage config instead of auto-detected default */ storage?: StorageConfig; /** Initial state overrides (yolo, thinkingLevel, etc.) */ @@ -81,13 +93,18 @@ export interface MastraCodeConfig { disableHooks?: boolean; } +export function createAuthStorage() { + const authStorage = new AuthStorage(); + setAuthStorage(authStorage); + setOpenAIAuthStorage(authStorage); + return authStorage; +} + export async function createMastraCode(config?: MastraCodeConfig) { const cwd = config?.cwd ?? process.cwd(); // Auth storage (shared with Claude Max / OpenAI providers and Harness) - const authStorage = new AuthStorage(); - setAuthStorage(authStorage); - setOpenAIAuthStorage(authStorage); + const authStorage = createAuthStorage(); // Project detection const project = detectProject(cwd); @@ -112,15 +129,6 @@ export async function createMastraCode(config?: MastraCodeConfig) { // MCP const mcpManager = config?.disableMcp ? undefined : createMcpManager(project.rootPath); - // Agent - const codeAgent = new Agent({ - id: 'code-agent', - name: 'Code Agent', - instructions: getDynamicInstructions, - model: getDynamicModel, - tools: createDynamicTools(mcpManager, config?.extraTools), - }); - // Hooks const hookManager = config?.disableHooks ? undefined : new HookManager(project.rootPath, 'session-init'); @@ -130,6 +138,15 @@ export async function createMastraCode(config?: MastraCodeConfig) { console.info(`Hooks: ${hookCount} hook(s) configured`); } + // Agent + const codeAgent = new Agent({ + id: 'code-agent', + name: 'Code Agent', + instructions: getDynamicInstructions, + model: getDynamicModel, + tools: createDynamicTools(mcpManager, config?.extraTools, hookManager, config?.disabledTools), + }); + // Build subagent definitions with project-scoped tools const viewTool = createViewTool(project.rootPath); const grepTool = createGrepTool(project.rootPath); @@ -138,11 +155,21 @@ export async function createMastraCode(config?: MastraCodeConfig) { const writeFileTool = createWriteFileTool(project.rootPath); const stringReplaceLspTool = createStringReplaceLspTool(project.rootPath); - const readOnlyTools = { + // Filter disabled tools from a tool map so subagents respect disabledTools config. + const filterDisabled = >(tools: T): T => { + if (!config?.disabledTools?.length) return tools; + const filtered = { ...tools }; + for (const name of config.disabledTools) { + delete (filtered as Record)[name]; + } + return filtered; + }; + + const readOnlyTools = filterDisabled({ view: viewTool, search_content: grepTool, find_files: globTool, - }; + }); const defaultSubagents: HarnessSubagent[] = [ { @@ -167,14 +194,14 @@ export async function createMastraCode(config?: MastraCodeConfig) { description: "Task execution with write capabilities. Use for 'implement feature X', 'fix bug Y', 'refactor module Z'.", instructions: executeSubagent.instructions, - tools: { + tools: filterDisabled({ ...readOnlyTools, string_replace_lsp: stringReplaceLspTool, write_file: writeFileTool, execute_command: executeCommandTool, task_write: taskWriteTool, task_check: taskCheckTool, - }, + }), }, ]; @@ -212,11 +239,23 @@ export async function createMastraCode(config?: MastraCodeConfig) { ]; // Build lightweight provider access for resolving built-in packs at startup. - // OAuth providers are checked via authStorage, env-only providers via process.env. - // Also scan the full provider registry so any configured API key satisfies access checks. + // Anthropic/OpenAI use AuthStorage only; other providers use env API keys. + // Also scan the full provider registry so configured env API keys satisfy access checks. + const anthropicCred = authStorage.get('anthropic'); + const openaiCred = authStorage.get('openai-codex'); const startupAccess: ProviderAccess = { - anthropic: authStorage.isLoggedIn('anthropic') ? 'oauth' : process.env.ANTHROPIC_API_KEY ? 'apikey' : false, - openai: authStorage.isLoggedIn('openai-codex') ? 'oauth' : process.env.OPENAI_API_KEY ? 'apikey' : false, + anthropic: + anthropicCred?.type === 'oauth' + ? 'oauth' + : anthropicCred?.type === 'api_key' && anthropicCred.key.trim().length > 0 + ? 'apikey' + : false, + openai: + openaiCred?.type === 'oauth' + ? 'oauth' + : openaiCred?.type === 'api_key' && openaiCred.key.trim().length > 0 + ? 'apikey' + : false, cerebras: process.env.CEREBRAS_API_KEY ? 'apikey' : false, google: process.env.GOOGLE_GENERATIVE_AI_API_KEY ? 'apikey' : false, deepseek: process.env.DEEPSEEK_API_KEY ? 'apikey' : false, @@ -226,6 +265,7 @@ export async function createMastraCode(config?: MastraCodeConfig) { const registry = PROVIDER_REGISTRY as Record; for (const [provider, config] of Object.entries(registry)) { if (startupAccess[provider] && startupAccess[provider] !== false) continue; // Already enabled above + if (provider === 'anthropic' || provider === 'openai') continue; const envVars = config?.apiKeyEnvVar; const envVarList = Array.isArray(envVars) ? envVars : envVars ? [envVars] : []; if (envVarList.some(envVar => process.env[envVar])) { @@ -272,7 +312,6 @@ export async function createMastraCode(config?: MastraCodeConfig) { globalInitialState[`subagentModelId_${key}`] = modelId; } } - const harness = new Harness({ id: 'mastra-code', resourceId: project.resourceId, @@ -298,6 +337,18 @@ export async function createMastraCode(config?: MastraCodeConfig) { if (oauthId && authStorage.isLoggedIn(oauthId)) { return true; } + if (provider === 'anthropic') { + const cred = authStorage.get('anthropic'); + if (cred?.type === 'api_key' && cred.key.trim().length > 0) { + return true; + } + } + if (provider === 'openai') { + const cred = authStorage.get('openai-codex'); + if (cred?.type === 'api_key' && cred.key.trim().length > 0) { + return true; + } + } const customProvider = loadSettings().customProviders.find(entry => { return provider === getCustomProviderId(entry.name); @@ -305,7 +356,6 @@ export async function createMastraCode(config?: MastraCodeConfig) { if (customProvider) { return true; } - return undefined; }, modelUseCountProvider: () => loadSettings().modelUseCounts, diff --git a/mastracode/src/providers/claude-max.ts b/mastracode/src/providers/claude-max.ts index 93328a30176..561838bd769 100644 --- a/mastracode/src/providers/claude-max.ts +++ b/mastracode/src/providers/claude-max.ts @@ -30,8 +30,8 @@ export function getAuthStorage(): AuthStorage { /** * Set a custom AuthStorage instance (useful for TUI integration) */ -export function setAuthStorage(storage: AuthStorage): void { - authStorageInstance = storage; +export function setAuthStorage(storage: AuthStorage | undefined): void { + authStorageInstance = storage ?? null; } /** @@ -138,7 +138,7 @@ export function opencodeClaudeMaxProvider(modelId: string = 'claude-sonnet-4-202 // Test environment: use API key if (process.env.NODE_ENV === 'test' || process.env.VITEST) { const anthropic = createAnthropic({ - apiKey: process.env.ANTHROPIC_API_KEY || 'test-api-key', + apiKey: 'test-api-key', }); return wrapLanguageModel({ model: anthropic(modelId), @@ -153,6 +153,13 @@ export function opencodeClaudeMaxProvider(modelId: string = 'claude-sonnet-4-202 // Reload from disk to handle multi-instance refresh authStorage.reload(); + const storedCred = authStorage.get('anthropic'); + if (storedCred?.type === 'api_key') { + throw new Error( + 'Anthropic API key credential is configured, but Claude Max OAuth provider requires OAuth credentials.', + ); + } + // Get access token (auto-refreshes if expired) const accessToken = await authStorage.getApiKey('anthropic'); diff --git a/mastracode/src/providers/openai-codex.ts b/mastracode/src/providers/openai-codex.ts index 5c3d8c02572..953b973d6c2 100644 --- a/mastracode/src/providers/openai-codex.ts +++ b/mastracode/src/providers/openai-codex.ts @@ -33,8 +33,8 @@ export function getAuthStorage(): AuthStorage { /** * Set a custom AuthStorage instance (useful for TUI integration) */ -export function setAuthStorage(storage: AuthStorage): void { - authStorageInstance = storage; +export function setAuthStorage(storage: AuthStorage | undefined): void { + authStorageInstance = storage ?? null; } // Default instructions for Codex API (required) @@ -121,7 +121,7 @@ export function openaiCodexProvider( // Test environment: use API key if (process.env.NODE_ENV === 'test' || process.env.VITEST) { const openai = createOpenAI({ - apiKey: process.env.OPENAI_API_KEY || 'test-api-key', + apiKey: 'test-api-key', }); return wrapLanguageModel({ model: openai.responses(modelId), diff --git a/mastracode/src/tui/commands/models-pack.ts b/mastracode/src/tui/commands/models-pack.ts index 3c4ba69a856..98651db73b5 100644 --- a/mastracode/src/tui/commands/models-pack.ts +++ b/mastracode/src/tui/commands/models-pack.ts @@ -470,14 +470,15 @@ export async function handleModelsPackCommand(ctx: SlashCommandContext): Promise const models = await harness.listAvailableModels(); const hasEnv = (provider: string) => models.some(m => m.provider === provider && m.hasApiKey); - const accessLevel = (provider: string, oauthId: string): ProviderAccessLevel => { - if (ctx.authStorage?.isLoggedIn(oauthId)) return 'oauth'; - if (hasEnv(provider)) return 'apikey'; + const accessLevel = (storageProviderId: string): ProviderAccessLevel => { + const cred = ctx.authStorage?.get(storageProviderId); + if (cred?.type === 'oauth') return 'oauth'; + if (cred?.type === 'api_key' && cred.key.trim().length > 0) return 'apikey'; return false; }; const access: ProviderAccess = { - anthropic: accessLevel('anthropic', 'anthropic'), - openai: accessLevel('openai', 'openai-codex'), + anthropic: accessLevel('anthropic'), + openai: accessLevel('openai-codex'), cerebras: hasEnv('cerebras') ? ('apikey' as const) : false, google: hasEnv('google') ? ('apikey' as const) : false, deepseek: hasEnv('deepseek') ? ('apikey' as const) : false, diff --git a/mastracode/src/tui/mastra-tui.ts b/mastracode/src/tui/mastra-tui.ts index ebeb1fb89c2..0a6290f249c 100644 --- a/mastracode/src/tui/mastra-tui.ts +++ b/mastracode/src/tui/mastra-tui.ts @@ -360,14 +360,15 @@ export class MastraTUI { private async buildProviderAccess(): Promise { const models = await this.state.harness.listAvailableModels(); const hasEnv = (provider: string) => models.some(m => m.provider === provider && m.hasApiKey); - const accessLevel = (provider: string, oauthId: string): ProviderAccessLevel => { - if (this.state.authStorage?.isLoggedIn(oauthId)) return 'oauth'; - if (hasEnv(provider)) return 'apikey'; + const accessLevel = (storageProviderId: string): ProviderAccessLevel => { + const cred = this.state.authStorage?.get(storageProviderId); + if (cred?.type === 'oauth') return 'oauth'; + if (cred?.type === 'api_key' && cred.key.trim().length > 0) return 'apikey'; return false; }; const access: ProviderAccess = { - anthropic: accessLevel('anthropic', 'anthropic'), - openai: accessLevel('openai', 'openai-codex'), + anthropic: accessLevel('anthropic'), + openai: accessLevel('openai-codex'), cerebras: hasEnv('cerebras') ? ('apikey' as const) : false, google: hasEnv('google') ? ('apikey' as const) : false, deepseek: hasEnv('deepseek') ? ('apikey' as const) : false, diff --git a/packages/core/src/harness/harness.ts b/packages/core/src/harness/harness.ts index 897567b9597..6aa14d54bf0 100644 --- a/packages/core/src/harness/harness.ts +++ b/packages/core/src/harness/harness.ts @@ -1507,9 +1507,13 @@ export class Harness { break; } case 'file': + if (typeof part.data !== 'string') { + console.warn('[Harness] Skipping file part with non-string data:', typeof part.data); + break; + } content.push({ type: 'file', - data: typeof part.data === 'string' ? part.data : '', + data: part.data, mediaType: (part as { mediaType?: string }).mediaType ?? (part as { mimeType?: string }).mimeType ??