Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
8d99cd6
feat(mastracode): support extra tools and auth-only storage init (#1)
Kitenite Feb 26, 2026
6fd8e2c
feat(harness): rename images→files and use AI SDK FilePart shape (#2)
saddlepaddle Feb 27, 2026
64f6b27
fix: address PR review feedback and add changesets
Kitenite Feb 27, 2026
4811be2
chore(ci): add daily upstream sync workflow for forks
Kitenite Feb 27, 2026
f12ed13
docs: improve changeset descriptions with migration examples
Kitenite Feb 27, 2026
e3e4351
fix(mastracode): route anthropic api_key away from oauth bearer
Kitenite Feb 28, 2026
0444fb0
fix(mastracode): use auth storage only for anthropic/openai auth
Kitenite Feb 28, 2026
8534e16
Merge pull request #3 from superset-sh/kitenite/jet
Kitenite Feb 28, 2026
37202e8
Merge remote-tracking branch 'upstream/main'
github-actions[bot] Feb 28, 2026
267442f
Merge remote-tracking branch 'upstream/main' into kitenite/jet
Kitenite Feb 28, 2026
2b6f926
Merge remote-tracking branch 'upstream/main'
Kitenite Mar 2, 2026
71938ec
Merge branch 'main' into kitenite/jet
Kitenite Mar 2, 2026
854a476
Merge remote-tracking branch 'origin' into kitenite/jet
Kitenite Mar 2, 2026
4c08006
Merge branch 'kitenite/jet' into codex/merge-jet-into-main
Kitenite Mar 2, 2026
5cf97e0
feat(mastracode): add disabledTools API and extract permission rule u…
Kitenite Mar 2, 2026
dd13f27
refactor(mastracode): keep disabledTools change focused
Kitenite Mar 2, 2026
d717519
Merge pull request #4 from superset-sh/kitenite/dimple
Kitenite Mar 2, 2026
acf05f2
Merge remote-tracking branch 'upstream/main' into codex/sync-origin-m…
Kitenite Mar 3, 2026
efdeda2
Merge branch 'main' into main
roaminro Mar 3, 2026
ee0888d
fix(mastracode): address PR review comments
Kitenite Mar 3, 2026
86af8d8
Merge upstream/main and resolve conflicts
Kitenite Mar 3, 2026
f6f7035
Merge branch 'main' into kitenite/jet
Kitenite Mar 3, 2026
818c4a7
chore: remove fork-only files from PR
Kitenite Mar 3, 2026
6bd3c17
Merge branch 'main' into kitenite/jet
DanielSLew Mar 4, 2026
fc8565a
Delete create-auth-storage.test.ts
Kitenite Mar 4, 2026
e0350f8
Resolve merge conflict with upstream/main
Kitenite Mar 4, 2026
c5d62b5
Fix prettier formatting in mastracode
Kitenite Mar 4, 2026
87c626f
Merge branch 'main' into kitenite/jet
Kitenite Mar 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/real-wolves-thank.md
Original file line number Diff line number Diff line change
@@ -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
28 changes: 28 additions & 0 deletions mastracode/src/agents/__tests__/extra-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', {
Expand Down
93 changes: 72 additions & 21 deletions mastracode/src/agents/__tests__/model.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,17 @@ vi.mock('@ai-sdk/anthropic', () => ({
}),
}));

// Mock @ai-sdk/openai
vi.mock('@ai-sdk/openai', () => ({
createOpenAI: vi.fn((_opts: Record<string, unknown>) => {
const openai = ((modelId: string) => ({ __provider: 'openai-direct', modelId })) as unknown as {
responses: (modelId: string) => Record<string, unknown>;
};
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<string, unknown> }) => ({
Expand All @@ -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 };
Expand All @@ -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<string, unknown>;

Expand All @@ -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<string, unknown>;

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' });
Expand All @@ -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');
Expand All @@ -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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
expect(result.__provider).toBe('model-router');
});
Expand All @@ -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');
});
Expand All @@ -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();
});
});
134 changes: 134 additions & 0 deletions mastracode/src/agents/__tests__/tools.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { describe, expect, it, vi } from 'vitest';
import { createDynamicTools } from '../tools.js';

function createRequestContext(state: Record<string, unknown>, 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);
});
});
Loading
Loading