mirror of
https://github.com/nexu-io/open-design.git
synced 2026-05-31 19:04:39 +07:00
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:
parent
e8c179d3a6
commit
729ce2b0cb
10 changed files with 1174 additions and 135 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
|
|
|
|||
185
apps/daemon/src/run-tool-bundle.ts
Normal file
185
apps/daemon/src/run-tool-bundle.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>');
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
231
apps/daemon/tests/run-tool-bundle.test.ts
Normal file
231
apps/daemon/tests/run-tool-bundle.test.ts
Normal 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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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' });
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue