mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-29 20:52:51 +00:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2a2d1588d | ||
|
|
2325a2f32d | ||
|
|
12a33ea21a | ||
|
|
e1684aece0 | ||
|
|
c47e1f178b |
@@ -44,6 +44,15 @@ fall back to creating a fresh agent when continuation was requested.
|
|||||||
Hub owns conversation keys, trigger policy, execution records, and the mapping to workspace/agent
|
Hub owns conversation keys, trigger policy, execution records, and the mapping to workspace/agent
|
||||||
IDs. None of these routing concepts are part of the generic daemon RPCs. Creation accepts ordinary
|
IDs. None of these routing concepts are part of the generic daemon RPCs. Creation accepts ordinary
|
||||||
provider configuration, exact MCP tool policy, environment, and workspace/worktree selection.
|
provider configuration, exact MCP tool policy, environment, and workspace/worktree selection.
|
||||||
|
|
||||||
|
`server_info.features.optionalMcpSupport` advertises optional MCP launch requirements.
|
||||||
|
Hub sends `toolPolicy.enforcement: "if-supported"` and
|
||||||
|
`mcpServerRequirements: { hub: "optional" }` only when this flag is true.
|
||||||
|
Without it, Hub preserves its existing strict tool-policy request. Available
|
||||||
|
providers remain selectable; providers lacking strict approval support need an
|
||||||
|
updated daemon to launch from Hub. Required external/plugin servers and explicitly
|
||||||
|
required tool policies keep their existing refusal semantics.
|
||||||
|
|
||||||
Private session configuration is persisted for recovery and is not exposed in agent snapshots.
|
Private session configuration is persisted for recovery and is not exposed in agent snapshots.
|
||||||
Provider controls remain provider-native; see [providers.md](providers.md).
|
Provider controls remain provider-native; see [providers.md](providers.md).
|
||||||
|
|
||||||
|
|||||||
+10
-3
@@ -30,9 +30,16 @@ at session startup. See [provider configuration](custom-providers.md#provider-op
|
|||||||
for defaults and merge rules, and the [SDK guide](../public-docs/sdk/provider-options.md)
|
for defaults and merge rules, and the [SDK guide](../public-docs/sdk/provider-options.md)
|
||||||
for native keys and examples.
|
for native keys and examples.
|
||||||
|
|
||||||
Exact MCP preapproval is a separate daemon-owned contract. A new provider must fail
|
Exact MCP preapproval is a separate daemon-owned contract. A required tool policy
|
||||||
closed for Hub unattended execution until it can approve one exact injected MCP
|
(the default) fails closed unless the provider can approve exact injected server/tool
|
||||||
server and tool identity without approving native tools.
|
identities without approving native tools. Native agent creation also accepts
|
||||||
|
`toolPolicy.enforcement: "if-supported"` for automatic approval convenience:
|
||||||
|
unsupported providers retain ordinary MCP configuration and native permissions.
|
||||||
|
Hub uses that optional request; MCP support and exact approval are not provider
|
||||||
|
eligibility requirements. Optional injection is declared separately with
|
||||||
|
`mcpServerRequirements: { [serverName]: "optional" }`; other injected servers
|
||||||
|
(including plugin-owned servers) keep their required semantics. The daemon
|
||||||
|
advertises this native launch contract as `features.optionalMcpSupport`. The legacy Hub create API remains strict-only.
|
||||||
|
|
||||||
## Core adapter patterns
|
## Core adapter patterns
|
||||||
|
|
||||||
|
|||||||
@@ -509,10 +509,14 @@ export interface McpToolRef {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ToolPolicy {
|
export interface ToolPolicy {
|
||||||
|
/** Missing or required fails closed; if-supported requests approval convenience only. */
|
||||||
|
enforcement?: "required" | "if-supported";
|
||||||
preapproved: McpToolRef[];
|
preapproved: McpToolRef[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AgentSessionConfig {
|
export interface AgentSessionConfig {
|
||||||
|
/** Per-server launch requirement; unspecified servers are required. */
|
||||||
|
mcpServerRequirements?: Record<string, "optional" | "required">;
|
||||||
provider: AgentProvider;
|
provider: AgentProvider;
|
||||||
cwd: string;
|
cwd: string;
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -571,3 +571,38 @@ test.each(["all", "base", "uncommitted"] as const)(
|
|||||||
expect(parsed.attachments).toEqual([attachment]);
|
expect(parsed.attachments).toEqual([attachment]);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
test("native agent creation accepts optional approval without weakening legacy strict acknowledgement", () => {
|
||||||
|
const toolPolicy = {
|
||||||
|
preapproved: [{ kind: "mcp", server: "hub", tool: "finish_execution" }],
|
||||||
|
enforcement: "if-supported",
|
||||||
|
};
|
||||||
|
const parsed = SessionInboundMessageSchema.parse({
|
||||||
|
type: "create_agent_request",
|
||||||
|
requestId: "req",
|
||||||
|
config: { provider: "pi", cwd: "/tmp", toolPolicy },
|
||||||
|
});
|
||||||
|
expect(parsed).toMatchObject({ config: { toolPolicy } });
|
||||||
|
expect(
|
||||||
|
SessionInboundMessageSchema.safeParse({
|
||||||
|
type: "hub.execution.agent.create.request",
|
||||||
|
requestId: "req",
|
||||||
|
executionId: "exec",
|
||||||
|
provider: "pi",
|
||||||
|
cwd: "/tmp",
|
||||||
|
prompt: "test",
|
||||||
|
toolPolicy: { preapproved: toolPolicy.preapproved },
|
||||||
|
}).success,
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
SessionInboundMessageSchema.safeParse({
|
||||||
|
type: "hub.execution.agent.create.request",
|
||||||
|
requestId: "req",
|
||||||
|
executionId: "exec",
|
||||||
|
provider: "pi",
|
||||||
|
cwd: "/tmp",
|
||||||
|
prompt: "test",
|
||||||
|
toolPolicy,
|
||||||
|
}).success,
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|||||||
@@ -490,8 +490,11 @@ const AgentSessionConfigSchema = z.object({
|
|||||||
featureValues: z.record(z.string(), z.unknown()).optional(),
|
featureValues: z.record(z.string(), z.unknown()).optional(),
|
||||||
title: z.string().trim().min(1).max(MAX_EXPLICIT_AGENT_TITLE_CHARS).optional().nullable(),
|
title: z.string().trim().min(1).max(MAX_EXPLICIT_AGENT_TITLE_CHARS).optional().nullable(),
|
||||||
providerOptions: ProviderOptionsSchema.optional(),
|
providerOptions: ProviderOptionsSchema.optional(),
|
||||||
toolPolicy: ToolPolicySchema.optional(),
|
toolPolicy: ToolPolicySchema.extend({
|
||||||
|
enforcement: z.enum(["required", "if-supported"]).optional(),
|
||||||
|
}).optional(),
|
||||||
systemPrompt: z.string().optional(),
|
systemPrompt: z.string().optional(),
|
||||||
|
mcpServerRequirements: z.record(z.string(), z.enum(["optional", "required"])).optional(),
|
||||||
mcpServers: z.record(z.string(), McpServerConfigSchema).optional(),
|
mcpServers: z.record(z.string(), McpServerConfigSchema).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -3671,6 +3674,7 @@ export const ServerInfoStatusPayloadSchema = z
|
|||||||
internalWorkspaces: z.boolean().optional(),
|
internalWorkspaces: z.boolean().optional(),
|
||||||
backgroundWorkspaces: z.boolean().optional(),
|
backgroundWorkspaces: z.boolean().optional(),
|
||||||
providersSnapshot: z.boolean().optional(),
|
providersSnapshot: z.boolean().optional(),
|
||||||
|
optionalMcpSupport: z.boolean().optional(),
|
||||||
connectedHosts: z.boolean().optional(),
|
connectedHosts: z.boolean().optional(),
|
||||||
hostMultiplexing: z.boolean().optional(),
|
hostMultiplexing: z.boolean().optional(),
|
||||||
usageSources: z.boolean().optional(),
|
usageSources: z.boolean().optional(),
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
type AgentManagerEvent,
|
type AgentManagerEvent,
|
||||||
type ManagedAgent,
|
type ManagedAgent,
|
||||||
} from "./agent-manager.js";
|
} from "./agent-manager.js";
|
||||||
|
import { buildProviderRegistry } from "./provider-registry.js";
|
||||||
import { AgentStorage } from "./agent-storage.js";
|
import { AgentStorage } from "./agent-storage.js";
|
||||||
import { InMemoryAgentTimelineStore } from "./agent-timeline-store.js";
|
import { InMemoryAgentTimelineStore } from "./agent-timeline-store.js";
|
||||||
import { toAgentPayload } from "./agent-projections.js";
|
import { toAgentPayload } from "./agent-projections.js";
|
||||||
@@ -445,7 +446,7 @@ class EnvProbeAgentClient extends TestAgentClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class TestAgentSession implements AgentSession {
|
class TestAgentSession implements AgentSession {
|
||||||
readonly provider = "codex" as const;
|
readonly provider: AgentProvider;
|
||||||
readonly capabilities = TEST_CAPABILITIES;
|
readonly capabilities = TEST_CAPABILITIES;
|
||||||
readonly id = randomUUID();
|
readonly id = randomUUID();
|
||||||
private runtimeModel: string | null = null;
|
private runtimeModel: string | null = null;
|
||||||
@@ -453,7 +454,9 @@ class TestAgentSession implements AgentSession {
|
|||||||
private turnIdCounter = 0;
|
private turnIdCounter = 0;
|
||||||
private interrupted = false;
|
private interrupted = false;
|
||||||
|
|
||||||
constructor(private readonly config: AgentSessionConfig) {}
|
constructor(private readonly config: AgentSessionConfig) {
|
||||||
|
this.provider = config.provider;
|
||||||
|
}
|
||||||
|
|
||||||
async run(): Promise<AgentRunResult> {
|
async run(): Promise<AgentRunResult> {
|
||||||
return {
|
return {
|
||||||
@@ -3822,54 +3825,85 @@ test("resumeAgentFromPersistence drops stored internal paseo MCP when runtime in
|
|||||||
expect(snapshot.config.mcpServers).toBeUndefined();
|
expect(snapshot.config.mcpServers).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("createAgent preserves a user-provided paseo MCP config", async () => {
|
test.each([
|
||||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
{ provider: "pi", supportsMcp: true },
|
||||||
const storagePath = join(workdir, "agents");
|
{ provider: "pi", supportsMcp: false },
|
||||||
const storage = new AgentStorage(storagePath, logger);
|
{ provider: "codex", supportsMcp: false },
|
||||||
|
])(
|
||||||
|
"createAgent permits optional approval for $provider with MCP capability $supportsMcp",
|
||||||
|
async ({ provider, supportsMcp }) => {
|
||||||
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||||
|
const storagePath = join(workdir, "agents");
|
||||||
|
const storage = new AgentStorage(storagePath, logger);
|
||||||
|
|
||||||
class CaptureClient extends TestAgentClient {
|
class CaptureClient extends TestAgentClient {
|
||||||
lastConfig: AgentSessionConfig | null = null;
|
lastConfig: AgentSessionConfig | null = null;
|
||||||
|
|
||||||
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
||||||
this.lastConfig = config;
|
this.lastConfig = config;
|
||||||
return new McpCapableTestAgentSession(config);
|
return supportsMcp ? new McpCapableTestAgentSession(config) : new TestAgentSession(config);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const client = new CaptureClient();
|
const client = new CaptureClient(provider);
|
||||||
const manager = new AgentManager({
|
const manager = new AgentManager({
|
||||||
clients: {
|
clients: {
|
||||||
codex: client,
|
[provider]: client,
|
||||||
},
|
},
|
||||||
registry: storage,
|
providerDefinitions: {
|
||||||
logger,
|
[provider]: {
|
||||||
mcpBaseUrl: "http://127.0.0.1:6767/mcp/agents",
|
enabled: true,
|
||||||
idFactory: () => "00000000-0000-4000-8000-000000000104",
|
applyToolPolicy: buildProviderRegistry(logger)[provider]!.applyToolPolicy,
|
||||||
});
|
|
||||||
|
|
||||||
const snapshot = await manager.createAgent(
|
|
||||||
{
|
|
||||||
provider: "codex",
|
|
||||||
cwd: workdir,
|
|
||||||
mcpServers: {
|
|
||||||
paseo: {
|
|
||||||
type: "http",
|
|
||||||
url: "https://example.com/custom-paseo",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
registry: storage,
|
||||||
undefined,
|
logger,
|
||||||
{ workspaceId: undefined },
|
mcpBaseUrl: "http://127.0.0.1:6767/mcp/agents",
|
||||||
);
|
idFactory: () => "00000000-0000-4000-8000-000000000104",
|
||||||
|
});
|
||||||
|
|
||||||
expect(snapshot.config.mcpServers).toEqual({
|
const snapshot = await manager.createAgent(
|
||||||
paseo: {
|
{
|
||||||
type: "http",
|
provider,
|
||||||
url: "https://example.com/custom-paseo",
|
mcpServerRequirements: { paseo: "optional" },
|
||||||
},
|
cwd: workdir,
|
||||||
});
|
toolPolicy: {
|
||||||
expect(client.lastConfig?.mcpServers).toEqual(snapshot.config.mcpServers);
|
enforcement: "if-supported",
|
||||||
});
|
preapproved: [{ kind: "mcp", server: "paseo", tool: "finish_execution" }],
|
||||||
|
},
|
||||||
|
mcpServers: {
|
||||||
|
paseo: {
|
||||||
|
type: "http",
|
||||||
|
url: "https://example.com/custom-paseo",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
{ workspaceId: undefined },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(snapshot.config.mcpServers).toEqual({
|
||||||
|
paseo: {
|
||||||
|
type: "http",
|
||||||
|
url: "https://example.com/custom-paseo",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect((await storage.get(snapshot.id))?.config?.mcpServerRequirements).toEqual({
|
||||||
|
paseo: "optional",
|
||||||
|
});
|
||||||
|
await manager.reloadAgentSession(snapshot.id);
|
||||||
|
expect(manager.getAgent(snapshot.id)?.config.mcpServerRequirements).toEqual({
|
||||||
|
paseo: "optional",
|
||||||
|
});
|
||||||
|
const expectedPolicy =
|
||||||
|
provider === "codex"
|
||||||
|
? { preapproved: [{ kind: "mcp", server: "paseo", tool: "finish_execution" }] }
|
||||||
|
: undefined;
|
||||||
|
expect(snapshot.config.toolPolicy).toEqual(expectedPolicy);
|
||||||
|
expect(client.lastConfig?.toolPolicy).toEqual(expectedPolicy);
|
||||||
|
expect(client.lastConfig?.mcpServers).toEqual(snapshot.config.mcpServers);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
test("createAgent fails when cwd does not exist", async () => {
|
test("createAgent fails when cwd does not exist", async () => {
|
||||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||||
@@ -12575,3 +12609,21 @@ test("an out-of-band command racing idle cleanup waits for the resumed runtime",
|
|||||||
expect(h.commands).toEqual(["/maintenance"]);
|
expect(h.commands).toEqual(["/maintenance"]);
|
||||||
expect(h.prompts).toEqual([]);
|
expect(h.prompts).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("MCP requirements cannot mark a later plugin server optional", async () => {
|
||||||
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||||
|
const manager = new AgentManager({
|
||||||
|
clients: { codex: new TestAgentClient() },
|
||||||
|
registry: new AgentStorage(join(workdir, "agents"), logger),
|
||||||
|
logger,
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
manager.createAgent(
|
||||||
|
{ provider: "codex", cwd: workdir, mcpServerRequirements: { plugin: "optional" } },
|
||||||
|
undefined,
|
||||||
|
{ workspaceId: undefined },
|
||||||
|
),
|
||||||
|
).rejects.toThrow(
|
||||||
|
"MCP server requirement 'plugin' requires MCP server 'plugin' in the same agent request",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -216,6 +216,8 @@ function buildStoredAgentConfig(record: StoredAgentRecord): AgentSessionConfig {
|
|||||||
if (record.config.providerOptions != null) {
|
if (record.config.providerOptions != null) {
|
||||||
config.providerOptions = record.config.providerOptions;
|
config.providerOptions = record.config.providerOptions;
|
||||||
}
|
}
|
||||||
|
if (record.config.mcpServerRequirements != null)
|
||||||
|
config.mcpServerRequirements = record.config.mcpServerRequirements;
|
||||||
if (record.config.toolPolicy != null) config.toolPolicy = record.config.toolPolicy;
|
if (record.config.toolPolicy != null) config.toolPolicy = record.config.toolPolicy;
|
||||||
if (record.config.systemPrompt != null) {
|
if (record.config.systemPrompt != null) {
|
||||||
config.systemPrompt = record.config.systemPrompt;
|
config.systemPrompt = record.config.systemPrompt;
|
||||||
@@ -1686,7 +1688,7 @@ export class AgentManager {
|
|||||||
);
|
);
|
||||||
const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext);
|
const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext);
|
||||||
if (
|
if (
|
||||||
Object.keys(stripInternalPaseoMcpServer(providerLaunchConfig).mcpServers ?? {}).length > 0 &&
|
this.hasRequiredExternalMcpServers(providerLaunchConfig) &&
|
||||||
existing.session.capabilities.supportsMcpServers !== true
|
existing.session.capabilities.supportsMcpServers !== true
|
||||||
) {
|
) {
|
||||||
throw this.unsupportedMcpError(providerLaunchConfig, launchContext);
|
throw this.unsupportedMcpError(providerLaunchConfig, launchContext);
|
||||||
@@ -3888,13 +3890,19 @@ export class AgentManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private hasRequiredExternalMcpServers(config: AgentSessionConfig): boolean {
|
||||||
|
return Object.keys(stripInternalPaseoMcpServer(config).mcpServers ?? {}).some(
|
||||||
|
(name) => config.mcpServerRequirements?.[name] !== "optional",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private async requireExternalMcpSupport(
|
private async requireExternalMcpSupport(
|
||||||
session: AgentSession,
|
session: AgentSession,
|
||||||
launchConfig: AgentSessionConfig,
|
launchConfig: AgentSessionConfig,
|
||||||
launchContext: AgentLaunchContext,
|
launchContext: AgentLaunchContext,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (
|
if (
|
||||||
Object.keys(stripInternalPaseoMcpServer(launchConfig).mcpServers ?? {}).length === 0 ||
|
!this.hasRequiredExternalMcpServers(launchConfig) ||
|
||||||
session.capabilities.supportsMcpServers === true
|
session.capabilities.supportsMcpServers === true
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
@@ -5457,6 +5465,7 @@ export class AgentManager {
|
|||||||
private applyProviderConfiguration(config: AgentSessionConfig): AgentSessionConfig {
|
private applyProviderConfiguration(config: AgentSessionConfig): AgentSessionConfig {
|
||||||
const definition = this.providerDefinitions.get(config.provider);
|
const definition = this.providerDefinitions.get(config.provider);
|
||||||
this.validateToolPolicyServers(config);
|
this.validateToolPolicyServers(config);
|
||||||
|
this.validateMcpServerRequirements(config);
|
||||||
if (config.toolPolicy && !definition?.applyToolPolicy) {
|
if (config.toolPolicy && !definition?.applyToolPolicy) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Provider '${config.provider}' cannot preapprove exact MCP tools for unattended execution`,
|
`Provider '${config.provider}' cannot preapprove exact MCP tools for unattended execution`,
|
||||||
@@ -5467,6 +5476,16 @@ export class AgentManager {
|
|||||||
: config;
|
: config;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private validateMcpServerRequirements(config: AgentSessionConfig): void {
|
||||||
|
for (const name of Object.keys(config.mcpServerRequirements ?? {})) {
|
||||||
|
if (!Object.hasOwn(config.mcpServers ?? {}, name)) {
|
||||||
|
throw new Error(
|
||||||
|
`MCP server requirement '${name}' requires MCP server '${name}' in the same agent request`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private validateToolPolicyServers(config: AgentSessionConfig): void {
|
private validateToolPolicyServers(config: AgentSessionConfig): void {
|
||||||
if (!config.toolPolicy) return;
|
if (!config.toolPolicy) return;
|
||||||
const serverNames = new Set(Object.keys(config.mcpServers ?? {}));
|
const serverNames = new Set(Object.keys(config.mcpServers ?? {}));
|
||||||
|
|||||||
@@ -395,6 +395,9 @@ function buildSerializableConfig(config: AgentSessionConfig): SerializableAgentC
|
|||||||
if (config.systemPrompt) {
|
if (config.systemPrompt) {
|
||||||
serializable.systemPrompt = config.systemPrompt;
|
serializable.systemPrompt = config.systemPrompt;
|
||||||
}
|
}
|
||||||
|
if (config.mcpServerRequirements) {
|
||||||
|
serializable.mcpServerRequirements = config.mcpServerRequirements;
|
||||||
|
}
|
||||||
if (config.mcpServers) {
|
if (config.mcpServers) {
|
||||||
serializable.mcpServers = config.mcpServers;
|
serializable.mcpServers = config.mcpServers;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -607,6 +607,7 @@ export interface AgentSessionConfig {
|
|||||||
title?: string | null;
|
title?: string | null;
|
||||||
providerOptions?: ProviderOptions;
|
providerOptions?: ProviderOptions;
|
||||||
toolPolicy?: ToolPolicy;
|
toolPolicy?: ToolPolicy;
|
||||||
|
mcpServerRequirements?: Record<string, "optional" | "required">;
|
||||||
mcpServers?: Record<string, McpServerConfig>;
|
mcpServers?: Record<string, McpServerConfig>;
|
||||||
/**
|
/**
|
||||||
* Internal agents are hidden from listings and don't trigger notifications.
|
* Internal agents are hidden from listings and don't trigger notifications.
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ const SERIALIZABLE_CONFIG_SCHEMA = z
|
|||||||
.nullable()
|
.nullable()
|
||||||
.optional(),
|
.optional(),
|
||||||
systemPrompt: z.string().nullable().optional(),
|
systemPrompt: z.string().nullable().optional(),
|
||||||
|
mcpServerRequirements: z
|
||||||
|
.record(z.string(), z.enum(["optional", "required"]))
|
||||||
|
.nullable()
|
||||||
|
.optional(),
|
||||||
mcpServers: z.record(z.string(), z.any()).nullable().optional(),
|
mcpServers: z.record(z.string(), z.any()).nullable().optional(),
|
||||||
})
|
})
|
||||||
.nullable()
|
.nullable()
|
||||||
@@ -89,6 +93,7 @@ export type SerializableAgentConfig = Pick<
|
|||||||
| "toolPolicy"
|
| "toolPolicy"
|
||||||
| "systemPrompt"
|
| "systemPrompt"
|
||||||
| "mcpServers"
|
| "mcpServers"
|
||||||
|
| "mcpServerRequirements"
|
||||||
>;
|
>;
|
||||||
|
|
||||||
export type StoredAgentRecord = z.infer<typeof STORED_AGENT_SCHEMA>;
|
export type StoredAgentRecord = z.infer<typeof STORED_AGENT_SCHEMA>;
|
||||||
|
|||||||
@@ -135,6 +135,29 @@ describe("provider-owned option schemas", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("exact MCP preapproval mappings", () => {
|
describe("exact MCP preapproval mappings", () => {
|
||||||
|
test("optional exact preapproval permits unsupported providers without changing their MCP config", () => {
|
||||||
|
const registry = buildProviderRegistry(createTestLogger());
|
||||||
|
const config = {
|
||||||
|
provider: "pi",
|
||||||
|
cwd: "/tmp",
|
||||||
|
mcpServers: { hub: { type: "http" as const, url: "http://localhost/hub" } },
|
||||||
|
};
|
||||||
|
expect(
|
||||||
|
registry.pi.applyToolPolicy(config, { ...hubPolicy, enforcement: "if-supported" }),
|
||||||
|
).toEqual({ ...config, toolPolicy: undefined });
|
||||||
|
});
|
||||||
|
test.each(["claude", "codex", "opencode"])(
|
||||||
|
"optional grants retain exact approval for %s",
|
||||||
|
(provider) => {
|
||||||
|
const registry = buildProviderRegistry(createTestLogger());
|
||||||
|
const config = { provider, cwd: "/tmp" };
|
||||||
|
expect(
|
||||||
|
registry[provider]!.applyToolPolicy(config, { ...hubPolicy, enforcement: "if-supported" })
|
||||||
|
.toolPolicy,
|
||||||
|
).toEqual(hubPolicy);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
test("unsupported providers fail closed with an unattended-execution error", () => {
|
test("unsupported providers fail closed with an unattended-execution error", () => {
|
||||||
const registry = buildProviderRegistry(createTestLogger());
|
const registry = buildProviderRegistry(createTestLogger());
|
||||||
expect(() =>
|
expect(() =>
|
||||||
|
|||||||
@@ -652,12 +652,15 @@ function createRegistryEntry(
|
|||||||
supportsExactMcpPreapproval: resolved.contract.supportsExactMcpPreapproval,
|
supportsExactMcpPreapproval: resolved.contract.supportsExactMcpPreapproval,
|
||||||
applyToolPolicy: (config, toolPolicy) => {
|
applyToolPolicy: (config, toolPolicy) => {
|
||||||
if (toolPolicy && !resolved.contract.supportsExactMcpPreapproval) {
|
if (toolPolicy && !resolved.contract.supportsExactMcpPreapproval) {
|
||||||
throw new ToolPolicyUnsupportedError(provider);
|
if (toolPolicy.enforcement !== "if-supported")
|
||||||
|
throw new ToolPolicyUnsupportedError(provider);
|
||||||
|
return { ...config, toolPolicy: undefined };
|
||||||
}
|
}
|
||||||
|
const exactPolicy = toolPolicy ? { preapproved: toolPolicy.preapproved } : undefined;
|
||||||
return {
|
return {
|
||||||
...config,
|
...config,
|
||||||
toolPolicy: toolPolicy
|
toolPolicy: exactPolicy
|
||||||
? (resolved.contract.applyToolPolicy?.(provider, toolPolicy) ?? toolPolicy)
|
? (resolved.contract.applyToolPolicy?.(provider, exactPolicy) ?? exactPolicy)
|
||||||
: undefined,
|
: undefined,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, expect, test } from "vitest";
|
import { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
import type { AgentSessionConfig } from "./agent-sdk-types.js";
|
import type { AgentSessionConfig } from "./agent-sdk-types.js";
|
||||||
import { withRuntimePaseoMcpServer } from "./runtime-mcp-config.js";
|
import { stripInternalPaseoMcpServer, withRuntimePaseoMcpServer } from "./runtime-mcp-config.js";
|
||||||
|
|
||||||
const BASE_CONFIG: AgentSessionConfig = {
|
const BASE_CONFIG: AgentSessionConfig = {
|
||||||
provider: "claude",
|
provider: "claude",
|
||||||
@@ -49,3 +49,15 @@ describe("withRuntimePaseoMcpServer", () => {
|
|||||||
expect(result.mcpServers).toBeUndefined();
|
expect(result.mcpServers).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("stripping an internal MCP server also strips its launch requirement", () => {
|
||||||
|
const result = stripInternalPaseoMcpServer({
|
||||||
|
...BASE_CONFIG,
|
||||||
|
mcpServerRequirements: { paseo: "optional", hub: "optional" },
|
||||||
|
mcpServers: {
|
||||||
|
paseo: { type: "http", url: "http://127.0.0.1/mcp/agents?callerAgentId=agent-1" },
|
||||||
|
hub: { type: "http", url: "https://hub.test/mcp" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(result.mcpServerRequirements).toEqual({ hub: "optional" });
|
||||||
|
});
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ export function stripInternalPaseoMcpServer(config: AgentSessionConfig): AgentSe
|
|||||||
delete nextMcpServers[PASEO_MCP_SERVER_NAME];
|
delete nextMcpServers[PASEO_MCP_SERVER_NAME];
|
||||||
|
|
||||||
const next = { ...config };
|
const next = { ...config };
|
||||||
|
if (next.mcpServerRequirements?.[PASEO_MCP_SERVER_NAME]) {
|
||||||
|
next.mcpServerRequirements = { ...next.mcpServerRequirements };
|
||||||
|
delete next.mcpServerRequirements[PASEO_MCP_SERVER_NAME];
|
||||||
|
if (Object.keys(next.mcpServerRequirements).length === 0) delete next.mcpServerRequirements;
|
||||||
|
}
|
||||||
if (Object.keys(nextMcpServers).length > 0) {
|
if (Object.keys(nextMcpServers).length > 0) {
|
||||||
next.mcpServers = nextMcpServers;
|
next.mcpServers = nextMcpServers;
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -214,3 +214,14 @@ describe("persistence hooks", () => {
|
|||||||
expect(handle).toBeNull();
|
expect(handle).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("restores optional MCP requirements for continuation", () => {
|
||||||
|
const record = createRecord({
|
||||||
|
config: {
|
||||||
|
mcpServerRequirements: { hub: "optional" },
|
||||||
|
mcpServers: { hub: { type: "http", url: "https://hub.test/mcp" } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(buildConfigOverrides(record).mcpServerRequirements).toEqual({ hub: "optional" });
|
||||||
|
expect(buildSessionConfig(record)?.mcpServerRequirements).toEqual({ hub: "optional" });
|
||||||
|
});
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ export function buildConfigOverrides(record: StoredAgentRecord): Partial<AgentSe
|
|||||||
toolPolicy: record.config?.toolPolicy ?? undefined,
|
toolPolicy: record.config?.toolPolicy ?? undefined,
|
||||||
systemPrompt: record.config?.systemPrompt ?? undefined,
|
systemPrompt: record.config?.systemPrompt ?? undefined,
|
||||||
mcpServers: record.config?.mcpServers ?? undefined,
|
mcpServers: record.config?.mcpServers ?? undefined,
|
||||||
|
mcpServerRequirements: record.config?.mcpServerRequirements ?? undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,6 +101,7 @@ export function buildSessionConfig(
|
|||||||
toolPolicy: overrides.toolPolicy,
|
toolPolicy: overrides.toolPolicy,
|
||||||
systemPrompt: overrides.systemPrompt,
|
systemPrompt: overrides.systemPrompt,
|
||||||
mcpServers: overrides.mcpServers,
|
mcpServers: overrides.mcpServers,
|
||||||
|
mcpServerRequirements: overrides.mcpServerRequirements,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1966,6 +1966,7 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
workspaceSortTimes: true,
|
workspaceSortTimes: true,
|
||||||
// COMPAT(providersSnapshot): keep optional until all clients rely on snapshot flow.
|
// COMPAT(providersSnapshot): keep optional until all clients rely on snapshot flow.
|
||||||
providersSnapshot: true,
|
providersSnapshot: true,
|
||||||
|
optionalMcpSupport: true,
|
||||||
// COMPAT(providersSnapshotCwd): added in v0.3.2, remove gate after 2027-02-10.
|
// COMPAT(providersSnapshotCwd): added in v0.3.2, remove gate after 2027-02-10.
|
||||||
providersSnapshotCwd: true,
|
providersSnapshotCwd: true,
|
||||||
// COMPAT(checkoutForgeSetAutoMerge): added in v0.2.0-beta.1. Remove the
|
// COMPAT(checkoutForgeSetAutoMerge): added in v0.2.0-beta.1. Remove the
|
||||||
|
|||||||
Reference in New Issue
Block a user