From 8d99cd6a9022383029088d5ca01a48fdc7a1376c Mon Sep 17 00:00:00 2001 From: Kiet <31864905+Kitenite@users.noreply.github.com> Date: Thu, 26 Feb 2026 15:04:01 -0800 Subject: [PATCH 01/13] feat(mastracode): support extra tools and auth-only storage init (#1) --- .../src/__tests__/create-auth-storage.test.ts | 13 ++ mastracode/src/agents/__tests__/tools.test.ts | 134 ++++++++++++++++++ mastracode/src/agents/tools.ts | 40 +++++- mastracode/src/index.ts | 29 ++-- 4 files changed, 203 insertions(+), 13 deletions(-) create mode 100644 mastracode/src/__tests__/create-auth-storage.test.ts create mode 100644 mastracode/src/agents/__tests__/tools.test.ts diff --git a/mastracode/src/__tests__/create-auth-storage.test.ts b/mastracode/src/__tests__/create-auth-storage.test.ts new file mode 100644 index 00000000000..a3f36a7c0dd --- /dev/null +++ b/mastracode/src/__tests__/create-auth-storage.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest'; +import { createAuthStorage } from '../index.js'; +import { getAuthStorage as getClaudeAuthStorage } from '../providers/claude-max.js'; +import { getAuthStorage as getOpenAIAuthStorage } from '../providers/openai-codex.js'; + +describe('createAuthStorage', () => { + it('wires a shared auth storage instance to provider modules', () => { + const authStorage = createAuthStorage(); + + expect(getClaudeAuthStorage()).toBe(authStorage); + expect(getOpenAIAuthStorage()).toBe(authStorage); + }); +}); diff --git a/mastracode/src/agents/__tests__/tools.test.ts b/mastracode/src/agents/__tests__/tools.test.ts new file mode 100644 index 00000000000..032ad8cb399 --- /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/tools.ts b/mastracode/src/agents/tools.ts index c4e78e86df9..ce1ee23bce8 100644 --- a/mastracode/src/agents/tools.ts +++ b/mastracode/src/agents/tools.ts @@ -1,6 +1,7 @@ import { createAnthropic } from '@ai-sdk/anthropic'; 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 { @@ -17,7 +18,40 @@ import { requestSandboxAccessTool, } from '../tools'; -export function createDynamicTools(mcpManager?: McpManager, extraTools?: Record) { +function wrapToolWithHooks(toolName: string, tool: any, hookManager?: HookManager): any { + 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, hookManager?: HookManager) { return function getDynamicTools({ requestContext }: { requestContext: RequestContext }) { const ctx = requestContext.get('harness') as HarnessRequestContext | undefined; const state = ctx?.getState?.(); @@ -84,6 +118,10 @@ export function createDynamicTools(mcpManager?: McpManager, extraTools?: Record< } } + 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 93efea3e886..de0bb45eb6e 100644 --- a/mastracode/src/index.ts +++ b/mastracode/src/index.ts @@ -65,13 +65,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); @@ -96,15 +101,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'); @@ -114,6 +110,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), + }); + // Build subagent definitions with project-scoped tools const viewTool = createViewTool(project.rootPath); const grepTool = createGrepTool(project.rootPath); From 6fd8e2c7ea5682edf292f95f4b34d50986f47a51 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Thu, 26 Feb 2026 16:56:38 -0800 Subject: [PATCH 02/13] =?UTF-8?q?feat(harness):=20rename=20images=E2=86=92?= =?UTF-8?q?files=20and=20use=20AI=20SDK=20FilePart=20shape=20(#2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(harness): rename images→files and use AI SDK FilePart shape - sendMessage now accepts `files` (matching AI SDK FilePart: data, mediaType, filename) instead of `images` (custom shape with mimeType field mismatch) - Mapping is now a direct passthrough instead of pointless field rename - Add `type: 'file'` to HarnessMessageContent union for generic file parts - Handle `file` parts in convertToHarnessMessage for history reload * fix(mastracode): remap images→files in TUI fireMessage The harness sendMessage param was renamed from `images` (custom shape) to `files` (AI SDK FilePart shape). Remap at the TUI boundary so clipboard paste still works. --- mastracode/src/tui/mastra-tui.ts | 3 ++- packages/core/src/harness/harness.ts | 20 ++++++++++++-------- packages/core/src/harness/types.ts | 1 + 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/mastracode/src/tui/mastra-tui.ts b/mastracode/src/tui/mastra-tui.ts index 45d1f19a2cb..de9d5d74e4f 100644 --- a/mastracode/src/tui/mastra-tui.ts +++ b/mastracode/src/tui/mastra-tui.ts @@ -215,7 +215,8 @@ export class MastraTUI { * Errors are handled via harness events. */ private fireMessage(content: string, images?: Array<{ data: string; mimeType: string }>): void { - this.state.harness.sendMessage({ content, images: images ? images : undefined }).catch(error => { + const files = images?.map(img => ({ data: img.data, mediaType: img.mimeType })); + this.state.harness.sendMessage({ content, files }).catch(error => { showError(this.state, error instanceof Error ? error.message : 'Unknown error'); }); } diff --git a/packages/core/src/harness/harness.ts b/packages/core/src/harness/harness.ts index 366fcd5e9c7..fe7902f3f8a 100644 --- a/packages/core/src/harness/harness.ts +++ b/packages/core/src/harness/harness.ts @@ -1059,12 +1059,12 @@ export class Harness { */ async sendMessage({ content, - images, + files, tracingContext, tracingOptions, }: { content: string; - images?: Array<{ data: string; mimeType: string }>; + files?: Array<{ data: string; mediaType: string; filename?: string }>; tracingContext?: TracingContext; tracingOptions?: TracingOptions; }): Promise { @@ -1098,16 +1098,12 @@ export class Harness { streamOptions.toolsets = await this.buildToolsets(requestContext); let messageInput: string | Record = content; - if (images?.length) { + if (files?.length) { messageInput = { role: 'user', content: [ { type: 'text', text: content }, - ...images.map((img: { data: string; mimeType: string }) => ({ - type: 'file', - data: img.data, - mediaType: img.mimeType, - })), + ...files.map(f => ({ type: 'file' as const, ...f })), ], }; } @@ -1297,6 +1293,14 @@ export class Harness { }); break; } + case 'file': + content.push({ + type: 'file', + data: typeof part.data === 'string' ? part.data : '', + mediaType: (part as { mediaType?: string }).mediaType ?? 'application/octet-stream', + ...((part as { filename?: string }).filename ? { filename: (part as { filename?: string }).filename } : {}), + }); + break; // Skip other part types (step-start, data-om-status, etc.) } } diff --git a/packages/core/src/harness/types.ts b/packages/core/src/harness/types.ts index d2f9523824c..fb5a53d5cb5 100644 --- a/packages/core/src/harness/types.ts +++ b/packages/core/src/harness/types.ts @@ -764,6 +764,7 @@ export type HarnessMessageContent = | { type: 'tool_call'; id: string; name: string; args: unknown } | { type: 'tool_result'; id: string; name: string; result: unknown; isError: boolean } | { type: 'image'; data: string; mimeType: string } + | { type: 'file'; data: string; mediaType: string; filename?: string } | { type: 'om_observation_start'; tokensToObserve: number; From 64f6b276cb4f5030a957ba293bead2d89d490643 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Fri, 27 Feb 2026 14:58:18 -0800 Subject: [PATCH 03/13] fix: address PR review feedback and add changesets - Add afterEach cleanup in auth storage test for isolation - Skip malformed file parts (non-string data) instead of silently coercing - Add changesets for mastracode (minor) and @mastra/core (patch) Co-Authored-By: Claude Opus 4.6 --- .changeset/huge-boxes-travel.md | 6 ++++++ .changeset/real-wolves-thank.md | 6 ++++++ .../src/__tests__/create-auth-storage.test.ts | 11 ++++++++--- packages/core/src/harness/harness.ts | 13 +++++++------ 4 files changed, 27 insertions(+), 9 deletions(-) create mode 100644 .changeset/huge-boxes-travel.md create mode 100644 .changeset/real-wolves-thank.md diff --git a/.changeset/huge-boxes-travel.md b/.changeset/huge-boxes-travel.md new file mode 100644 index 00000000000..584726bb19c --- /dev/null +++ b/.changeset/huge-boxes-travel.md @@ -0,0 +1,6 @@ +--- +'@mastra/core': patch +'mastracode': patch +--- + +Renamed images to files in harness sendMessage to align with AI SDK FilePart shape (data, mediaType, filename) diff --git a/.changeset/real-wolves-thank.md b/.changeset/real-wolves-thank.md new file mode 100644 index 00000000000..dd39a134902 --- /dev/null +++ b/.changeset/real-wolves-thank.md @@ -0,0 +1,6 @@ +--- +'mastracode': minor +'@mastra/core': patch +--- + +Added pre/post hook wrapping for tool execution and exported createAuthStorage for standalone auth provider initialization diff --git a/mastracode/src/__tests__/create-auth-storage.test.ts b/mastracode/src/__tests__/create-auth-storage.test.ts index a3f36a7c0dd..265ffd21ddc 100644 --- a/mastracode/src/__tests__/create-auth-storage.test.ts +++ b/mastracode/src/__tests__/create-auth-storage.test.ts @@ -1,9 +1,14 @@ -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it } from 'vitest'; import { createAuthStorage } from '../index.js'; -import { getAuthStorage as getClaudeAuthStorage } from '../providers/claude-max.js'; -import { getAuthStorage as getOpenAIAuthStorage } from '../providers/openai-codex.js'; +import { getAuthStorage as getClaudeAuthStorage, setAuthStorage as setClaudeAuthStorage } from '../providers/claude-max.js'; +import { getAuthStorage as getOpenAIAuthStorage, setAuthStorage as setOpenAIAuthStorage } from '../providers/openai-codex.js'; describe('createAuthStorage', () => { + afterEach(() => { + setClaudeAuthStorage(undefined as any); + setOpenAIAuthStorage(undefined as any); + }); + it('wires a shared auth storage instance to provider modules', () => { const authStorage = createAuthStorage(); diff --git a/packages/core/src/harness/harness.ts b/packages/core/src/harness/harness.ts index fe7902f3f8a..655355c74a7 100644 --- a/packages/core/src/harness/harness.ts +++ b/packages/core/src/harness/harness.ts @@ -1101,10 +1101,7 @@ export class Harness { if (files?.length) { messageInput = { role: 'user', - content: [ - { type: 'text', text: content }, - ...files.map(f => ({ type: 'file' as const, ...f })), - ], + content: [{ type: 'text', text: content }, ...files.map(f => ({ type: 'file' as const, ...f }))], }; } @@ -1293,14 +1290,18 @@ export class Harness { }); break; } - case 'file': + case 'file': { + if (typeof part.data !== 'string') { + break; + } content.push({ type: 'file', - data: typeof part.data === 'string' ? part.data : '', + data: part.data, mediaType: (part as { mediaType?: string }).mediaType ?? 'application/octet-stream', ...((part as { filename?: string }).filename ? { filename: (part as { filename?: string }).filename } : {}), }); break; + } // Skip other part types (step-start, data-om-status, etc.) } } From 4811be2b3f0e1b1247f7a28007c8c14375f22598 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Fri, 27 Feb 2026 15:00:02 -0800 Subject: [PATCH 04/13] chore(ci): add daily upstream sync workflow for forks --- .changeset/thin-walls-bet.md | 5 +++ .github/workflows/sync-upstream-fork.yml | 52 ++++++++++++++++++++++++ README.md | 2 + 3 files changed, 59 insertions(+) create mode 100644 .changeset/thin-walls-bet.md create mode 100644 .github/workflows/sync-upstream-fork.yml diff --git a/.changeset/thin-walls-bet.md b/.changeset/thin-walls-bet.md new file mode 100644 index 00000000000..09082c5da39 --- /dev/null +++ b/.changeset/thin-walls-bet.md @@ -0,0 +1,5 @@ +--- +--- + +Added a daily GitHub Action workflow for forks to sync the default branch from `mastra-ai/mastra`. +This is an infrastructure-only change and does not modify published packages. diff --git a/.github/workflows/sync-upstream-fork.yml b/.github/workflows/sync-upstream-fork.yml new file mode 100644 index 00000000000..e305f11c035 --- /dev/null +++ b/.github/workflows/sync-upstream-fork.yml @@ -0,0 +1,52 @@ +name: Sync Fork With Upstream + +permissions: + contents: write + +on: + schedule: + - cron: '0 6 * * *' + workflow_dispatch: + +jobs: + sync-upstream: + # Run only on forks. Upstream repository does not need this job. + if: ${{ github.repository != 'mastra-ai/mastra' }} + runs-on: ubuntu-latest + env: + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + UPSTREAM_REPO: mastra-ai/mastra + steps: + - name: Checkout repository + uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Configure Git user + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - name: Sync default branch from upstream + run: | + set -euo pipefail + + if git remote get-url upstream >/dev/null 2>&1; then + git remote set-url upstream "https://github.com/${UPSTREAM_REPO}.git" + else + git remote add upstream "https://github.com/${UPSTREAM_REPO}.git" + fi + + git fetch origin "${DEFAULT_BRANCH}" + git fetch upstream "${DEFAULT_BRANCH}" + + git checkout -B "${DEFAULT_BRANCH}" "origin/${DEFAULT_BRANCH}" + + UPSTREAM_NEW_COMMITS="$(git rev-list --count "origin/${DEFAULT_BRANCH}..upstream/${DEFAULT_BRANCH}")" + if [ "${UPSTREAM_NEW_COMMITS}" -eq 0 ]; then + echo "No upstream changes to sync." + exit 0 + fi + + git merge --no-edit "upstream/${DEFAULT_BRANCH}" + git push origin "${DEFAULT_BRANCH}" diff --git a/README.md b/README.md index 6513a8147b4..62b13f3974a 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,8 @@ If you are a developer and would like to contribute with code, please open an is Information about the project setup can be found in the [development documentation](./DEVELOPMENT.md) +For fork maintainers: this repository includes a daily GitHub Action at `.github/workflows/sync-upstream-fork.yml` that syncs your fork's default branch from `mastra-ai/mastra`. + ## Support We have an [open community Discord](https://discord.gg/BTYqqHKUrf). Come and say hello and let us know if you have any questions or need any help getting things running. From f12ed132cba7728ad03476493baae9affc94b303 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Fri, 27 Feb 2026 15:11:57 -0800 Subject: [PATCH 05/13] docs: improve changeset descriptions with migration examples Co-Authored-By: Claude Opus 4.6 --- .changeset/huge-boxes-travel.md | 14 +++++++++++++- .changeset/real-wolves-thank.md | 13 ++++++++++++- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/.changeset/huge-boxes-travel.md b/.changeset/huge-boxes-travel.md index 584726bb19c..843a3fccced 100644 --- a/.changeset/huge-boxes-travel.md +++ b/.changeset/huge-boxes-travel.md @@ -3,4 +3,16 @@ 'mastracode': patch --- -Renamed images to files in harness sendMessage to align with AI SDK FilePart shape (data, mediaType, filename) +Renamed `images` to `files` in `harness.sendMessage(...)` to align with the AI SDK `FilePart` shape. + +**Migration** + +Before: +```ts +await harness.sendMessage({ content: "Hi", images: [{ data, mimeType }] }); +``` + +After: +```ts +await harness.sendMessage({ content: "Hi", files: [{ data, mediaType, filename }] }); +``` diff --git a/.changeset/real-wolves-thank.md b/.changeset/real-wolves-thank.md index dd39a134902..9da1cc8518a 100644 --- a/.changeset/real-wolves-thank.md +++ b/.changeset/real-wolves-thank.md @@ -3,4 +3,15 @@ '@mastra/core': patch --- -Added pre/post hook wrapping for tool execution and exported createAuthStorage for standalone auth provider initialization +Added pre/post hook wrapping for tool execution via `HookManager` and exported `createAuthStorage` for standalone auth provider initialization. + +`@mastra/core` receives a patch bump as a peer dependency of `mastracode`. + +**New API: `createAuthStorage`** + +```ts +import { createAuthStorage } from 'mastracode'; + +const authStorage = createAuthStorage(); +// authStorage is now wired into Claude Max and OpenAI Codex providers +``` From e3e435117970a7bc74eb61f66f9394c2eb6c8650 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Fri, 27 Feb 2026 18:03:38 -0800 Subject: [PATCH 06/13] fix(mastracode): route anthropic api_key away from oauth bearer --- mastracode/src/agents/__tests__/model.test.ts | 21 +++++++++++++++++-- mastracode/src/agents/model.ts | 14 ++++++++++--- mastracode/src/providers/claude-max.ts | 7 +++++++ 3 files changed, 37 insertions(+), 5 deletions(-) diff --git a/mastracode/src/agents/__tests__/model.test.ts b/mastracode/src/agents/__tests__/model.test.ts index af11a9e0e64..dc09bb7a111 100644 --- a/mastracode/src/agents/__tests__/model.test.ts +++ b/mastracode/src/agents/__tests__/model.test.ts @@ -68,15 +68,32 @@ describe('resolveModel', () => { }); describe('anthropic/* models', () => { - it('prefers Claude Max OAuth when logged in, even if API key is present', () => { + it('prefers Claude Max OAuth when stored OAuth credential exists, even if API key is present', () => { process.env.ANTHROPIC_API_KEY = 'sk-test-key-123'; - mockAuthStorageInstance.isLoggedIn.mockImplementation((p: string) => p === 'anthropic'); + 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('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; + + expect(result.__provider).toBe('anthropic-direct'); + expect(result.__wrapped).toBe(true); + expect(result.modelId).toBe('claude-sonnet-4-20250514'); + expect(opencodeClaudeMaxProvider).not.toHaveBeenCalled(); + }); + 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); diff --git a/mastracode/src/agents/model.ts b/mastracode/src/agents/model.ts index 8e4c027e967..452c5ca2d00 100644 --- a/mastracode/src/agents/model.ts +++ b/mastracode/src/agents/model.ts @@ -81,7 +81,7 @@ function anthropicApiKeyProvider(modelId: string, apiKey: string): LanguageModel * 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 anthropic/* models: Uses stored OAuth credentials when present, otherwise direct API key * - For openai/* models with OAuth: Uses OpenAI Codex OAuth provider * - For moonshotai/* models: Uses Moonshot AI Anthropic-compatible endpoint * - For all other providers: Uses Mastra's model router (models.dev gateway) @@ -106,10 +106,18 @@ 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); } + + // 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 (env var or stored credential) const apiKey = getAnthropicApiKey(); if (apiKey) { diff --git a/mastracode/src/providers/claude-max.ts b/mastracode/src/providers/claude-max.ts index 93328a30176..bd6c35fae69 100644 --- a/mastracode/src/providers/claude-max.ts +++ b/mastracode/src/providers/claude-max.ts @@ -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'); From 0444fb0db85e6b7c5066592608fe504217fcac5b Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Fri, 27 Feb 2026 18:12:06 -0800 Subject: [PATCH 07/13] fix(mastracode): use auth storage only for anthropic/openai auth --- mastracode/src/agents/__tests__/model.test.ts | 80 +++++++++++++------ mastracode/src/agents/model.ts | 64 +++++++++++---- mastracode/src/index.ts | 33 +++++++- mastracode/src/providers/claude-max.ts | 2 +- mastracode/src/providers/openai-codex.ts | 2 +- mastracode/src/tui/commands/models-pack.ts | 11 +-- mastracode/src/tui/mastra-tui.ts | 11 +-- 7 files changed, 148 insertions(+), 55 deletions(-) diff --git a/mastracode/src/agents/__tests__/model.test.ts b/mastracode/src/agents/__tests__/model.test.ts index dc09bb7a111..f5de3b3a94b 100644 --- a/mastracode/src/agents/__tests__/model.test.ts +++ b/mastracode/src/agents/__tests__/model.test.ts @@ -35,6 +35,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 }) => ({ @@ -52,7 +63,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 }; @@ -68,8 +80,7 @@ describe('resolveModel', () => { }); describe('anthropic/* models', () => { - it('prefers Claude Max OAuth when stored OAuth credential exists, even if API key is present', () => { - process.env.ANTHROPIC_API_KEY = 'sk-test-key-123'; + it('prefers Claude Max OAuth when stored OAuth credential exists', () => { mockAuthStorageInstance.get.mockReturnValue({ type: 'oauth', access: 'oauth-access-token', @@ -94,16 +105,14 @@ describe('resolveModel', () => { expect(opencodeClaudeMaxProvider).not.toHaveBeenCalled(); }); - it('falls back to API key when not logged in via OAuth', () => { + it('does not use env API key when no stored Anthropic credential exists', () => { process.env.ANTHROPIC_API_KEY = 'sk-test-key-123'; - mockAuthStorageInstance.isLoggedIn.mockReturnValue(false); + mockAuthStorageInstance.get.mockReturnValue(undefined); const result = resolveModel('anthropic/claude-sonnet-4-20250514') as Record; - expect(result.__provider).toBe('anthropic-direct'); - expect(result.__wrapped).toBe(true); - expect(result.modelId).toBe('claude-sonnet-4-20250514'); - expect(opencodeClaudeMaxProvider).not.toHaveBeenCalled(); + 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', () => { @@ -119,7 +128,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'); @@ -135,14 +143,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'); }); @@ -168,12 +190,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'); }); @@ -188,9 +205,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/model.ts b/mastracode/src/agents/model.ts index 452c5ca2d00..63b57fee844 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'; @@ -26,7 +27,8 @@ type ResolvedModel = | ReturnType | ReturnType | ModelRouterLanguageModel - | ReturnType>; + | ReturnType> + | ReturnType>; export function remapOpenAIModelForCodexOAuth(modelId: string): string { if (!modelId.startsWith(OPENAI_PREFIX)) { @@ -48,18 +50,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; } @@ -77,12 +87,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: Uses stored OAuth credentials when present, otherwise direct API key - * - For openai/* models with OAuth: Uses OpenAI Codex OAuth provider + * - 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) */ @@ -118,18 +138,30 @@ export function resolveModel( return anthropicApiKeyProvider(bareModelId, storedCred.key.trim()); } - // Fallback: direct API key (env var or stored credential) + // 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/index.ts b/mastracode/src/index.ts index de0bb45eb6e..5a7ea050237 100644 --- a/mastracode/src/index.ts +++ b/mastracode/src/index.ts @@ -201,11 +201,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, @@ -215,6 +227,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])) { @@ -286,6 +299,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; + } + } return undefined; }, modelUseCountProvider: () => loadSettings().modelUseCounts, diff --git a/mastracode/src/providers/claude-max.ts b/mastracode/src/providers/claude-max.ts index bd6c35fae69..8404ca00c2a 100644 --- a/mastracode/src/providers/claude-max.ts +++ b/mastracode/src/providers/claude-max.ts @@ -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), diff --git a/mastracode/src/providers/openai-codex.ts b/mastracode/src/providers/openai-codex.ts index 5c3d8c02572..fcf5e57128e 100644 --- a/mastracode/src/providers/openai-codex.ts +++ b/mastracode/src/providers/openai-codex.ts @@ -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 5159273f09c..51c502cbbfa 100644 --- a/mastracode/src/tui/commands/models-pack.ts +++ b/mastracode/src/tui/commands/models-pack.ts @@ -469,14 +469,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 de9d5d74e4f..fca7eedff55 100644 --- a/mastracode/src/tui/mastra-tui.ts +++ b/mastracode/src/tui/mastra-tui.ts @@ -339,14 +339,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, From 5cf97e0059c7339d5556e4a3d6ede84643542f31 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Sun, 1 Mar 2026 21:15:15 -0800 Subject: [PATCH 08/13] feat(mastracode): add disabledTools API and extract permission rule utils --- .../src/__tests__/permission-rules.test.ts | 71 +++++++++++++++++++ .../src/agents/__tests__/extra-tools.test.ts | 32 +++++++++ mastracode/src/agents/tools.ts | 14 +++- mastracode/src/index.ts | 50 +++++++++++-- mastracode/src/utils/permission-rules.ts | 67 +++++++++++++++++ 5 files changed, 227 insertions(+), 7 deletions(-) create mode 100644 mastracode/src/__tests__/permission-rules.test.ts create mode 100644 mastracode/src/utils/permission-rules.ts diff --git a/mastracode/src/__tests__/permission-rules.test.ts b/mastracode/src/__tests__/permission-rules.test.ts new file mode 100644 index 00000000000..a2088313249 --- /dev/null +++ b/mastracode/src/__tests__/permission-rules.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest'; +import { + arePermissionRulesEqual, + hasPermissionRules, + mergePermissionRules, + normalizePermissionRules, +} from '../utils/permission-rules.js'; + +describe('permission-rules utils', () => { + it('normalizes unknown input into an empty rules object', () => { + expect(normalizePermissionRules(undefined)).toEqual({ categories: {}, tools: {} }); + expect(normalizePermissionRules('invalid')).toEqual({ categories: {}, tools: {} }); + }); + + it('drops invalid policy entries during normalization', () => { + const normalized = normalizePermissionRules({ + categories: { + read: 'allow', + edit: 'deny', + execute: 'invalid', + }, + tools: { + request_sandbox_access: 'deny', + view: 'wrong', + }, + }); + + expect(normalized).toEqual({ + categories: { read: 'allow', edit: 'deny' }, + tools: { request_sandbox_access: 'deny' }, + }); + }); + + it('merges category and tool overrides', () => { + const merged = mergePermissionRules( + { + categories: { read: 'allow', edit: 'ask' }, + tools: { view: 'allow' }, + }, + { + categories: { edit: 'deny', execute: 'allow' }, + tools: { view: 'deny', write_file: 'deny' }, + }, + ); + + expect(merged).toEqual({ + categories: { read: 'allow', edit: 'deny', execute: 'allow' }, + tools: { view: 'deny', write_file: 'deny' }, + }); + }); + + it('detects equality and non-empty rules', () => { + const a = { + categories: { edit: 'ask' as const }, + tools: { write_file: 'deny' as const }, + }; + const b = { + categories: { edit: 'ask' as const }, + tools: { write_file: 'deny' as const }, + }; + const c = { + categories: { edit: 'deny' as const }, + tools: { write_file: 'deny' as const }, + }; + + expect(arePermissionRulesEqual(a, b)).toBe(true); + expect(arePermissionRulesEqual(a, c)).toBe(false); + expect(hasPermissionRules({ categories: {}, tools: {} })).toBe(false); + expect(hasPermissionRules(a)).toBe(true); + }); +}); diff --git a/mastracode/src/agents/__tests__/extra-tools.test.ts b/mastracode/src/agents/__tests__/extra-tools.test.ts index 19939a5d193..ada2adf8f5f 100644 --- a/mastracode/src/agents/__tests__/extra-tools.test.ts +++ b/mastracode/src/agents/__tests__/extra-tools.test.ts @@ -184,6 +184,38 @@ 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'); + expect(tools).toHaveProperty('view'); + }); + + 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/tools.ts b/mastracode/src/agents/tools.ts index 87ac213fddf..8b42812409a 100644 --- a/mastracode/src/agents/tools.ts +++ b/mastracode/src/agents/tools.ts @@ -52,7 +52,12 @@ function wrapToolWithHooks(toolName: string, tool: any, hookManager?: HookManage }; } -export function createDynamicTools(mcpManager?: McpManager, extraTools?: Record, hookManager?: HookManager) { +export function createDynamicTools( + mcpManager?: McpManager, + extraTools?: Record, + hookManager?: HookManager, + disabledTools?: string[], +) { return function getDynamicTools({ requestContext }: { requestContext: RequestContext }) { const ctx = requestContext.get('harness') as HarnessRequestContext | undefined; const state = ctx?.getState?.(); @@ -113,6 +118,13 @@ export function createDynamicTools(mcpManager?: McpManager, extraTools?: Record< } } + // 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) { diff --git a/mastracode/src/index.ts b/mastracode/src/index.ts index 5a7ea050237..6ddf03f8ec5 100644 --- a/mastracode/src/index.ts +++ b/mastracode/src/index.ts @@ -34,6 +34,14 @@ import { } from './tools/index.js'; import { mastra } from './tui/theme.js'; import { syncGateways } from './utils/gateway-sync.js'; +import { + arePermissionRulesEqual, + hasPermissionRules, + mergePermissionRules, + normalizePermissionRules, + toObjectRecord, + type MastraCodePermissionRules, +} from './utils/permission-rules.js'; import { detectProject, getStorageConfig, getResourceIdOverride } from './utils/project.js'; import type { StorageConfig } from './utils/project.js'; import { createStorage } from './utils/storage-factory.js'; @@ -43,6 +51,7 @@ const PROVIDER_TO_OAUTH_ID: Record = { anthropic: 'anthropic', openai: 'openai-codex', }; +export type { MastraCodePermissionRules } from './utils/permission-rules.js'; export interface MastraCodeConfig { /** Working directory for project detection. Default: process.cwd() */ @@ -53,10 +62,14 @@ export interface MastraCodeConfig { subagents?: HarnessSubagent[]; /** Extra tools merged into the dynamic tool set */ extraTools?: Record; + /** 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.) */ initialState?: Record; + /** Permission rule overrides applied on startup and thread switches */ + permissionRules?: MastraCodePermissionRules; /** Override heartbeat handlers. Default: gateway-sync */ heartbeatHandlers?: HeartbeatHandler[]; /** Disable MCP server discovery. Default: false */ @@ -116,7 +129,7 @@ export async function createMastraCode(config?: MastraCodeConfig) { name: 'Code Agent', instructions: getDynamicInstructions, model: getDynamicModel, - tools: createDynamicTools(mcpManager, config?.extraTools, hookManager), + tools: createDynamicTools(mcpManager, config?.extraTools, hookManager, config?.disabledTools), }); // Build subagent definitions with project-scoped tools @@ -273,6 +286,14 @@ export async function createMastraCode(config?: MastraCodeConfig) { globalInitialState[`subagentModelId_${key}`] = modelId; } } + const initialStateOverrides = config?.initialState ?? {}; + const initialStateRecord = toObjectRecord(initialStateOverrides) ?? {}; + const configuredPermissionRules = normalizePermissionRules(config?.permissionRules); + const hasConfiguredPermissionRules = hasPermissionRules(configuredPermissionRules); + const mergedInitialPermissionRules = mergePermissionRules( + normalizePermissionRules(initialStateRecord.permissionRules), + configuredPermissionRules, + ); const harness = new Harness({ id: 'mastra-code', @@ -289,7 +310,8 @@ export async function createMastraCode(config?: MastraCodeConfig) { gitBranch: project.gitBranch, yolo: true, ...globalInitialState, - ...config?.initialState, + ...initialStateOverrides, + permissionRules: mergedInitialPermissionRules, }, workspace: getDynamicWorkspace, modes, @@ -329,16 +351,32 @@ export async function createMastraCode(config?: MastraCodeConfig) { }, }); - // Sync hookManager session ID on thread changes - if (hookManager) { + const applyConfiguredPermissionRules = (): void => { + if (!hasConfiguredPermissionRules) return; + const stateRecord = toObjectRecord(harness.getState()) ?? {}; + const currentRules = normalizePermissionRules(stateRecord.permissionRules); + const targetRules = mergePermissionRules(currentRules, configuredPermissionRules); + if (arePermissionRulesEqual(currentRules, targetRules)) { + return; + } + void harness.setState({ + permissionRules: targetRules, + }); + }; + + // Sync hookManager session ID and configured permission rules on thread changes + if (hookManager || hasConfiguredPermissionRules) { harness.subscribe(event => { if (event.type === 'thread_changed') { - hookManager.setSessionId(event.threadId); + hookManager?.setSessionId(event.threadId); + applyConfiguredPermissionRules(); } else if (event.type === 'thread_created') { - hookManager.setSessionId(event.thread.id); + hookManager?.setSessionId(event.thread.id); + applyConfiguredPermissionRules(); } }); } + applyConfiguredPermissionRules(); return { harness, mcpManager, hookManager, authStorage, storageWarning }; } diff --git a/mastracode/src/utils/permission-rules.ts b/mastracode/src/utils/permission-rules.ts new file mode 100644 index 00000000000..efef8989b07 --- /dev/null +++ b/mastracode/src/utils/permission-rules.ts @@ -0,0 +1,67 @@ +import type { PermissionPolicy, PermissionRules } from '../permissions.js'; + +/** Permission rule overrides merged into runtime permission rules */ +export type MastraCodePermissionRules = Partial; + +export function toObjectRecord(value: unknown): Record | null { + if (typeof value === 'object' && value !== null) { + return value as Record; + } + return null; +} + +function isPermissionPolicy(value: unknown): value is PermissionPolicy { + return value === 'allow' || value === 'ask' || value === 'deny'; +} + +export function normalizePermissionRules(value: unknown): PermissionRules { + const record = toObjectRecord(value); + const categoriesRecord = toObjectRecord(record?.categories); + const toolsRecord = toObjectRecord(record?.tools); + + const categories: Record = {}; + if (categoriesRecord) { + for (const [name, policy] of Object.entries(categoriesRecord)) { + if (isPermissionPolicy(policy)) categories[name] = policy; + } + } + + const tools: Record = {}; + if (toolsRecord) { + for (const [name, policy] of Object.entries(toolsRecord)) { + if (isPermissionPolicy(policy)) tools[name] = policy; + } + } + + return { categories, tools }; +} + +export function mergePermissionRules(base: PermissionRules, overrides: PermissionRules): PermissionRules { + return { + categories: { + ...base.categories, + ...overrides.categories, + }, + tools: { + ...base.tools, + ...overrides.tools, + }, + }; +} + +function areStringMapsEqual(a: Record, b: Record): boolean { + const aKeys = Object.keys(a); + if (aKeys.length !== Object.keys(b).length) return false; + for (const key of aKeys) { + if (a[key] !== b[key]) return false; + } + return true; +} + +export function arePermissionRulesEqual(a: PermissionRules, b: PermissionRules): boolean { + return areStringMapsEqual(a.categories, b.categories) && areStringMapsEqual(a.tools, b.tools); +} + +export function hasPermissionRules(rules: PermissionRules): boolean { + return Object.keys(rules.categories).length > 0 || Object.keys(rules.tools).length > 0; +} From dd13f271904da4a294e2aea155de772d6beba405 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Sun, 1 Mar 2026 21:29:31 -0800 Subject: [PATCH 09/13] refactor(mastracode): keep disabledTools change focused --- .../src/__tests__/permission-rules.test.ts | 71 ------------------- mastracode/src/index.ts | 47 ++---------- mastracode/src/utils/permission-rules.ts | 67 ----------------- 3 files changed, 5 insertions(+), 180 deletions(-) delete mode 100644 mastracode/src/__tests__/permission-rules.test.ts delete mode 100644 mastracode/src/utils/permission-rules.ts diff --git a/mastracode/src/__tests__/permission-rules.test.ts b/mastracode/src/__tests__/permission-rules.test.ts deleted file mode 100644 index a2088313249..00000000000 --- a/mastracode/src/__tests__/permission-rules.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { - arePermissionRulesEqual, - hasPermissionRules, - mergePermissionRules, - normalizePermissionRules, -} from '../utils/permission-rules.js'; - -describe('permission-rules utils', () => { - it('normalizes unknown input into an empty rules object', () => { - expect(normalizePermissionRules(undefined)).toEqual({ categories: {}, tools: {} }); - expect(normalizePermissionRules('invalid')).toEqual({ categories: {}, tools: {} }); - }); - - it('drops invalid policy entries during normalization', () => { - const normalized = normalizePermissionRules({ - categories: { - read: 'allow', - edit: 'deny', - execute: 'invalid', - }, - tools: { - request_sandbox_access: 'deny', - view: 'wrong', - }, - }); - - expect(normalized).toEqual({ - categories: { read: 'allow', edit: 'deny' }, - tools: { request_sandbox_access: 'deny' }, - }); - }); - - it('merges category and tool overrides', () => { - const merged = mergePermissionRules( - { - categories: { read: 'allow', edit: 'ask' }, - tools: { view: 'allow' }, - }, - { - categories: { edit: 'deny', execute: 'allow' }, - tools: { view: 'deny', write_file: 'deny' }, - }, - ); - - expect(merged).toEqual({ - categories: { read: 'allow', edit: 'deny', execute: 'allow' }, - tools: { view: 'deny', write_file: 'deny' }, - }); - }); - - it('detects equality and non-empty rules', () => { - const a = { - categories: { edit: 'ask' as const }, - tools: { write_file: 'deny' as const }, - }; - const b = { - categories: { edit: 'ask' as const }, - tools: { write_file: 'deny' as const }, - }; - const c = { - categories: { edit: 'deny' as const }, - tools: { write_file: 'deny' as const }, - }; - - expect(arePermissionRulesEqual(a, b)).toBe(true); - expect(arePermissionRulesEqual(a, c)).toBe(false); - expect(hasPermissionRules({ categories: {}, tools: {} })).toBe(false); - expect(hasPermissionRules(a)).toBe(true); - }); -}); diff --git a/mastracode/src/index.ts b/mastracode/src/index.ts index 6ddf03f8ec5..1a75c5d40f3 100644 --- a/mastracode/src/index.ts +++ b/mastracode/src/index.ts @@ -34,14 +34,6 @@ import { } from './tools/index.js'; import { mastra } from './tui/theme.js'; import { syncGateways } from './utils/gateway-sync.js'; -import { - arePermissionRulesEqual, - hasPermissionRules, - mergePermissionRules, - normalizePermissionRules, - toObjectRecord, - type MastraCodePermissionRules, -} from './utils/permission-rules.js'; import { detectProject, getStorageConfig, getResourceIdOverride } from './utils/project.js'; import type { StorageConfig } from './utils/project.js'; import { createStorage } from './utils/storage-factory.js'; @@ -51,7 +43,6 @@ const PROVIDER_TO_OAUTH_ID: Record = { anthropic: 'anthropic', openai: 'openai-codex', }; -export type { MastraCodePermissionRules } from './utils/permission-rules.js'; export interface MastraCodeConfig { /** Working directory for project detection. Default: process.cwd() */ @@ -68,8 +59,6 @@ export interface MastraCodeConfig { storage?: StorageConfig; /** Initial state overrides (yolo, thinkingLevel, etc.) */ initialState?: Record; - /** Permission rule overrides applied on startup and thread switches */ - permissionRules?: MastraCodePermissionRules; /** Override heartbeat handlers. Default: gateway-sync */ heartbeatHandlers?: HeartbeatHandler[]; /** Disable MCP server discovery. Default: false */ @@ -286,15 +275,6 @@ export async function createMastraCode(config?: MastraCodeConfig) { globalInitialState[`subagentModelId_${key}`] = modelId; } } - const initialStateOverrides = config?.initialState ?? {}; - const initialStateRecord = toObjectRecord(initialStateOverrides) ?? {}; - const configuredPermissionRules = normalizePermissionRules(config?.permissionRules); - const hasConfiguredPermissionRules = hasPermissionRules(configuredPermissionRules); - const mergedInitialPermissionRules = mergePermissionRules( - normalizePermissionRules(initialStateRecord.permissionRules), - configuredPermissionRules, - ); - const harness = new Harness({ id: 'mastra-code', resourceId: project.resourceId, @@ -310,8 +290,7 @@ export async function createMastraCode(config?: MastraCodeConfig) { gitBranch: project.gitBranch, yolo: true, ...globalInitialState, - ...initialStateOverrides, - permissionRules: mergedInitialPermissionRules, + ...config?.initialState, }, workspace: getDynamicWorkspace, modes, @@ -351,32 +330,16 @@ export async function createMastraCode(config?: MastraCodeConfig) { }, }); - const applyConfiguredPermissionRules = (): void => { - if (!hasConfiguredPermissionRules) return; - const stateRecord = toObjectRecord(harness.getState()) ?? {}; - const currentRules = normalizePermissionRules(stateRecord.permissionRules); - const targetRules = mergePermissionRules(currentRules, configuredPermissionRules); - if (arePermissionRulesEqual(currentRules, targetRules)) { - return; - } - void harness.setState({ - permissionRules: targetRules, - }); - }; - - // Sync hookManager session ID and configured permission rules on thread changes - if (hookManager || hasConfiguredPermissionRules) { + // Sync hookManager session ID on thread changes + if (hookManager) { harness.subscribe(event => { if (event.type === 'thread_changed') { - hookManager?.setSessionId(event.threadId); - applyConfiguredPermissionRules(); + hookManager.setSessionId(event.threadId); } else if (event.type === 'thread_created') { - hookManager?.setSessionId(event.thread.id); - applyConfiguredPermissionRules(); + hookManager.setSessionId(event.thread.id); } }); } - applyConfiguredPermissionRules(); return { harness, mcpManager, hookManager, authStorage, storageWarning }; } diff --git a/mastracode/src/utils/permission-rules.ts b/mastracode/src/utils/permission-rules.ts deleted file mode 100644 index efef8989b07..00000000000 --- a/mastracode/src/utils/permission-rules.ts +++ /dev/null @@ -1,67 +0,0 @@ -import type { PermissionPolicy, PermissionRules } from '../permissions.js'; - -/** Permission rule overrides merged into runtime permission rules */ -export type MastraCodePermissionRules = Partial; - -export function toObjectRecord(value: unknown): Record | null { - if (typeof value === 'object' && value !== null) { - return value as Record; - } - return null; -} - -function isPermissionPolicy(value: unknown): value is PermissionPolicy { - return value === 'allow' || value === 'ask' || value === 'deny'; -} - -export function normalizePermissionRules(value: unknown): PermissionRules { - const record = toObjectRecord(value); - const categoriesRecord = toObjectRecord(record?.categories); - const toolsRecord = toObjectRecord(record?.tools); - - const categories: Record = {}; - if (categoriesRecord) { - for (const [name, policy] of Object.entries(categoriesRecord)) { - if (isPermissionPolicy(policy)) categories[name] = policy; - } - } - - const tools: Record = {}; - if (toolsRecord) { - for (const [name, policy] of Object.entries(toolsRecord)) { - if (isPermissionPolicy(policy)) tools[name] = policy; - } - } - - return { categories, tools }; -} - -export function mergePermissionRules(base: PermissionRules, overrides: PermissionRules): PermissionRules { - return { - categories: { - ...base.categories, - ...overrides.categories, - }, - tools: { - ...base.tools, - ...overrides.tools, - }, - }; -} - -function areStringMapsEqual(a: Record, b: Record): boolean { - const aKeys = Object.keys(a); - if (aKeys.length !== Object.keys(b).length) return false; - for (const key of aKeys) { - if (a[key] !== b[key]) return false; - } - return true; -} - -export function arePermissionRulesEqual(a: PermissionRules, b: PermissionRules): boolean { - return areStringMapsEqual(a.categories, b.categories) && areStringMapsEqual(a.tools, b.tools); -} - -export function hasPermissionRules(rules: PermissionRules): boolean { - return Object.keys(rules.categories).length > 0 || Object.keys(rules.tools).length > 0; -} From ee0888de1f37f281e746f621f042521d3225f41e Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Tue, 3 Mar 2026 13:37:15 -0800 Subject: [PATCH 10/13] fix(mastracode): address PR review comments - Apply disabledTools filtering to subagent tool maps so disabled tools can't be bypassed through subagent delegation - Replace broad `any` types with ToolLike interface in tools.ts - Add console.warn for non-string file payloads in harness stream parser - Remove invalid changeset with empty frontmatter (infra-only change) - Accept undefined in setAuthStorage to enable clean test teardown - Remove `as any` casts in create-auth-storage test Co-Authored-By: Claude Opus 4.6 --- .changeset/thin-walls-bet.md | 5 ----- .../src/__tests__/create-auth-storage.test.ts | 4 ++-- mastracode/src/agents/tools.ts | 12 ++++++++--- mastracode/src/index.ts | 20 ++++++++++++++----- mastracode/src/providers/claude-max.ts | 4 ++-- mastracode/src/providers/openai-codex.ts | 4 ++-- packages/core/src/harness/harness.ts | 1 + 7 files changed, 31 insertions(+), 19 deletions(-) delete mode 100644 .changeset/thin-walls-bet.md diff --git a/.changeset/thin-walls-bet.md b/.changeset/thin-walls-bet.md deleted file mode 100644 index 09082c5da39..00000000000 --- a/.changeset/thin-walls-bet.md +++ /dev/null @@ -1,5 +0,0 @@ ---- ---- - -Added a daily GitHub Action workflow for forks to sync the default branch from `mastra-ai/mastra`. -This is an infrastructure-only change and does not modify published packages. diff --git a/mastracode/src/__tests__/create-auth-storage.test.ts b/mastracode/src/__tests__/create-auth-storage.test.ts index 265ffd21ddc..0d5d825cc19 100644 --- a/mastracode/src/__tests__/create-auth-storage.test.ts +++ b/mastracode/src/__tests__/create-auth-storage.test.ts @@ -5,8 +5,8 @@ import { getAuthStorage as getOpenAIAuthStorage, setAuthStorage as setOpenAIAuth describe('createAuthStorage', () => { afterEach(() => { - setClaudeAuthStorage(undefined as any); - setOpenAIAuthStorage(undefined as any); + setClaudeAuthStorage(undefined); + setOpenAIAuthStorage(undefined); }); it('wires a shared auth storage instance to provider modules', () => { diff --git a/mastracode/src/agents/tools.ts b/mastracode/src/agents/tools.ts index 038292e5ef5..7e64e4f1257 100644 --- a/mastracode/src/agents/tools.ts +++ b/mastracode/src/agents/tools.ts @@ -7,7 +7,13 @@ import type { McpManager } from '../mcp'; import type { stateSchema } from '../schema'; import { createWebSearchTool, createWebExtractTool, hasTavilyKey, requestSandboxAccessTool } from '../tools'; -function wrapToolWithHooks(toolName: string, tool: any, hookManager?: HookManager): any { +/** 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; } @@ -42,7 +48,7 @@ function wrapToolWithHooks(toolName: string, tool: any, hookManager?: HookManage export function createDynamicTools( mcpManager?: McpManager, - extraTools?: Record, + extraTools?: Record, hookManager?: HookManager, disabledTools?: string[], ) { @@ -57,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_sandbox_access: requestSandboxAccessTool, }; diff --git a/mastracode/src/index.ts b/mastracode/src/index.ts index d2a876529ed..23c65186b1a 100644 --- a/mastracode/src/index.ts +++ b/mastracode/src/index.ts @@ -59,7 +59,7 @@ export interface MastraCodeConfig { /** Override or extend subagent definitions. Default: explore/plan/execute */ subagents?: HarnessSubagent[]; /** Extra tools merged into the dynamic tool set */ - extraTools?: Record; + extraTools?: Record 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 */ @@ -136,11 +136,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[] = [ { @@ -165,14 +175,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, - }, + }), }, ]; diff --git a/mastracode/src/providers/claude-max.ts b/mastracode/src/providers/claude-max.ts index 8404ca00c2a..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; } /** diff --git a/mastracode/src/providers/openai-codex.ts b/mastracode/src/providers/openai-codex.ts index fcf5e57128e..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) diff --git a/packages/core/src/harness/harness.ts b/packages/core/src/harness/harness.ts index a9fef33026d..04e3c487724 100644 --- a/packages/core/src/harness/harness.ts +++ b/packages/core/src/harness/harness.ts @@ -1436,6 +1436,7 @@ export class Harness { } case 'file': if (typeof part.data !== 'string') { + console.warn('[Harness] Skipping file part with non-string data:', typeof part.data); break; } content.push({ From 818c4a7771e444f626b888b8909efdeda567fcab Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Tue, 3 Mar 2026 15:20:33 -0800 Subject: [PATCH 11/13] chore: remove fork-only files from PR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove README fork note and sync-upstream-fork workflow — these are fork-specific and should not be upstreamed. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/sync-upstream-fork.yml | 52 ------------------------ README.md | 2 - 2 files changed, 54 deletions(-) delete mode 100644 .github/workflows/sync-upstream-fork.yml diff --git a/.github/workflows/sync-upstream-fork.yml b/.github/workflows/sync-upstream-fork.yml deleted file mode 100644 index e305f11c035..00000000000 --- a/.github/workflows/sync-upstream-fork.yml +++ /dev/null @@ -1,52 +0,0 @@ -name: Sync Fork With Upstream - -permissions: - contents: write - -on: - schedule: - - cron: '0 6 * * *' - workflow_dispatch: - -jobs: - sync-upstream: - # Run only on forks. Upstream repository does not need this job. - if: ${{ github.repository != 'mastra-ai/mastra' }} - runs-on: ubuntu-latest - env: - DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} - UPSTREAM_REPO: mastra-ai/mastra - steps: - - name: Checkout repository - uses: actions/checkout@v5 - with: - fetch-depth: 0 - - - name: Configure Git user - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - - - name: Sync default branch from upstream - run: | - set -euo pipefail - - if git remote get-url upstream >/dev/null 2>&1; then - git remote set-url upstream "https://github.com/${UPSTREAM_REPO}.git" - else - git remote add upstream "https://github.com/${UPSTREAM_REPO}.git" - fi - - git fetch origin "${DEFAULT_BRANCH}" - git fetch upstream "${DEFAULT_BRANCH}" - - git checkout -B "${DEFAULT_BRANCH}" "origin/${DEFAULT_BRANCH}" - - UPSTREAM_NEW_COMMITS="$(git rev-list --count "origin/${DEFAULT_BRANCH}..upstream/${DEFAULT_BRANCH}")" - if [ "${UPSTREAM_NEW_COMMITS}" -eq 0 ]; then - echo "No upstream changes to sync." - exit 0 - fi - - git merge --no-edit "upstream/${DEFAULT_BRANCH}" - git push origin "${DEFAULT_BRANCH}" diff --git a/README.md b/README.md index f6dfd564f75..2b501d056ea 100644 --- a/README.md +++ b/README.md @@ -62,8 +62,6 @@ If you are a developer and would like to contribute with code, please open an is Information about the project setup can be found in the [development documentation](./DEVELOPMENT.md) -For fork maintainers: this repository includes a daily GitHub Action at `.github/workflows/sync-upstream-fork.yml` that syncs your fork's default branch from `mastra-ai/mastra`. - ## Support We have an [open community Discord](https://discord.gg/BTYqqHKUrf). Come and say hello and let us know if you have any questions or need any help getting things running. From fc8565a2fb28fb2e2721e9279dd5bddfb9eccfe1 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Wed, 4 Mar 2026 10:08:39 -0800 Subject: [PATCH 12/13] Delete create-auth-storage.test.ts Co-Authored-By: Claude Opus 4.6 --- .../src/__tests__/create-auth-storage.test.ts | 18 ------------------ 1 file changed, 18 deletions(-) delete mode 100644 mastracode/src/__tests__/create-auth-storage.test.ts diff --git a/mastracode/src/__tests__/create-auth-storage.test.ts b/mastracode/src/__tests__/create-auth-storage.test.ts deleted file mode 100644 index 0d5d825cc19..00000000000 --- a/mastracode/src/__tests__/create-auth-storage.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { afterEach, describe, expect, it } from 'vitest'; -import { createAuthStorage } from '../index.js'; -import { getAuthStorage as getClaudeAuthStorage, setAuthStorage as setClaudeAuthStorage } from '../providers/claude-max.js'; -import { getAuthStorage as getOpenAIAuthStorage, setAuthStorage as setOpenAIAuthStorage } from '../providers/openai-codex.js'; - -describe('createAuthStorage', () => { - afterEach(() => { - setClaudeAuthStorage(undefined); - setOpenAIAuthStorage(undefined); - }); - - it('wires a shared auth storage instance to provider modules', () => { - const authStorage = createAuthStorage(); - - expect(getClaudeAuthStorage()).toBe(authStorage); - expect(getOpenAIAuthStorage()).toBe(authStorage); - }); -}); From c5d62b57a9e09d0990f23a7ea0c104d8428d0a52 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Wed, 4 Mar 2026 10:49:11 -0800 Subject: [PATCH 13/13] Fix prettier formatting in mastracode Co-Authored-By: Claude Opus 4.6 --- .../src/agents/__tests__/extra-tools.test.ts | 7 +------ mastracode/src/agents/__tests__/tools.test.ts | 14 +++++++------- mastracode/src/index.ts | 12 ++++++++++-- 3 files changed, 18 insertions(+), 15 deletions(-) diff --git a/mastracode/src/agents/__tests__/extra-tools.test.ts b/mastracode/src/agents/__tests__/extra-tools.test.ts index b18746f2c8b..b2b5e62318e 100644 --- a/mastracode/src/agents/__tests__/extra-tools.test.ts +++ b/mastracode/src/agents/__tests__/extra-tools.test.ts @@ -244,12 +244,7 @@ describe('createDynamicTools – disabledTools filtering', () => { execute: async () => ({ result: 'custom' }), }); - const getDynamicTools = createDynamicTools( - undefined, - { my_tool: myTool }, - undefined, - ['my_tool'], - ); + const getDynamicTools = createDynamicTools(undefined, { my_tool: myTool }, undefined, ['my_tool']); const tools = getDynamicTools({ requestContext: makeRequestContext() }); expect(tools).not.toHaveProperty('my_tool'); }); diff --git a/mastracode/src/agents/__tests__/tools.test.ts b/mastracode/src/agents/__tests__/tools.test.ts index 032ad8cb399..5b4199e68b0 100644 --- a/mastracode/src/agents/__tests__/tools.test.ts +++ b/mastracode/src/agents/__tests__/tools.test.ts @@ -70,7 +70,12 @@ describe('createDynamicTools', () => { 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: [] })), + runPreToolUse: vi.fn(async () => ({ + allowed: false, + blockReason: 'blocked by policy', + results: [], + warnings: [], + })), runPostToolUse: vi.fn(async () => ({ allowed: true, results: [], warnings: [] })), }; @@ -124,11 +129,6 @@ describe('createDynamicTools', () => { }); await expect(tools.custom_tool.execute({ foo: 'bar' }, {})).rejects.toThrow('boom'); - expect(hookManager.runPostToolUse).toHaveBeenCalledWith( - 'custom_tool', - { foo: 'bar' }, - { error: 'boom' }, - true, - ); + expect(hookManager.runPostToolUse).toHaveBeenCalledWith('custom_tool', { foo: 'bar' }, { error: 'boom' }, true); }); }); diff --git a/mastracode/src/index.ts b/mastracode/src/index.ts index e493d19f0a0..55ea826bc11 100644 --- a/mastracode/src/index.ts +++ b/mastracode/src/index.ts @@ -67,8 +67,16 @@ export interface MastraCodeConfig { subagents?: HarnessSubagent[]; /** Extra tools merged into the dynamic tool set. Can be a static record or a function that receives requestContext. */ extraTools?: - | Record Promise | unknown; [key: string]: unknown }> - | ((ctx: { requestContext: RequestContext }) => Record Promise | unknown; [key: string]: unknown }>); + | 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 */