feat(providers): add native options and exact MCP grants (#3025)
* feat(providers): add native options and exact MCP grants Keep provider policy in provider-owned schemas so unattended Hub runs can grant only requested tools from injected MCP servers. Preserve the native resolved sandbox policy when a workflow override is present, including configured writable roots. * test(hub): verify real provider tool policy * test(app): avoid pinning transient restart status
This commit is contained in:
@@ -453,6 +453,26 @@ export interface AgentRunResult {
|
||||
canceled?: boolean;
|
||||
}
|
||||
|
||||
export type JsonValue =
|
||||
| null
|
||||
| boolean
|
||||
| number
|
||||
| string
|
||||
| JsonValue[]
|
||||
| { [key: string]: JsonValue };
|
||||
|
||||
export type ProviderOptions = Record<string, JsonValue>;
|
||||
|
||||
export interface McpToolRef {
|
||||
kind: "mcp";
|
||||
server: string;
|
||||
tool: string;
|
||||
}
|
||||
|
||||
export interface ToolPolicy {
|
||||
preapproved: McpToolRef[];
|
||||
}
|
||||
|
||||
export interface AgentSessionConfig {
|
||||
provider: AgentProvider;
|
||||
cwd: string;
|
||||
@@ -466,14 +486,8 @@ export interface AgentSessionConfig {
|
||||
thinkingOptionId?: string;
|
||||
featureValues?: Record<string, unknown>;
|
||||
title?: string | null;
|
||||
approvalPolicy?: string;
|
||||
sandboxMode?: string;
|
||||
networkAccess?: boolean;
|
||||
webSearch?: boolean;
|
||||
extra?: {
|
||||
codex?: AgentMetadata;
|
||||
claude?: AgentMetadata;
|
||||
};
|
||||
providerOptions?: ProviderOptions;
|
||||
toolPolicy?: ToolPolicy;
|
||||
mcpServers?: Record<string, McpServerConfig>;
|
||||
/**
|
||||
* Internal agents are hidden from listings and don't trigger notifications.
|
||||
|
||||
@@ -108,6 +108,43 @@ describe("Hub session protocol", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("round-trips native provider options and structured MCP preapproval", () => {
|
||||
const message = {
|
||||
type: "hub.execution.agent.create.request",
|
||||
requestId: "request-policy",
|
||||
executionId: "execution-policy",
|
||||
provider: "codex",
|
||||
cwd: "/workspace",
|
||||
prompt: "Classify and finish",
|
||||
providerOptions: {
|
||||
sandbox_mode: "workspace-write",
|
||||
sandbox_workspace_write: { writable_roots: ["/var/cache/npm"] },
|
||||
},
|
||||
mcpServers: {
|
||||
hub: { type: "http", url: "https://hub.example/executions/policy" },
|
||||
},
|
||||
toolPolicy: {
|
||||
preapproved: [{ kind: "mcp", server: "hub", tool: "finish_execution" }],
|
||||
},
|
||||
};
|
||||
|
||||
expect(SessionInboundMessageSchema.parse(message)).toEqual(message);
|
||||
});
|
||||
|
||||
test.each(["Bash", "Edit", "Write"])("cannot encode native %s tool preapproval", (tool) => {
|
||||
const message = {
|
||||
type: "hub.execution.agent.create.request",
|
||||
requestId: "request-native-tool",
|
||||
executionId: "execution-native-tool",
|
||||
provider: "claude",
|
||||
cwd: "/workspace",
|
||||
prompt: "Do work",
|
||||
toolPolicy: { preapproved: [{ kind: "native", server: "claude", tool }] },
|
||||
};
|
||||
|
||||
expect(SessionInboundMessageSchema.safeParse(message).success).toBe(false);
|
||||
});
|
||||
|
||||
test.each([
|
||||
undefined,
|
||||
{ mode: "branch-off", newBranch: "hub-work", base: "main" },
|
||||
|
||||
@@ -362,6 +362,22 @@ const McpServerConfigSchema = z.discriminatedUnion("type", [
|
||||
McpSseServerConfigSchema,
|
||||
]);
|
||||
|
||||
const ProviderOptionsSchema = z.record(z.string(), z.json());
|
||||
|
||||
const McpToolRefSchema = z
|
||||
.object({
|
||||
kind: z.literal("mcp"),
|
||||
server: z.string().trim().min(1),
|
||||
tool: z.string().trim().min(1),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const ToolPolicySchema = z
|
||||
.object({
|
||||
preapproved: z.array(McpToolRefSchema),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const AgentSessionConfigSchema = z.object({
|
||||
provider: AgentProviderSchema,
|
||||
cwd: z.string(),
|
||||
@@ -370,17 +386,8 @@ const AgentSessionConfigSchema = z.object({
|
||||
thinkingOptionId: z.string().optional(),
|
||||
featureValues: z.record(z.string(), z.unknown()).optional(),
|
||||
title: z.string().trim().min(1).max(MAX_EXPLICIT_AGENT_TITLE_CHARS).optional().nullable(),
|
||||
approvalPolicy: z.string().optional(),
|
||||
sandboxMode: z.string().optional(),
|
||||
networkAccess: z.boolean().optional(),
|
||||
webSearch: z.boolean().optional(),
|
||||
extra: z
|
||||
.object({
|
||||
codex: z.record(z.string(), z.unknown()).optional(),
|
||||
claude: z.record(z.string(), z.unknown()).optional(),
|
||||
})
|
||||
.partial()
|
||||
.optional(),
|
||||
providerOptions: ProviderOptionsSchema.optional(),
|
||||
toolPolicy: ToolPolicySchema.optional(),
|
||||
systemPrompt: z.string().optional(),
|
||||
mcpServers: z.record(z.string(), McpServerConfigSchema).optional(),
|
||||
});
|
||||
@@ -2513,6 +2520,8 @@ export const HubExecutionAgentCreateRequestSchema = z.object({
|
||||
modeId: z.string().optional(),
|
||||
thinkingOptionId: z.string().optional(),
|
||||
featureValues: z.record(z.string(), z.unknown()).optional(),
|
||||
providerOptions: ProviderOptionsSchema.optional(),
|
||||
toolPolicy: ToolPolicySchema.optional(),
|
||||
env: z.record(z.string(), z.string()).optional(),
|
||||
mcpServers: z.record(z.string(), McpServerConfigSchema).optional(),
|
||||
worktree: CreateAgentWorktreeTargetSchema.optional(),
|
||||
@@ -2520,6 +2529,31 @@ export const HubExecutionAgentCreateRequestSchema = z.object({
|
||||
|
||||
export type HubExecutionAgentCreateRequest = z.infer<typeof HubExecutionAgentCreateRequestSchema>;
|
||||
|
||||
const HubExecutionAgentCreateErrorSchema = z.discriminatedUnion("code", [
|
||||
z.object({
|
||||
code: z.literal("provider_options_invalid"),
|
||||
provider: z.string(),
|
||||
issues: z.array(
|
||||
z.object({
|
||||
path: z.array(z.union([z.string(), z.number()])),
|
||||
message: z.string(),
|
||||
}),
|
||||
),
|
||||
message: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
code: z.literal("tool_policy_unsupported"),
|
||||
provider: z.string(),
|
||||
message: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
code: z.literal("create_failed"),
|
||||
message: z.string(),
|
||||
}),
|
||||
]);
|
||||
|
||||
export type HubExecutionAgentCreateError = z.infer<typeof HubExecutionAgentCreateErrorSchema>;
|
||||
|
||||
export const HubExecutionControlActionSchema = z.enum(["interrupt", "archive"]);
|
||||
export type HubExecutionControlAction = z.infer<typeof HubExecutionControlActionSchema>;
|
||||
|
||||
@@ -5300,7 +5334,8 @@ export const HubExecutionAgentCreateResponseSchema = z.object({
|
||||
agentId: z.string().nullable(),
|
||||
agent: AgentSnapshotPayloadSchema.nullable(),
|
||||
success: z.boolean(),
|
||||
error: z.string().nullable(),
|
||||
toolPolicyApplied: z.literal(true).optional(),
|
||||
error: HubExecutionAgentCreateErrorSchema.nullable(),
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -33,18 +33,8 @@ export const ScheduleTargetSchema = z.discriminatedUnion("type", [
|
||||
archiveOnFinish: z.boolean().optional(),
|
||||
isolation: z.enum(["local", "worktree"]).optional(),
|
||||
title: z.string().trim().min(1).nullable().optional(),
|
||||
approvalPolicy: z.string().trim().min(1).optional(),
|
||||
sandboxMode: z.string().trim().min(1).optional(),
|
||||
networkAccess: z.boolean().optional(),
|
||||
webSearch: z.boolean().optional(),
|
||||
providerOptions: z.record(z.string(), z.json()).optional(),
|
||||
featureValues: z.record(z.string(), z.unknown()).optional(),
|
||||
extra: z
|
||||
.object({
|
||||
codex: z.record(z.string(), z.unknown()).optional(),
|
||||
claude: z.record(z.string(), z.unknown()).optional(),
|
||||
})
|
||||
.partial()
|
||||
.optional(),
|
||||
systemPrompt: z.string().optional(),
|
||||
mcpServers: z.record(z.string(), z.unknown()).optional(),
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user