mcp(create_agent): validate mode and refuse silent cross-provider inheritance (#688)
The MCP create_agent tool inherited the parent agent's mode regardless of provider, so a Claude parent in bypassPermissions spawning an opencode child would feed an invalid mode to opencode and the new agent would die in error state. The fix: - Add an optional mode field to the agent-to-agent input schema. - Validate any explicit mode against the target provider's available modes; throw with the list when invalid. - Inherit the caller's mode only when both sides use the same provider; otherwise refuse with a helpful error listing the target's modes.
This commit is contained in:
@@ -0,0 +1,108 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { resolveAndValidateCreateAgentMode } from "./create-agent-mode.js";
|
||||||
|
|
||||||
|
const CLAUDE_MODES = ["default", "acceptEdits", "plan", "bypassPermissions"];
|
||||||
|
const OPENCODE_MODES = ["build", "full-access", "plan"];
|
||||||
|
const CODEX_MODES = ["auto", "full-access"];
|
||||||
|
|
||||||
|
describe("resolveAndValidateCreateAgentMode", () => {
|
||||||
|
it("returns the requested mode when it is valid for the target provider", () => {
|
||||||
|
const resolved = resolveAndValidateCreateAgentMode({
|
||||||
|
requestedMode: "plan",
|
||||||
|
targetProvider: "opencode",
|
||||||
|
parent: null,
|
||||||
|
availableModes: OPENCODE_MODES,
|
||||||
|
});
|
||||||
|
expect(resolved).toBe("plan");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws when the requested mode is invalid for the target provider", () => {
|
||||||
|
expect(() =>
|
||||||
|
resolveAndValidateCreateAgentMode({
|
||||||
|
requestedMode: "bypassPermissions",
|
||||||
|
targetProvider: "opencode",
|
||||||
|
parent: null,
|
||||||
|
availableModes: OPENCODE_MODES,
|
||||||
|
}),
|
||||||
|
).toThrow(
|
||||||
|
"Invalid mode 'bypassPermissions' for provider 'opencode'. Available modes: build, full-access, plan",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined (provider default) when no mode and no caller", () => {
|
||||||
|
const resolved = resolveAndValidateCreateAgentMode({
|
||||||
|
requestedMode: undefined,
|
||||||
|
targetProvider: "claude",
|
||||||
|
parent: null,
|
||||||
|
availableModes: CLAUDE_MODES,
|
||||||
|
});
|
||||||
|
expect(resolved).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("inherits the caller mode when caller and target share a provider", () => {
|
||||||
|
const resolved = resolveAndValidateCreateAgentMode({
|
||||||
|
requestedMode: undefined,
|
||||||
|
targetProvider: "claude",
|
||||||
|
parent: { provider: "claude", modeId: "bypassPermissions" },
|
||||||
|
availableModes: CLAUDE_MODES,
|
||||||
|
});
|
||||||
|
expect(resolved).toBe("bypassPermissions");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined when same-provider caller has no mode", () => {
|
||||||
|
const resolved = resolveAndValidateCreateAgentMode({
|
||||||
|
requestedMode: undefined,
|
||||||
|
targetProvider: "claude",
|
||||||
|
parent: { provider: "claude", modeId: null },
|
||||||
|
availableModes: CLAUDE_MODES,
|
||||||
|
});
|
||||||
|
expect(resolved).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses cross-provider inheritance with the target provider's modes in the message", () => {
|
||||||
|
expect(() =>
|
||||||
|
resolveAndValidateCreateAgentMode({
|
||||||
|
requestedMode: undefined,
|
||||||
|
targetProvider: "opencode",
|
||||||
|
parent: { provider: "claude", modeId: "bypassPermissions" },
|
||||||
|
availableModes: OPENCODE_MODES,
|
||||||
|
}),
|
||||||
|
).toThrow(
|
||||||
|
"cannot inherit mode 'bypassPermissions' from caller (provider 'claude') for new agent (provider 'opencode'). Pass an explicit mode. Available modes for 'opencode': build, full-access, plan",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses cross-provider inheritance even when the caller mode is null", () => {
|
||||||
|
expect(() =>
|
||||||
|
resolveAndValidateCreateAgentMode({
|
||||||
|
requestedMode: undefined,
|
||||||
|
targetProvider: "codex",
|
||||||
|
parent: { provider: "opencode", modeId: null },
|
||||||
|
availableModes: CODEX_MODES,
|
||||||
|
}),
|
||||||
|
).toThrow(
|
||||||
|
"cannot inherit mode '<none>' from caller (provider 'opencode') for new agent (provider 'codex'). Pass an explicit mode. Available modes for 'codex': auto, full-access",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes through an explicit mode when the target provider's modes are unknown", () => {
|
||||||
|
const resolved = resolveAndValidateCreateAgentMode({
|
||||||
|
requestedMode: "default",
|
||||||
|
targetProvider: "zai-custom",
|
||||||
|
parent: null,
|
||||||
|
availableModes: undefined,
|
||||||
|
});
|
||||||
|
expect(resolved).toBe("default");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders 'unknown' in cross-provider error when target modes are unknown", () => {
|
||||||
|
expect(() =>
|
||||||
|
resolveAndValidateCreateAgentMode({
|
||||||
|
requestedMode: undefined,
|
||||||
|
targetProvider: "zai-custom",
|
||||||
|
parent: { provider: "claude", modeId: "default" },
|
||||||
|
availableModes: undefined,
|
||||||
|
}),
|
||||||
|
).toThrow("Available modes for 'zai-custom': unknown");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import type { AgentProvider } from "./agent-sdk-types.js";
|
||||||
|
|
||||||
|
interface CreateAgentModeParent {
|
||||||
|
provider: AgentProvider;
|
||||||
|
modeId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolveCreateAgentModeInput {
|
||||||
|
requestedMode: string | undefined;
|
||||||
|
targetProvider: AgentProvider;
|
||||||
|
parent: CreateAgentModeParent | null;
|
||||||
|
// `undefined` = target provider's modes unknown: explicit modes pass through
|
||||||
|
// unvalidated, but cross-provider inheritance is still refused.
|
||||||
|
availableModes: string[] | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function listModes(modes: string[] | undefined): string {
|
||||||
|
if (modes === undefined) {
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
return modes.length > 0 ? modes.join(", ") : "(none)";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveAndValidateCreateAgentMode(
|
||||||
|
input: ResolveCreateAgentModeInput,
|
||||||
|
): string | undefined {
|
||||||
|
const { requestedMode, targetProvider, parent, availableModes } = input;
|
||||||
|
|
||||||
|
if (requestedMode !== undefined) {
|
||||||
|
if (availableModes !== undefined && !availableModes.includes(requestedMode)) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid mode '${requestedMode}' for provider '${targetProvider}'. Available modes: ${listModes(availableModes)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return requestedMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!parent) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parent.provider === targetProvider) {
|
||||||
|
return parent.modeId ?? undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
`cannot inherit mode '${parent.modeId ?? "<none>"}' from caller (provider '${parent.provider}') for new agent (provider '${targetProvider}'). Pass an explicit mode. Available modes for '${targetProvider}': ${listModes(availableModes)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -593,7 +593,7 @@ describe("create_agent MCP tool", () => {
|
|||||||
await tool.callback({
|
await tool.callback({
|
||||||
cwd: existingCwd,
|
cwd: existingCwd,
|
||||||
title: "Config test",
|
title: "Config test",
|
||||||
mode: "default",
|
mode: "auto",
|
||||||
initialPrompt: "Do work",
|
initialPrompt: "Do work",
|
||||||
provider: "codex/gpt-5.4",
|
provider: "codex/gpt-5.4",
|
||||||
thinking: "think-hard",
|
thinking: "think-hard",
|
||||||
@@ -1169,7 +1169,7 @@ describe("create_agent MCP tool", () => {
|
|||||||
await tool.callback({
|
await tool.callback({
|
||||||
cwd: existingCwd,
|
cwd: existingCwd,
|
||||||
title: "Injected config test",
|
title: "Injected config test",
|
||||||
mode: "default",
|
mode: "auto",
|
||||||
provider: "codex/gpt-5.4",
|
provider: "codex/gpt-5.4",
|
||||||
initialPrompt: "Do work",
|
initialPrompt: "Do work",
|
||||||
});
|
});
|
||||||
@@ -1183,6 +1183,129 @@ describe("create_agent MCP tool", () => {
|
|||||||
expect(agentIdArg).toBeUndefined();
|
expect(agentIdArg).toBeUndefined();
|
||||||
expect(optionsArg).toBeUndefined();
|
expect(optionsArg).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rejects an explicit mode that is not valid for the target provider", async () => {
|
||||||
|
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||||
|
const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
|
||||||
|
const tool = registeredTool(server, "create_agent");
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
tool.callback({
|
||||||
|
cwd: existingCwd,
|
||||||
|
title: "Bad mode",
|
||||||
|
provider: "opencode/gpt-5.4",
|
||||||
|
mode: "bypassPermissions",
|
||||||
|
initialPrompt: "Do work",
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(
|
||||||
|
"Invalid mode 'bypassPermissions' for provider 'opencode'. Available modes: build, full-access, plan",
|
||||||
|
);
|
||||||
|
expect(spies.agentManager.createAgent).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("inherits the caller mode when the new agent uses the same provider", async () => {
|
||||||
|
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||||
|
spies.agentManager.getAgent.mockReturnValue({
|
||||||
|
id: "parent-agent",
|
||||||
|
cwd: existingCwd,
|
||||||
|
provider: "claude",
|
||||||
|
currentModeId: "bypassPermissions",
|
||||||
|
} as ManagedAgent);
|
||||||
|
spies.agentManager.createAgent.mockResolvedValue({
|
||||||
|
id: "child-agent",
|
||||||
|
cwd: existingCwd,
|
||||||
|
lifecycle: "idle",
|
||||||
|
currentModeId: "bypassPermissions",
|
||||||
|
availableModes: [],
|
||||||
|
config: { title: "Child" },
|
||||||
|
} as ManagedAgent);
|
||||||
|
|
||||||
|
const server = await createAgentMcpServer({
|
||||||
|
agentManager,
|
||||||
|
agentStorage,
|
||||||
|
callerAgentId: "parent-agent",
|
||||||
|
logger,
|
||||||
|
});
|
||||||
|
const tool = registeredTool(server, "create_agent");
|
||||||
|
await tool.callback({
|
||||||
|
title: "Child",
|
||||||
|
provider: "claude/claude-sonnet-4-20250514",
|
||||||
|
initialPrompt: "Do work",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ modeId: "bypassPermissions" }),
|
||||||
|
undefined,
|
||||||
|
expect.any(Object),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses cross-provider mode inheritance when no explicit mode is given", async () => {
|
||||||
|
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||||
|
spies.agentManager.getAgent.mockReturnValue({
|
||||||
|
id: "parent-agent",
|
||||||
|
cwd: existingCwd,
|
||||||
|
provider: "claude",
|
||||||
|
currentModeId: "bypassPermissions",
|
||||||
|
} as ManagedAgent);
|
||||||
|
|
||||||
|
const server = await createAgentMcpServer({
|
||||||
|
agentManager,
|
||||||
|
agentStorage,
|
||||||
|
callerAgentId: "parent-agent",
|
||||||
|
logger,
|
||||||
|
});
|
||||||
|
const tool = registeredTool(server, "create_agent");
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
tool.callback({
|
||||||
|
title: "Child",
|
||||||
|
provider: "opencode/gpt-5.4",
|
||||||
|
initialPrompt: "Do work",
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(
|
||||||
|
"cannot inherit mode 'bypassPermissions' from caller (provider 'claude') for new agent (provider 'opencode'). Pass an explicit mode. Available modes for 'opencode': build, full-access, plan",
|
||||||
|
);
|
||||||
|
expect(spies.agentManager.createAgent).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts an explicit valid mode across providers", async () => {
|
||||||
|
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||||
|
spies.agentManager.getAgent.mockReturnValue({
|
||||||
|
id: "parent-agent",
|
||||||
|
cwd: existingCwd,
|
||||||
|
provider: "claude",
|
||||||
|
currentModeId: "bypassPermissions",
|
||||||
|
} as ManagedAgent);
|
||||||
|
spies.agentManager.createAgent.mockResolvedValue({
|
||||||
|
id: "child-agent",
|
||||||
|
cwd: existingCwd,
|
||||||
|
lifecycle: "idle",
|
||||||
|
currentModeId: "build",
|
||||||
|
availableModes: [],
|
||||||
|
config: { title: "Child" },
|
||||||
|
} as ManagedAgent);
|
||||||
|
|
||||||
|
const server = await createAgentMcpServer({
|
||||||
|
agentManager,
|
||||||
|
agentStorage,
|
||||||
|
callerAgentId: "parent-agent",
|
||||||
|
logger,
|
||||||
|
});
|
||||||
|
const tool = registeredTool(server, "create_agent");
|
||||||
|
await tool.callback({
|
||||||
|
title: "Child",
|
||||||
|
provider: "opencode/gpt-5.4",
|
||||||
|
mode: "build",
|
||||||
|
initialPrompt: "Do work",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ modeId: "build" }),
|
||||||
|
undefined,
|
||||||
|
expect.any(Object),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("create_schedule MCP tool", () => {
|
describe("create_schedule MCP tool", () => {
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ import type {
|
|||||||
import type { ScheduleService } from "../schedule/service.js";
|
import type { ScheduleService } from "../schedule/service.js";
|
||||||
import { ScheduleSummarySchema, StoredScheduleSchema } from "../schedule/types.js";
|
import { ScheduleSummarySchema, StoredScheduleSchema } from "../schedule/types.js";
|
||||||
import type { ProviderDefinition } from "./provider-registry.js";
|
import type { ProviderDefinition } from "./provider-registry.js";
|
||||||
|
import { getAgentProviderDefinition } from "./provider-manifest.js";
|
||||||
|
import { resolveAndValidateCreateAgentMode } from "./create-agent-mode.js";
|
||||||
import { resolveSnapshotCwd } from "./provider-snapshot-manager.js";
|
import { resolveSnapshotCwd } from "./provider-snapshot-manager.js";
|
||||||
import {
|
import {
|
||||||
AgentModelSchema,
|
AgentModelSchema,
|
||||||
@@ -492,6 +494,12 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
|||||||
.trim()
|
.trim()
|
||||||
.min(1, "initialPrompt is required")
|
.min(1, "initialPrompt is required")
|
||||||
.describe("Required first task to run immediately after creation."),
|
.describe("Required first task to run immediately after creation."),
|
||||||
|
mode: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
"Optional session mode for the new agent. Required when the new agent uses a different provider than the caller agent.",
|
||||||
|
),
|
||||||
background: z
|
background: z
|
||||||
.boolean()
|
.boolean()
|
||||||
.optional()
|
.optional()
|
||||||
@@ -628,6 +636,18 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
|||||||
setupContinuation: AgentWorktreeSetupContinuation | undefined;
|
setupContinuation: AgentWorktreeSetupContinuation | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getAvailableModeIds = (provider: AgentProvider): string[] | undefined => {
|
||||||
|
const fromRegistry = providerRegistry?.[provider];
|
||||||
|
if (fromRegistry) {
|
||||||
|
return fromRegistry.modes.map((mode) => mode.id);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return getAgentProviderDefinition(provider).modes.map((mode) => mode.id);
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const resolveCallerCreateAgentArgs = (
|
const resolveCallerCreateAgentArgs = (
|
||||||
args: unknown,
|
args: unknown,
|
||||||
parentAgentId: string,
|
parentAgentId: string,
|
||||||
@@ -645,10 +665,12 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
|||||||
lockedCwd: callerContext?.lockedCwd,
|
lockedCwd: callerContext?.lockedCwd,
|
||||||
allowCustomCwd: callerContext?.allowCustomCwd ?? true,
|
allowCustomCwd: callerContext?.allowCustomCwd ?? true,
|
||||||
});
|
});
|
||||||
const parentMode = parentAgent.currentModeId;
|
const resolvedMode = resolveAndValidateCreateAgentMode({
|
||||||
const resolvedMode = parentMode
|
requestedMode: callerArgs.mode,
|
||||||
? mapModeAcrossProviders(parentMode, parentAgent.provider, provider)
|
targetProvider: provider,
|
||||||
: undefined;
|
parent: { provider: parentAgent.provider, modeId: parentAgent.currentModeId },
|
||||||
|
availableModes: getAvailableModeIds(provider),
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
provider,
|
provider,
|
||||||
initialPrompt: callerArgs.initialPrompt,
|
initialPrompt: callerArgs.initialPrompt,
|
||||||
@@ -670,6 +692,12 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
|||||||
const topLevelArgs = topLevelCreateAgentArgsSchema.parse(args);
|
const topLevelArgs = topLevelCreateAgentArgsSchema.parse(args);
|
||||||
const resolvedProviderModel = resolveRequiredProviderModel(topLevelArgs.provider);
|
const resolvedProviderModel = resolveRequiredProviderModel(topLevelArgs.provider);
|
||||||
const { cwd, mode, worktreeName, baseBranch, refName, action, githubPrNumber } = topLevelArgs;
|
const { cwd, mode, worktreeName, baseBranch, refName, action, githubPrNumber } = topLevelArgs;
|
||||||
|
const resolvedMode = resolveAndValidateCreateAgentMode({
|
||||||
|
requestedMode: mode,
|
||||||
|
targetProvider: resolvedProviderModel.provider,
|
||||||
|
parent: null,
|
||||||
|
availableModes: getAvailableModeIds(resolvedProviderModel.provider),
|
||||||
|
});
|
||||||
let resolvedCwd = expandUserPath(cwd);
|
let resolvedCwd = expandUserPath(cwd);
|
||||||
let setupContinuation: AgentWorktreeSetupContinuation | undefined;
|
let setupContinuation: AgentWorktreeSetupContinuation | undefined;
|
||||||
|
|
||||||
@@ -725,7 +753,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
|||||||
labels: topLevelArgs.labels,
|
labels: topLevelArgs.labels,
|
||||||
notifyOnFinish: topLevelArgs.notifyOnFinish ?? false,
|
notifyOnFinish: topLevelArgs.notifyOnFinish ?? false,
|
||||||
resolvedCwd,
|
resolvedCwd,
|
||||||
resolvedMode: mode,
|
resolvedMode,
|
||||||
setupContinuation,
|
setupContinuation,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user