feat(daemon): add run-scoped MCP tool bundles (#3244)

* feat(daemon): add run-scoped MCP tool bundles

* fix(daemon): keep sandbox runs in managed project dirs

* fix(daemon): reject malformed run tool bundles

* fix(contracts): model run-scoped mcp server inputs

* fix(daemon): reject unsupported run tool bundles

* fix(daemon): validate run tools before chat fallback

* test(daemon): expect sandbox imported folder failure

* fix(daemon): preflight sandbox project roots before run rows

* fix(daemon): preflight sandbox chat project roots

* fix(daemon): allow host editor for sandbox imports

* fix(daemon): preflight sandbox routine project reuse

* fix(daemon): reject undeliverable Claude tool bundles

* fix(daemon): single-source chat route validation
This commit is contained in:
Denis Redozubov 2026-05-31 05:53:04 +02:00 committed by GitHub
parent e8c179d3a6
commit 729ce2b0cb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 1174 additions and 135 deletions

View file

@ -19,7 +19,6 @@ import { isSafeId as isSafeProjectId } from './projects.js';
import { projectKindToTracking } from '@open-design/contracts/analytics';
import { proxyDispatcherRequestInit, validateBaseUrlResolved } from './connectionTest.js';
import { googleStreamGenerateContentUrl } from './google-models.js';
import { parseMediaExecutionPolicyInput } from './media-policy.js';
import { createRoleMarkerGuard } from './role-marker-guard.js';
// Allowlist for the `/feedback` route. Mirrors the
@ -45,7 +44,7 @@ export interface RegisterChatRoutesDeps extends RouteDeps<'db' | 'design' | 'htt
export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
const { db, design } = ctx;
const { sendApiError, createSseResponse } = ctx.http;
const { startChatRun, submitToolResultToRun } = ctx.chat;
const { submitToolResultToRun } = ctx.chat;
const { testProviderConnection, testAgentConnection, getAgentDef, isKnownModel, sanitizeCustomModel, listProviderModels } = ctx.agents;
const {
handleCritiqueArtifact,
@ -54,7 +53,6 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
critiqueResponseCapBytes,
critiqueRunRegistry,
} = ctx.critique;
const isDaemonShuttingDown = ctx.lifecycle?.isDaemonShuttingDown ?? (() => false);
const rejectProxyPluginContext = (body: Record<string, unknown>, res: any) => {
if (
(typeof body.pluginId === 'string' && body.pluginId.trim().length > 0) ||
@ -79,6 +77,8 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
// so any handler we wired here was shadowed and never executed. Plugin
// snapshot resolution, clientType inference, and the daemon-side
// run_created/finished analytics all live in `server.ts` now.
// POST /api/chat is likewise owned by `server.ts`; keep the chat run
// launch path single-sourced so validation changes land on the live route.
app.get('/api/runs', (req, res) => {
const { projectId, conversationId, status } = req.query;
@ -218,23 +218,6 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
res.status(202).json(outcome);
});
app.post('/api/chat', (req, res) => {
if (isDaemonShuttingDown()) {
return sendApiError(res, 503, 'UPSTREAM_UNAVAILABLE', 'daemon is shutting down');
}
const body = req.body && typeof req.body === 'object' ? req.body : {};
const mediaExecution = parseMediaExecutionPolicyInput(
(body as { mediaExecution?: unknown }).mediaExecution,
);
if (!mediaExecution.ok) {
return sendApiError(res, 400, 'BAD_REQUEST', mediaExecution.message);
}
const runBody = { ...body, mediaExecution: mediaExecution.policy };
const run = design.runs.create(runBody);
design.runs.stream(run, req, res);
design.runs.start(run, () => startChatRun(runBody, run));
});
// ---- Connection tests (single-shot JSON; no SSE) ------------------------
// Settings dialog uses these to verify a config works without sending a
// real chat. Always return HTTP 200 with `ok: false` on upstream-caused

View file

@ -15,6 +15,7 @@
import { spawn } from 'node:child_process';
import { access, constants as fsConstants } from 'node:fs/promises';
import path from 'node:path';
import type { Express } from 'express';
import type {
HostEditor,
@ -159,6 +160,28 @@ function applicableForPlatform(entry: CatalogueEntry, platform: Platform): boole
return true;
}
function projectHostOpenDir(
projectsRoot: string,
project: { id: string; metadata?: { baseDir?: unknown } | null },
resolveProjectDir: (
projectsRoot: string,
projectId: string,
metadata?: unknown,
opts?: { allowUnavailableSandboxImportedProject?: boolean },
) => string,
): string {
const importedBaseDir =
typeof project.metadata?.baseDir === 'string'
? path.normalize(project.metadata.baseDir)
: '';
if (importedBaseDir && path.isAbsolute(importedBaseDir)) {
return importedBaseDir;
}
return resolveProjectDir(projectsRoot, project.id, project.metadata, {
allowUnavailableSandboxImportedProject: true,
});
}
export function registerHostToolsRoutes(app: Express, ctx: RegisterHostToolsRoutesDeps) {
const { db } = ctx;
const { sendApiError } = ctx.http;
@ -209,7 +232,11 @@ export function registerHostToolsRoutes(app: Express, ctx: RegisterHostToolsRout
if (!project) {
return sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'project not found');
}
const resolvedDir = resolveProjectDir(PROJECTS_DIR, project.id, project.metadata);
const resolvedDir = projectHostOpenDir(
PROJECTS_DIR,
project,
resolveProjectDir,
);
const probe = await resolveEntry(entry);
if (!probe.available || !probe.launch) {
return sendApiError(res, 409, 'EDITOR_NOT_AVAILABLE', `${entry.label} is not installed`);

View file

@ -0,0 +1,185 @@
import type { McpAuthMode, McpServerConfig, McpTransport } from './mcp-config.js';
import type { RuntimeAgentDef } from './runtimes/types.js';
import { sanitizeMcpConfig, sanitizeMcpServer } from './mcp-config.js';
export interface RunToolBundle {
mcpServers: McpServerConfig[];
}
export interface RunToolBundleSummary {
mcpServers: Array<{
id: string;
label?: string;
templateId?: string;
transport: McpTransport;
enabled: boolean;
authMode?: McpAuthMode;
}>;
}
export interface ExternalMcpSelection {
enabledServers: McpServerConfig[];
persistedTokenServerIds: Set<string>;
}
export type RunToolBundleParseResult =
| { ok: true; bundle: RunToolBundle }
| { ok: false; message: string };
export type RunToolBundleValidationResult =
| { ok: true }
| { ok: false; message: string };
export type RunToolBundleDeliveryTarget =
| 'managed-project'
| 'external-project'
| 'none';
export interface RunToolBundleValidationOptions {
deliveryTarget?: RunToolBundleDeliveryTarget;
}
type RunToolBundleAgent = Pick<
RuntimeAgentDef,
'id' | 'name' | 'externalMcpInjection'
>;
function isPlainObject(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
function agentLabel(agent: RunToolBundleAgent): string {
return agent.name ? `${agent.name} (${agent.id})` : agent.id;
}
export function normalizeRunToolBundleForRun(raw: unknown): RunToolBundle {
if (!isPlainObject(raw)) return { mcpServers: [] };
return {
mcpServers: sanitizeMcpConfig({ servers: raw.mcpServers }).servers,
};
}
export function parseRunToolBundleForRequest(raw: unknown): RunToolBundleParseResult {
if (raw == null) return { ok: true, bundle: { mcpServers: [] } };
if (!isPlainObject(raw)) {
return { ok: false, message: 'toolBundle must be an object' };
}
if (raw.mcpServers == null) return { ok: true, bundle: { mcpServers: [] } };
if (!Array.isArray(raw.mcpServers)) {
return { ok: false, message: 'toolBundle.mcpServers must be an array' };
}
const seen = new Set<string>();
const servers: McpServerConfig[] = [];
for (const [index, entry] of raw.mcpServers.entries()) {
const server = sanitizeMcpServer(entry);
if (!server) {
return {
ok: false,
message: `toolBundle.mcpServers[${index}] is invalid`,
};
}
if (seen.has(server.id)) {
return {
ok: false,
message: `toolBundle.mcpServers[${index}] duplicates server id "${server.id}"`,
};
}
seen.add(server.id);
servers.push(server);
}
return { ok: true, bundle: { mcpServers: servers } };
}
export function summarizeRunToolBundle(bundle: RunToolBundle | null | undefined): RunToolBundleSummary {
const servers = Array.isArray(bundle?.mcpServers) ? bundle.mcpServers : [];
return {
mcpServers: servers.map((server) => ({
id: server.id,
...(server.label ? { label: server.label } : {}),
...(server.templateId ? { templateId: server.templateId } : {}),
transport: server.transport,
enabled: server.enabled,
...(server.authMode ? { authMode: server.authMode } : {}),
})),
};
}
export function validateRunToolBundleForAgent(
bundle: RunToolBundle | null | undefined,
agent: RunToolBundleAgent | null | undefined,
options: RunToolBundleValidationOptions = {},
): RunToolBundleValidationResult {
const servers = Array.isArray(bundle?.mcpServers) ? bundle.mcpServers : [];
const enabledServers = servers.filter((server) => server.enabled);
if (enabledServers.length === 0) return { ok: true };
if (!agent) {
return {
ok: false,
message: 'toolBundle requires a supported agentId',
};
}
if (agent.externalMcpInjection === 'claude-mcp-json') {
if (options.deliveryTarget && options.deliveryTarget !== 'managed-project') {
return {
ok: false,
message:
`${agentLabel(agent)} receives run-scoped MCP tool bundles through project .mcp.json, ` +
'so toolBundle requires a daemon-managed project',
};
}
return { ok: true };
}
if (agent.externalMcpInjection === 'opencode-env-content') {
return { ok: true };
}
if (agent.externalMcpInjection === 'acp-merge') {
const unsupported = servers.findIndex(
(server) => server.enabled && server.transport !== 'stdio',
);
if (unsupported === -1) return { ok: true };
return {
ok: false,
message:
`toolBundle.mcpServers[${unsupported}] uses ${servers[unsupported]?.transport} transport, ` +
`but ${agentLabel(agent)} only supports stdio run-scoped MCP servers`,
};
}
return {
ok: false,
message: `${agentLabel(agent)} does not support run-scoped MCP tool bundles`,
};
}
export function resolveExternalMcpServersForRun({
persistedServers,
runScopedServers,
sandboxMode,
}: {
persistedServers: McpServerConfig[];
runScopedServers: McpServerConfig[];
sandboxMode: boolean;
}): ExternalMcpSelection {
const runScopedIds = new Set(runScopedServers.map((server) => server.id));
const persistedForRun = sandboxMode ? [] : persistedServers;
const byId = new Map<string, McpServerConfig>();
for (const server of persistedForRun) byId.set(server.id, server);
for (const server of runScopedServers) byId.set(server.id, server);
const persistedTokenServerIds = new Set<string>();
for (const server of persistedForRun) {
if (!server.enabled) continue;
if (runScopedIds.has(server.id)) continue;
persistedTokenServerIds.add(server.id);
}
return {
enabledServers: Array.from(byId.values()).filter((server) => server.enabled),
persistedTokenServerIds,
};
}

View file

@ -3,6 +3,10 @@ import { randomUUID } from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { normalizeMediaExecutionPolicyForRun } from './media-policy.js';
import {
normalizeRunToolBundleForRun,
summarizeRunToolBundle,
} from './run-tool-bundle.js';
export const TERMINAL_RUN_STATUSES = new Set(['succeeded', 'failed', 'canceled']);
@ -57,6 +61,7 @@ export function createChatRunService({
pluginId:
typeof meta.pluginId === 'string' && meta.pluginId ? meta.pluginId : null,
mediaExecution: normalizeMediaExecutionPolicyForRun(meta.mediaExecution),
toolBundle: normalizeRunToolBundleForRun(meta.toolBundle),
status: 'queued',
createdAt: now,
updatedAt: now,
@ -149,6 +154,7 @@ export function createChatRunService({
errorCode: run.errorCode ?? null,
eventsLogPath: run.eventsLogPath ?? null,
mediaExecution: run.mediaExecution ?? normalizeMediaExecutionPolicyForRun(null),
toolBundle: summarizeRunToolBundle(run.toolBundle),
});
const finish = (run, status, code: number | null = null, signal: string | null = null) => {

View file

@ -316,6 +316,11 @@ import {
readMcpConfig,
writeMcpConfig,
} from './mcp-config.js';
import {
parseRunToolBundleForRequest,
resolveExternalMcpServersForRun,
validateRunToolBundleForAgent,
} from './run-tool-bundle.js';
import {
beginAuth,
exchangeCodeForToken,
@ -10775,8 +10780,8 @@ export async function startServer({
// doesn't exist yet). Without one we don't pass cwd to spawn — the
// agent then runs in whatever inherited dir, which still lets API
// mode work but loses file-tool addressability.
// For git-linked projects (metadata.baseDir), use that folder directly
// so the agent writes back to the user's original source tree.
// Project directory resolution lives in projects.ts so sandbox mode can
// consistently reject imported-folder metadata that has no managed copy.
let cwd = null;
let existingProjectFiles = [];
if (typeof projectId === 'string' && projectId) {
@ -10901,57 +10906,71 @@ export async function startServer({
// values further down at .mcp.json write time — see the spawn block
// below — instead of re-reading.
let externalMcpConfig = { servers: [] };
try {
externalMcpConfig = await readMcpConfig(RUNTIME_DATA_DIR);
} catch (err) {
console.warn(
'[mcp-config] read failed:',
err && err.message ? err.message : err,
);
if (!SANDBOX_RUNTIME.enabled) {
try {
externalMcpConfig = await readMcpConfig(RUNTIME_DATA_DIR);
} catch (err) {
console.warn(
'[mcp-config] read failed:',
err && err.message ? err.message : err,
);
}
}
const enabledExternalMcp = externalMcpConfig.servers.filter((s) => s.enabled);
const runScopedMcpServers = Array.isArray(run?.toolBundle?.mcpServers)
? run.toolBundle.mcpServers
: [];
const {
enabledServers: enabledExternalMcp,
persistedTokenServerIds,
} = resolveExternalMcpServersForRun({
persistedServers: externalMcpConfig.servers,
runScopedServers: runScopedMcpServers,
sandboxMode: SANDBOX_RUNTIME.enabled,
});
const oauthTokensForSpawn = {};
try {
const stored = await readAllTokens(RUNTIME_DATA_DIR);
for (const [serverId, tok] of Object.entries(stored)) {
if (!enabledExternalMcp.find((s) => s.id === serverId)) continue;
// Default to the persisted access token; null it out if expired so
// we never inject a stale `Authorization: Bearer …` header. The
// model treats a server with a Bearer pinned as connected and
// discourages re-auth, which is the worst possible UX when the
// token is going to 401 every call.
let access = isTokenExpired(tok) ? null : tok.accessToken;
if (isTokenExpired(tok) && tok.refreshToken) {
try {
const refreshed = await refreshAndPersistToken(
RUNTIME_DATA_DIR,
serverId,
tok,
);
if (refreshed) access = refreshed.accessToken;
} catch (err) {
if (persistedTokenServerIds.size > 0) {
try {
const stored = await readAllTokens(RUNTIME_DATA_DIR);
for (const [serverId, tok] of Object.entries(stored)) {
if (!persistedTokenServerIds.has(serverId)) continue;
// Default to the persisted access token; null it out if expired so
// we never inject a stale `Authorization: Bearer …` header. The
// model treats a server with a Bearer pinned as connected and
// discourages re-auth, which is the worst possible UX when the
// token is going to 401 every call.
let access = isTokenExpired(tok) ? null : tok.accessToken;
if (isTokenExpired(tok) && tok.refreshToken) {
try {
const refreshed = await refreshAndPersistToken(
RUNTIME_DATA_DIR,
serverId,
tok,
);
if (refreshed) access = refreshed.accessToken;
} catch (err) {
console.warn(
'[mcp-oauth] refresh failed for',
serverId,
err && err.message ? err.message : err,
);
}
}
if (access) {
oauthTokensForSpawn[serverId] = access;
} else {
console.warn(
'[mcp-oauth] refresh failed for',
'[mcp-oauth] skipping expired token for',
serverId,
err && err.message ? err.message : err,
'— reconnect required',
);
}
}
if (access) {
oauthTokensForSpawn[serverId] = access;
} else {
console.warn(
'[mcp-oauth] skipping expired token for',
serverId,
'— reconnect required',
);
}
} catch (err) {
console.warn(
'[mcp-tokens] read failed:',
err && err.message ? err.message : err,
);
}
} catch (err) {
console.warn(
'[mcp-tokens] read failed:',
err && err.message ? err.message : err,
);
}
const connectedExternalMcp = enabledExternalMcp
.filter((s) => typeof oauthTokensForSpawn[s.id] === 'string')
@ -13000,14 +13019,33 @@ export async function startServer({
};
});
function runToolBundleDeliveryTargetForProject(projectId, metadata) {
if (typeof projectId !== 'string' || !projectId || !isSafeId(projectId)) {
return 'none';
}
try {
const cwd = resolveProjectDir(PROJECTS_DIR, projectId, metadata, {
allowUnavailableSandboxImportedProject: true,
});
return isManagedProjectCwd(cwd, PROJECTS_DIR) ? 'managed-project' : 'external-project';
} catch {
return 'none';
}
}
app.post('/api/runs', async (req, res) => {
if (daemonShuttingDown) {
return sendApiError(res, 503, 'UPSTREAM_UNAVAILABLE', 'daemon is shutting down');
}
const mediaExecution = parseMediaExecutionPolicyInput(req.body?.mediaExecution);
const requestBody = req.body && typeof req.body === 'object' ? req.body : {};
const mediaExecution = parseMediaExecutionPolicyInput(requestBody.mediaExecution);
if (!mediaExecution.ok) {
return sendApiError(res, 400, 'BAD_REQUEST', mediaExecution.message);
}
const toolBundle = parseRunToolBundleForRequest(requestBody.toolBundle);
if (!toolBundle.ok) {
return sendApiError(res, 400, 'BAD_REQUEST', toolBundle.message);
}
// Plan §3.A1 / spec §11.5: resolve any pluginId / appliedPluginSnapshotId
// before the run is created. The resolver returns null when the body
// does not mention a plugin (legacy runs unchanged), an error envelope
@ -13023,7 +13061,7 @@ export async function startServer({
// bundled scenario that is not installed leaves the run plugin-less,
// which matches the legacy path.
let resolvedSnapshot = null;
if (typeof req.body?.projectId === 'string' && req.body.projectId) {
if (typeof requestBody.projectId === 'string' && requestBody.projectId) {
let registryView;
try {
registryView = await loadPluginRegistryView();
@ -13031,26 +13069,26 @@ export async function startServer({
return res.status(500).json({ error: String(err) });
}
const explicitPlugin =
req.body && (req.body.pluginId || req.body.appliedPluginSnapshotId);
let runResolveBody = req.body;
requestBody.pluginId || requestBody.appliedPluginSnapshotId;
let runResolveBody = requestBody;
if (!explicitPlugin) {
const projectRow = getProject(db, req.body.projectId);
const projectRow = getProject(db, requestBody.projectId);
const hasPin =
typeof projectRow?.appliedPluginSnapshotId === 'string'
&& projectRow.appliedPluginSnapshotId.length > 0;
if (!hasPin) {
const fallbackPluginId = defaultScenarioPluginIdForProjectMetadata(projectRow?.metadata);
if (fallbackPluginId && getInstalledPlugin(db, fallbackPluginId)) {
runResolveBody = { ...req.body, pluginId: fallbackPluginId };
runResolveBody = { ...requestBody, pluginId: fallbackPluginId };
}
}
}
const resolved = resolvePluginSnapshot({
db,
body: runResolveBody,
projectId: req.body.projectId,
conversationId: typeof req.body.conversationId === 'string'
? req.body.conversationId
projectId: requestBody.projectId,
conversationId: typeof requestBody.conversationId === 'string'
? requestBody.conversationId
: null,
registry: registryView,
connectorProbe: buildConnectorProbe(connectorService),
@ -13058,7 +13096,7 @@ export async function startServer({
if (resolved && !resolved.ok) {
if (!explicitPlugin) {
console.warn(
`[plugins] default-scenario fallback skipped for run on project ${req.body.projectId}: ${resolved.body?.error?.code ?? 'unknown'}`,
`[plugins] default-scenario fallback skipped for run on project ${requestBody.projectId}: ${resolved.body?.error?.code ?? 'unknown'}`,
);
} else {
return res.status(resolved.status).json(resolved.body);
@ -13067,7 +13105,11 @@ export async function startServer({
resolvedSnapshot = resolved;
}
}
const meta = { ...(req.body || {}), mediaExecution: mediaExecution.policy };
const meta = {
...requestBody,
mediaExecution: mediaExecution.policy,
toolBundle: toolBundle.bundle,
};
if (resolvedSnapshot?.ok) {
meta.appliedPluginSnapshotId = resolvedSnapshot.snapshotId;
if (!meta.pluginId) meta.pluginId = resolvedSnapshot.snapshot.pluginId;
@ -13079,6 +13121,53 @@ export async function startServer({
if (renderedQuery.length > 0) meta.message = renderedQuery;
}
}
let runProject = null;
if (typeof meta.projectId === 'string' && meta.projectId) {
try {
runProject = getProject(db, meta.projectId);
assertSandboxProjectRootAvailable(runProject?.metadata);
} catch (err) {
if (err instanceof SandboxImportedProjectError) {
return sendApiError(res, 400, 'BAD_REQUEST', err.message);
}
throw err;
}
}
// MCP / SDK callers may omit agentId. Resolve it before any run-create
// side effects so unsupported run-scoped tool bundles can fail cleanly.
if (typeof meta.agentId !== 'string' || !meta.agentId) {
try {
const appCfg = await readAppConfig(RUNTIME_DATA_DIR);
const cfgAgent = typeof appCfg.agentId === 'string' && appCfg.agentId
? appCfg.agentId
: null;
const agents = await detectAgents(appCfg.agentCliEnv ?? {}).catch(() => []);
const cfgAgentAvailable = cfgAgent
? agents.some((agent) => agent.id === cfgAgent && agent.available)
: false;
if (cfgAgent && cfgAgentAvailable) {
meta.agentId = cfgAgent;
} else {
const firstAvailable = agents.find((a) => a.available)?.id ?? null;
if (firstAvailable) meta.agentId = firstAvailable;
}
} catch (err) {
console.warn('[runs] agent id fallback failed', err);
}
}
const toolBundleSupport = validateRunToolBundleForAgent(
toolBundle.bundle,
typeof meta.agentId === 'string' ? getAgentDef(meta.agentId) : null,
{
deliveryTarget: runToolBundleDeliveryTargetForProject(
meta.projectId,
runProject?.metadata,
),
},
);
if (!toolBundleSupport.ok) {
return sendApiError(res, 400, 'BAD_REQUEST', toolBundleSupport.message);
}
// MCP / SDK callers POST /api/runs with just a projectId — no
// conversationId, no pre-created assistantMessageId — because they
// don't know about OD's chat-row lifecycle. The web flow
@ -13138,30 +13227,6 @@ export async function startServer({
console.warn('[runs] mcp conversation fallback failed', err);
}
}
// MCP / SDK callers may omit agentId. Resolve it from the saved
// app-config agent (the user's configured default) or the first
// available CLI so the run does not immediately fail with
// "unknown agent: undefined" inside startChatRun.
if (typeof meta.agentId !== 'string' || !meta.agentId) {
try {
const appCfg = await readAppConfig(RUNTIME_DATA_DIR);
const cfgAgent = typeof appCfg.agentId === 'string' && appCfg.agentId
? appCfg.agentId
: null;
const agents = await detectAgents(appCfg.agentCliEnv ?? {}).catch(() => []);
const cfgAgentAvailable = cfgAgent
? agents.some((agent) => agent.id === cfgAgent && agent.available)
: false;
if (cfgAgent && cfgAgentAvailable) {
meta.agentId = cfgAgent;
} else {
const firstAvailable = agents.find((a) => a.available)?.id ?? null;
if (firstAvailable) meta.agentId = firstAvailable;
}
} catch (err) {
console.warn('[runs] agent id fallback failed', err);
}
}
const run = design.runs.create(meta);
try {
pinAssistantMessageOnRunCreate(db, run);
@ -13576,11 +13641,45 @@ export async function startServer({
if (daemonShuttingDown) {
return sendApiError(res, 503, 'UPSTREAM_UNAVAILABLE', 'daemon is shutting down');
}
const mediaExecution = parseMediaExecutionPolicyInput(req.body?.mediaExecution);
const requestBody = req.body && typeof req.body === 'object' ? req.body : {};
const mediaExecution = parseMediaExecutionPolicyInput(requestBody.mediaExecution);
if (!mediaExecution.ok) {
return sendApiError(res, 400, 'BAD_REQUEST', mediaExecution.message);
}
const meta = { ...(req.body || {}), mediaExecution: mediaExecution.policy };
const toolBundle = parseRunToolBundleForRequest(requestBody.toolBundle);
if (!toolBundle.ok) {
return sendApiError(res, 400, 'BAD_REQUEST', toolBundle.message);
}
let chatProject = null;
if (typeof requestBody.projectId === 'string' && requestBody.projectId) {
try {
chatProject = getProject(db, requestBody.projectId);
assertSandboxProjectRootAvailable(chatProject?.metadata);
} catch (err) {
if (err instanceof SandboxImportedProjectError) {
return sendApiError(res, 400, 'BAD_REQUEST', err.message);
}
throw err;
}
}
const toolBundleSupport = validateRunToolBundleForAgent(
toolBundle.bundle,
typeof requestBody.agentId === 'string' ? getAgentDef(requestBody.agentId) : null,
{
deliveryTarget: runToolBundleDeliveryTargetForProject(
requestBody.projectId,
chatProject?.metadata,
),
},
);
if (!toolBundleSupport.ok) {
return sendApiError(res, 400, 'BAD_REQUEST', toolBundleSupport.message);
}
const meta = {
...requestBody,
mediaExecution: mediaExecution.policy,
toolBundle: toolBundle.bundle,
};
const run = design.runs.create(meta);
design.runs.stream(run, req, res);
design.runs.start(run, () => startChatRun(meta, run));
@ -13657,6 +13756,7 @@ export async function startServer({
if (routine.target.mode === 'reuse') {
const project = getProject(db, routine.target.projectId);
if (!project) throw new Error(`Routine target project ${routine.target.projectId} not found`);
assertSandboxProjectRootAvailable(project.metadata);
projectId = project.id;
projectName = project.name;
previousProjectSnapshotId = project.appliedPluginSnapshotId ?? null;

View file

@ -1,6 +1,6 @@
import type http from 'node:http';
import { mkdtempSync, rmSync, symlinkSync } from 'node:fs';
import { mkdir, readFile, stat, writeFile } from 'node:fs/promises';
import { chmod, mkdir, readFile, realpath, stat, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
@ -56,26 +56,6 @@ describe('POST /api/import/folder', () => {
}
}
async function waitForRunStatus(
runId: string,
): Promise<{ status: string; error?: string | null; errorCode?: string | null }> {
let lastStatus = 'unknown';
for (let attempt = 0; attempt < 200; attempt += 1) {
const statusResponse = await fetch(`${baseUrl}/api/runs/${runId}`);
const statusBody = (await statusResponse.json()) as {
status: string;
error?: string | null;
errorCode?: string | null;
};
lastStatus = statusBody.status;
if (statusBody.status !== 'queued' && statusBody.status !== 'running') {
return statusBody;
}
await new Promise((resolve) => setTimeout(resolve, 25));
}
throw new Error(`run did not reach a terminal status; last status: ${lastStatus}`);
}
it('creates a project rooted at the submitted folder', async () => {
const folder = makeFolder();
await writeFile(path.join(folder, 'index.html'), '<!doctype html>');
@ -105,7 +85,7 @@ describe('POST /api/import/folder', () => {
});
});
it('fails sandbox runs for imported folders instead of using an empty managed project', async () => {
it('rejects sandbox runs for imported folders before creating a run', async () => {
const folder = makeFolder();
await writeFile(path.join(folder, 'index.html'), '<!doctype html>');
@ -123,15 +103,78 @@ describe('POST /api/import/folder', () => {
message: 'Inspect the imported project.',
}),
});
expect(runResp.status).toBe(202);
const { runId } = (await runResp.json()) as { runId: string };
const status = await waitForRunStatus(runId);
expect(status.status).toBe('failed');
expect(status.errorCode).toBe('BAD_REQUEST');
expect(status.error).toMatch(/imported-folder projects.*OD_SANDBOX_MODE/i);
expect(runResp.status).toBe(400);
const body = (await runResp.json()) as { error?: { message?: string } };
expect(body.error?.message).toMatch(/imported-folder projects.*OD_SANDBOX_MODE/i);
});
});
it('rejects sandbox chat runs for imported folders before creating a run', async () => {
const folder = makeFolder();
await writeFile(path.join(folder, 'index.html'), '<!doctype html>');
const importResp = await importFolder({ baseDir: folder });
expect(importResp.status).toBe(200);
const { project } = (await importResp.json()) as { project: { id: string } };
await withSandboxMode(async () => {
const chatResp = await fetch(`${baseUrl}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
agentId: 'claude',
projectId: project.id,
message: 'Inspect the imported project.',
}),
});
expect(chatResp.status).toBe(400);
const body = (await chatResp.json()) as { error?: { message?: string } };
expect(body.error?.message).toMatch(/imported-folder projects.*OD_SANDBOX_MODE/i);
const runsResp = await fetch(`${baseUrl}/api/runs?projectId=${encodeURIComponent(project.id)}`);
expect(runsResp.status).toBe(200);
const runsBody = (await runsResp.json()) as { runs: unknown[] };
expect(runsBody.runs).toHaveLength(0);
});
});
it('opens imported-folder projects through host editor routes in sandbox mode', async () => {
const folder = makeFolder();
await writeFile(path.join(folder, 'index.html'), '<!doctype html>');
const binDir = makeFolder();
const cursorBin = path.join(
binDir,
process.platform === 'win32' ? 'cursor.cmd' : 'cursor',
);
await writeFile(
cursorBin,
process.platform === 'win32' ? '@echo off\r\nexit /b 0\r\n' : '#!/bin/sh\nexit 0\n',
);
await chmod(cursorBin, 0o755);
const importResp = await importFolder({ baseDir: folder });
expect(importResp.status).toBe(200);
const { project } = (await importResp.json()) as { project: { id: string } };
const previousPath = process.env.PATH;
process.env.PATH = `${binDir}${path.delimiter}${previousPath ?? ''}`;
try {
await withSandboxMode(async () => {
const resp = await fetch(`${baseUrl}/api/projects/${project.id}/open-in`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ editorId: 'cursor' }),
});
expect(resp.status).toBe(200);
const body = (await resp.json()) as { path?: string };
expect(body.path).toBe(await realpath(folder));
});
} finally {
if (previousPath == null) delete process.env.PATH;
else process.env.PATH = previousPath;
}
});
it('still opens an imported-folder project record in sandbox mode', async () => {
const folder = makeFolder();
await writeFile(path.join(folder, 'index.html'), '<!doctype html>');

View file

@ -68,10 +68,14 @@ process.exit(0);
async function waitForRunStatus(
baseUrl: string,
runId: string,
): Promise<{ status: string }> {
): Promise<{ status: string; error?: string | null; errorCode?: string | null }> {
for (let attempt = 0; attempt < 200; attempt += 1) {
const r = await fetch(`${baseUrl}/api/runs/${runId}`);
const body = (await r.json()) as { status: string };
const body = (await r.json()) as {
status: string;
error?: string | null;
errorCode?: string | null;
};
if (body.status !== 'queued' && body.status !== 'running') return body;
await new Promise((resolve) => setTimeout(resolve, 25));
}
@ -82,6 +86,7 @@ describe('spawn writes external MCP config for Claude Code', () => {
let server: http.Server;
let baseUrl: string;
const projectsToClean: string[] = [];
const tempDirs: string[] = [];
beforeAll(async () => {
const started = (await startServer({ port: 0, returnServer: true })) as {
@ -106,9 +111,12 @@ describe('spawn writes external MCP config for Claude Code', () => {
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ servers: [] }),
}).catch(() => {});
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
async function createProject(): Promise<{ id: string; dir: string }> {
async function createProject(): Promise<{ id: string; dir: string; conversationId: string }> {
const id = `mcp-spawn-${randomUUID()}`;
const r = await fetch(`${baseUrl}/api/projects`, {
method: 'POST',
@ -116,6 +124,7 @@ describe('spawn writes external MCP config for Claude Code', () => {
body: JSON.stringify({ id, name: id }),
});
expect(r.ok).toBe(true);
const body = (await r.json()) as { conversationId: string };
projectsToClean.push(id);
// The daemon owns its data dir; we discover the on-disk project path by
// having the daemon return the upload root, then composing path manually.
@ -123,7 +132,46 @@ describe('spawn writes external MCP config for Claude Code', () => {
const projectsBase = process.env.OD_DATA_DIR
? join(process.env.OD_DATA_DIR, 'projects')
: join(process.cwd(), '.od', 'projects');
return { id, dir: join(projectsBase, id) };
return { id, dir: join(projectsBase, id), conversationId: body.conversationId };
}
async function importFolderProject(): Promise<{
id: string;
dir: string;
externalDir: string;
conversationId: string;
}> {
const externalDir = await fsp.mkdtemp(join(tmpdir(), 'od-mcp-import-'));
tempDirs.push(externalDir);
await fsp.writeFile(join(externalDir, 'index.html'), '<!doctype html>');
const r = await fetch(`${baseUrl}/api/import/folder`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ baseDir: externalDir }),
});
expect(r.ok).toBe(true);
const body = (await r.json()) as { project: { id: string }; conversationId: string };
projectsToClean.push(body.project.id);
const projectsBase = process.env.OD_DATA_DIR
? join(process.env.OD_DATA_DIR, 'projects')
: join(process.cwd(), '.od', 'projects');
return {
id: body.project.id,
dir: join(projectsBase, body.project.id),
externalDir,
conversationId: body.conversationId,
};
}
async function withSandboxMode<T>(run: () => Promise<T>): Promise<T> {
const previous = process.env.OD_SANDBOX_MODE;
process.env.OD_SANDBOX_MODE = '1';
try {
return await run();
} finally {
if (previous == null) delete process.env.OD_SANDBOX_MODE;
else process.env.OD_SANDBOX_MODE = previous;
}
}
it('writes .mcp.json into the per-project dir, then removes it when servers are cleared', async () => {
@ -197,6 +245,347 @@ describe('spawn writes external MCP config for Claude Code', () => {
});
}, 30_000);
it('fails sandbox runs for imported-folder projects before writing MCP config', async () => {
await withFakeClaude(async () => {
const putRes = await fetch(`${baseUrl}/api/mcp/servers`, {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
servers: [
{
id: 'sandbox-run',
transport: 'sse',
enabled: true,
url: 'https://mcp.example.test',
},
],
}),
});
expect(putRes.ok).toBe(true);
const { id, dir, externalDir, conversationId } = await importFolderProject();
await withSandboxMode(async () => {
const chatRes = await fetch(`${baseUrl}/api/runs`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
agentId: 'claude',
projectId: id,
message: 'hello sandbox mcp',
}),
});
expect(chatRes.status).toBe(400);
const body = (await chatRes.json()) as { error?: { message?: string } };
expect(body.error?.message).toMatch(/imported-folder projects.*OD_SANDBOX_MODE/i);
});
const managedTarget = join(dir, '.mcp.json');
expect(existsSync(managedTarget)).toBe(false);
expect(existsSync(join(externalDir, '.mcp.json'))).toBe(false);
const messagesRes = await fetch(
`${baseUrl}/api/projects/${id}/conversations/${conversationId}/messages`,
);
expect(messagesRes.ok).toBe(true);
const messagesBody = (await messagesRes.json()) as {
messages: Array<{ role: string; content: string }>;
};
expect(messagesBody.messages.some((msg) => msg.content === 'hello sandbox mcp')).toBe(false);
});
}, 30_000);
it('rejects sandbox routine reuse of imported-folder projects before creating run state', async () => {
const { id } = await importFolderProject();
const conversationsBeforeRes = await fetch(`${baseUrl}/api/projects/${id}/conversations`);
expect(conversationsBeforeRes.ok).toBe(true);
const conversationsBeforeBody = (await conversationsBeforeRes.json()) as {
conversations: Array<{ id: string }>;
};
const conversationIdsBefore = conversationsBeforeBody.conversations.map((conversation) => conversation.id);
let routineId: string | null = null;
try {
const createRoutineRes = await fetch(`${baseUrl}/api/routines`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
name: 'Sandbox imported folder routine',
prompt: 'try to run inside an imported folder',
schedule: { kind: 'daily', time: '09:00', timezone: 'UTC' },
target: { mode: 'reuse', projectId: id },
agentId: 'claude',
enabled: false,
}),
});
expect(createRoutineRes.status).toBe(201);
const createRoutineBody = (await createRoutineRes.json()) as {
routine: { id: string };
};
routineId = createRoutineBody.routine.id;
await withSandboxMode(async () => {
const runRoutineRes = await fetch(`${baseUrl}/api/routines/${routineId}/run`, {
method: 'POST',
});
expect(runRoutineRes.status).toBe(500);
const runRoutineBody = (await runRoutineRes.json()) as { error?: string };
expect(runRoutineBody.error).toMatch(/imported-folder projects.*OD_SANDBOX_MODE/i);
});
const routineRunsRes = await fetch(`${baseUrl}/api/routines/${routineId}/runs?limit=10`);
expect(routineRunsRes.ok).toBe(true);
const routineRunsBody = (await routineRunsRes.json()) as { runs: unknown[] };
expect(routineRunsBody.runs).toHaveLength(0);
const runsRes = await fetch(`${baseUrl}/api/runs?projectId=${encodeURIComponent(id)}`);
expect(runsRes.ok).toBe(true);
const runsBody = (await runsRes.json()) as { runs: unknown[] };
expect(runsBody.runs).toHaveLength(0);
const conversationsAfterRes = await fetch(`${baseUrl}/api/projects/${id}/conversations`);
expect(conversationsAfterRes.ok).toBe(true);
const conversationsAfterBody = (await conversationsAfterRes.json()) as {
conversations: Array<{ id: string }>;
};
expect(conversationsAfterBody.conversations.map((conversation) => conversation.id)).toEqual(
conversationIdsBefore,
);
} finally {
if (routineId) {
await fetch(`${baseUrl}/api/routines/${routineId}`, { method: 'DELETE' }).catch(() => {});
}
}
}, 30_000);
it('injects run-scoped MCP servers without saving them to the persistent registry', async () => {
await withFakeClaude(async () => {
const { id, dir } = await createProject();
const chatRes = await fetch(`${baseUrl}/api/runs`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
agentId: 'claude',
projectId: id,
message: 'hello run-scoped mcp',
toolBundle: {
mcpServers: [
{
id: 'run-local',
transport: 'stdio',
command: 'node',
args: ['run-tool.js'],
env: { RUN_ONLY: '1' },
},
{
id: 'run-remote',
transport: 'http',
enabled: true,
authMode: 'none',
url: 'https://example.test/mcp',
headers: { 'X-Run': 'ok' },
},
],
},
}),
});
expect(chatRes.status).toBe(202);
const { runId } = (await chatRes.json()) as { runId: string };
const status = await waitForRunStatus(baseUrl, runId) as {
status: string;
toolBundle?: { mcpServers?: Array<{ id: string }> };
};
expect(status.status).toBe('succeeded');
expect(status.toolBundle?.mcpServers?.map((server) => server.id)).toEqual([
'run-local',
'run-remote',
]);
const target = join(dir, '.mcp.json');
expect(existsSync(target)).toBe(true);
const written = JSON.parse(await fsp.readFile(target, 'utf8'));
expect(written.mcpServers.run_local).toBeUndefined();
expect(written.mcpServers['run-local']).toMatchObject({
command: 'node',
args: ['run-tool.js'],
env: { RUN_ONLY: '1' },
});
expect(written.mcpServers['run-remote']).toMatchObject({
type: 'http',
url: 'https://example.test/mcp',
headers: { 'X-Run': 'ok' },
});
const persistedRes = await fetch(`${baseUrl}/api/mcp/servers`);
expect(persistedRes.ok).toBe(true);
const persisted = (await persistedRes.json()) as { servers: unknown[] };
expect(persisted.servers).toEqual([]);
});
}, 30_000);
it('rejects Claude run-scoped MCP bundles for imported-folder projects', async () => {
const { id, dir, externalDir, conversationId } = await importFolderProject();
const runsRes = await fetch(`${baseUrl}/api/runs`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
agentId: 'claude',
projectId: id,
message: 'imported run-scoped tools',
toolBundle: {
mcpServers: [
{
id: 'run-local',
transport: 'stdio',
command: 'node',
},
],
},
}),
});
expect(runsRes.status).toBe(400);
const runsBody = (await runsRes.json()) as { error?: { message?: string } };
expect(runsBody.error?.message).toContain('toolBundle requires a daemon-managed project');
const chatRes = await fetch(`${baseUrl}/api/chat`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
agentId: 'claude',
projectId: id,
message: 'imported chat-scoped tools',
toolBundle: {
mcpServers: [
{
id: 'run-local-chat',
transport: 'stdio',
command: 'node',
},
],
},
}),
});
expect(chatRes.status).toBe(400);
const chatBody = (await chatRes.json()) as { error?: { message?: string } };
expect(chatBody.error?.message).toContain('toolBundle requires a daemon-managed project');
expect(existsSync(join(dir, '.mcp.json'))).toBe(false);
expect(existsSync(join(externalDir, '.mcp.json'))).toBe(false);
const messagesRes = await fetch(
`${baseUrl}/api/projects/${id}/conversations/${conversationId}/messages`,
);
expect(messagesRes.ok).toBe(true);
const messagesBody = (await messagesRes.json()) as {
messages: Array<{ content: string }>;
};
expect(messagesBody.messages.some((msg) => msg.content === 'imported run-scoped tools')).toBe(false);
expect(messagesBody.messages.some((msg) => msg.content === 'imported chat-scoped tools')).toBe(false);
});
it('rejects malformed run-scoped MCP bundles before creating runs', async () => {
const { id } = await createProject();
const invalidRunsRes = await fetch(`${baseUrl}/api/runs`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
agentId: 'claude',
projectId: id,
message: 'bad tools',
toolBundle: {
mcpServers: [
{
id: 'missing-command',
transport: 'stdio',
},
],
},
}),
});
expect(invalidRunsRes.status).toBe(400);
const runsBody = (await invalidRunsRes.json()) as { error?: { message?: string } };
expect(runsBody.error?.message).toContain('toolBundle.mcpServers[0] is invalid');
const invalidChatRes = await fetch(`${baseUrl}/api/chat`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
agentId: 'claude',
projectId: id,
message: 'bad tools',
toolBundle: 'bad',
}),
});
expect(invalidChatRes.status).toBe(400);
const chatBody = (await invalidChatRes.json()) as { error?: { message?: string } };
expect(chatBody.error?.message).toContain('toolBundle must be an object');
});
it('rejects run-scoped MCP bundles the selected runtime cannot receive', async () => {
const { id, conversationId } = await createProject();
const unsupportedRuntimeRes = await fetch(`${baseUrl}/api/runs`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
agentId: 'codex',
projectId: id,
message: 'bad tools',
toolBundle: {
mcpServers: [
{
id: 'run-local',
transport: 'stdio',
command: 'node',
},
],
},
}),
});
expect(unsupportedRuntimeRes.status).toBe(400);
const unsupportedRuntimeBody = (await unsupportedRuntimeRes.json()) as {
error?: { message?: string };
};
expect(unsupportedRuntimeBody.error?.message).toContain(
'Codex CLI (codex) does not support run-scoped MCP tool bundles',
);
const messagesRes = await fetch(
`${baseUrl}/api/projects/${id}/conversations/${conversationId}/messages`,
);
expect(messagesRes.ok).toBe(true);
const messagesBody = (await messagesRes.json()) as {
messages: Array<{ role: string; content: string }>;
};
expect(messagesBody.messages.some((msg) => msg.content === 'bad tools')).toBe(false);
const unsupportedTransportRes = await fetch(`${baseUrl}/api/chat`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
agentId: 'hermes',
projectId: id,
message: 'bad remote tools',
toolBundle: {
mcpServers: [
{
id: 'run-remote',
transport: 'http',
url: 'https://example.test/mcp',
},
],
},
}),
});
expect(unsupportedTransportRes.status).toBe(400);
const unsupportedTransportBody = (await unsupportedTransportRes.json()) as {
error?: { message?: string };
};
expect(unsupportedTransportBody.error?.message).toContain(
'Hermes (hermes) only supports stdio run-scoped MCP servers',
);
});
it('does not write .mcp.json for ACP agents (Hermes wires via session args)', async () => {
// ACP agents (Hermes/Kimi) consume the `mcpServers` array via the ACP
// session/new params instead of `.mcp.json`. The `.mcp.json` write path

View file

@ -0,0 +1,231 @@
import { describe, expect, it } from 'vitest';
import {
normalizeRunToolBundleForRun,
parseRunToolBundleForRequest,
resolveExternalMcpServersForRun,
summarizeRunToolBundle,
validateRunToolBundleForAgent,
} from '../src/run-tool-bundle.js';
describe('run-scoped tool bundles', () => {
it('sanitizes MCP servers onto the run and redacts spawn-only details in summaries', () => {
const bundle = normalizeRunToolBundleForRun({
mcpServers: [
{
id: 'local-tools',
label: 'Local tools',
transport: 'stdio',
command: 'node',
args: ['server.js', '--token=secret'],
env: { API_TOKEN: 'secret' },
},
{
id: 'remote-tools',
transport: 'http',
url: 'https://example.test/mcp',
headers: { Authorization: 'Bearer secret' },
},
{
id: '../bad',
transport: 'stdio',
command: 'node',
},
],
});
expect(bundle.mcpServers).toHaveLength(2);
expect(bundle.mcpServers[0]).toMatchObject({
id: 'local-tools',
command: 'node',
env: { API_TOKEN: 'secret' },
});
const summary = summarizeRunToolBundle(bundle);
expect(summary).toEqual({
mcpServers: [
{
id: 'local-tools',
label: 'Local tools',
transport: 'stdio',
enabled: true,
},
{
id: 'remote-tools',
transport: 'http',
enabled: true,
authMode: 'oauth',
},
],
});
expect(JSON.stringify(summary)).not.toContain('secret');
expect(JSON.stringify(summary)).not.toContain('server.js');
});
it('uses only run-scoped MCP servers in sandbox mode', () => {
const persistedServers = normalizeRunToolBundleForRun({
mcpServers: [
{
id: 'persisted',
transport: 'http',
url: 'https://persisted.example.test/mcp',
},
],
}).mcpServers;
const runScopedServers = normalizeRunToolBundleForRun({
mcpServers: [
{
id: 'run-only',
transport: 'stdio',
command: 'node',
args: ['run-tool.js'],
},
],
}).mcpServers;
const selection = resolveExternalMcpServersForRun({
persistedServers,
runScopedServers,
sandboxMode: true,
});
expect(selection.enabledServers.map((server) => server.id)).toEqual(['run-only']);
expect([...selection.persistedTokenServerIds]).toEqual([]);
});
it('rejects malformed run-scoped MCP server entries for request payloads', () => {
expect(parseRunToolBundleForRequest('bad')).toEqual({
ok: false,
message: 'toolBundle must be an object',
});
expect(parseRunToolBundleForRequest({ mcpServers: 'bad' })).toEqual({
ok: false,
message: 'toolBundle.mcpServers must be an array',
});
expect(parseRunToolBundleForRequest({
mcpServers: [
{
id: 'missing-command',
transport: 'stdio',
},
],
})).toEqual({
ok: false,
message: 'toolBundle.mcpServers[0] is invalid',
});
expect(parseRunToolBundleForRequest({
mcpServers: [
{
id: 'dup',
transport: 'stdio',
command: 'node',
},
{
id: 'dup',
transport: 'http',
url: 'https://example.test/mcp',
},
],
})).toEqual({
ok: false,
message: 'toolBundle.mcpServers[1] duplicates server id "dup"',
});
});
it('lets a run-scoped server override persisted config without inheriting persisted tokens', () => {
const persistedServers = normalizeRunToolBundleForRun({
mcpServers: [
{
id: 'shared',
transport: 'http',
url: 'https://persisted.example.test/mcp',
},
{
id: 'persisted-only',
transport: 'http',
url: 'https://persisted-only.example.test/mcp',
},
],
}).mcpServers;
const runScopedServers = normalizeRunToolBundleForRun({
mcpServers: [
{
id: 'shared',
transport: 'http',
url: 'https://run.example.test/mcp',
headers: { Authorization: 'Bearer run-token' },
},
],
}).mcpServers;
const selection = resolveExternalMcpServersForRun({
persistedServers,
runScopedServers,
sandboxMode: false,
});
expect(selection.enabledServers).toHaveLength(2);
expect(selection.enabledServers.find((server) => server.id === 'shared')).toMatchObject({
url: 'https://run.example.test/mcp',
});
expect([...selection.persistedTokenServerIds]).toEqual(['persisted-only']);
});
it('rejects bundles for runtimes that cannot receive the requested servers', () => {
const stdioOnly = normalizeRunToolBundleForRun({
mcpServers: [
{
id: 'local',
transport: 'stdio',
command: 'node',
},
],
});
const remote = normalizeRunToolBundleForRun({
mcpServers: [
{
id: 'remote',
transport: 'http',
url: 'https://example.test/mcp',
},
],
});
expect(validateRunToolBundleForAgent(stdioOnly, {
id: 'codex',
name: 'Codex CLI',
})).toEqual({
ok: false,
message: 'Codex CLI (codex) does not support run-scoped MCP tool bundles',
});
expect(validateRunToolBundleForAgent(remote, {
id: 'hermes',
name: 'Hermes',
externalMcpInjection: 'acp-merge',
})).toEqual({
ok: false,
message:
'toolBundle.mcpServers[0] uses http transport, but Hermes (hermes) only supports stdio run-scoped MCP servers',
});
expect(validateRunToolBundleForAgent(remote, {
id: 'claude',
name: 'Claude Code',
externalMcpInjection: 'claude-mcp-json',
})).toEqual({ ok: true });
expect(validateRunToolBundleForAgent(remote, {
id: 'claude',
name: 'Claude Code',
externalMcpInjection: 'claude-mcp-json',
}, {
deliveryTarget: 'external-project',
})).toEqual({
ok: false,
message:
'Claude Code (claude) receives run-scoped MCP tool bundles through project .mcp.json, ' +
'so toolBundle requires a daemon-managed project',
});
});
});

View file

@ -80,6 +80,45 @@ describe('chat run service shutdown', () => {
});
});
it('stores a run-scoped tool bundle and returns a redacted status summary', () => {
const runs = createRuns();
const run = runs.create({
projectId: 'project-1',
conversationId: 'conv-a',
toolBundle: {
mcpServers: [
{
id: 'run-tools',
transport: 'stdio',
command: 'node',
args: ['server.js', '--token=secret'],
env: { API_TOKEN: 'secret' },
},
],
},
}) as any;
expect(run.toolBundle.mcpServers).toHaveLength(1);
expect(run.toolBundle.mcpServers[0]).toMatchObject({
id: 'run-tools',
command: 'node',
env: { API_TOKEN: 'secret' },
});
const status = runs.statusBody(run);
expect(status.toolBundle).toEqual({
mcpServers: [
{
id: 'run-tools',
transport: 'stdio',
enabled: true,
},
],
});
expect(JSON.stringify(status)).not.toContain('secret');
expect(JSON.stringify(status)).not.toContain('server.js');
});
it('cancels active runs and terminates their child process during daemon shutdown', async () => {
const runs = createRuns();
const child = new FakeChildProcess({ closeOn: 'SIGTERM' });

View file

@ -9,6 +9,7 @@ import type {
import type { ResearchOptions } from './research';
import type { RunContextSelection } from './context.js';
import type { MediaExecutionPolicy } from './media.js';
import type { McpAuthMode, McpServerConfig, McpTransport } from './mcp';
export type ChatRole = 'user' | 'assistant';
export type ChatCommentSelectionKind = PreviewCommentSelectionKind | 'visual';
@ -45,6 +46,12 @@ export interface ChatRequest {
* local providers.
*/
mediaExecution?: MediaExecutionPolicy;
/**
* Run-scoped tool bundle supplied by an orchestrator such as Muginn.
* These servers are made available only to the spawned agent for this run
* and are never written into the persistent Settings MCP registry.
*/
toolBundle?: RunScopedToolBundle;
/**
* Optional analytics context for the v2 run_created / run_finished
* events. The daemon never trusts these for behavior they only
@ -109,6 +116,31 @@ export interface ChatAnalyticsHints {
designSystemRunContext?: ChatAnalyticsDesignSystemRunContext;
}
export interface RunScopedMcpServerConfig extends Omit<McpServerConfig, 'enabled'> {
/**
* Omitted means enabled for this run. The daemon normalizes run-scoped
* inputs through the same sanitizer as persisted MCP config, but callers
* should not need to send persisted-settings boilerplate for disposable
* tool bundles.
*/
enabled?: boolean;
}
export interface RunScopedToolBundle {
mcpServers?: RunScopedMcpServerConfig[];
}
export interface RunScopedToolBundleSummary {
mcpServers: Array<{
id: string;
label?: string;
templateId?: string;
transport: McpTransport;
enabled: boolean;
authMode?: McpAuthMode;
}>;
}
export interface ChatRunCreateRequest extends ChatRequest {
projectId: string;
conversationId: string;
@ -130,6 +162,8 @@ export interface McpRunCreateRequest {
pluginId?: string;
model?: string;
pluginInputs?: Record<string, unknown>;
mediaExecution?: MediaExecutionPolicy;
toolBundle?: RunScopedToolBundle;
}
export type ChatRunStatus = 'queued' | 'running' | 'succeeded' | 'failed' | 'canceled';
@ -219,6 +253,8 @@ export interface ChatRunStatusResponse {
eventsLogPath?: string | null;
/** Present on daemon run status responses that know the effective run policy. */
mediaExecution?: MediaExecutionPolicy;
/** Run-scoped tool bundle summary with secrets and command details redacted. */
toolBundle?: RunScopedToolBundleSummary;
}
export interface ChatRunListResponse {