feat(plugins): run reviewed DSH dependencies and expose client UI (#2, #3)

#2: admit a reviewed DSH closure's own dependency tree on Node and Bun by
resolving bare imports and requiring the target to stay in the same hashed
receipt; stage node_modules through the importer (including hidden files and
contained .bin links); admit cjs/cts module entries; and refuse incompatible
engines.dsh ranges or dsh.bundle.platforms before conversion. UI-only bundles
now stage their source closure instead of being refused as 'nothing portable'.

#3 Engine side: expose a trusted+enabled plugin's dsh.client entry and assets
through two loopback-only public routes, with source/ fallback for converted
DSH bundles, 8 MiB bounds, confined paths, safe MIME/no-store/nosniff, and no
auth bypass beyond those two GET-only paths.

Verification: cargo check for codewhale-plugins/app-server; 30 published DSH
importer tests pass; Node/Bun resolver suites (49/50 host, 10 composition),
tsc, node build.mjs, rustfmt and git diff --check pass.
This commit is contained in:
CodeWhale Bot committed 2026-11-06 10:09:05 -08:00
1 parent 1253aec032
commit b0b2b9a335
6 files changed
+719 -55

No files matched your search

+8
View File
@@ -3425,6 +3425,14 @@ pub fn build_router(state: RuntimeApiState) -> Router {
// listener here. The response already redacts account detail for
// unauthorized requests; it never mutates state.
.route("/v1/runtime/info", get(runtime_info))
.route(
"/v1/apps/plugins/{selector}/ui",
get(plugins::get_plugin_ui),
)
.route(
"/v1/apps/plugins/{selector}/ui/{*path}",
get(plugins::get_plugin_ui_asset),
)
// Authenticates per handler: the display WS also takes a single-use
// ticket, and client-token minting is master-token only.
.merge(computer_display::router(
+490 -2
View File
@@ -13,12 +13,19 @@
//! `/plugin marketplace` command: local catalog documents only, tiers are
//! display-only, and installs route through the same reviewed installer.
use std::net::SocketAddr;
use std::path::{Path as FsPath, PathBuf};
use std::sync::Arc;
use axum::Json;
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::body::Body;
use axum::extract::{ConnectInfo, Path, Query, State};
use axum::http::uri::Authority;
use axum::http::{HeaderMap, HeaderValue, StatusCode, Uri, header};
use axum::response::Response;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use tokio::io::AsyncReadExt;
use codewhale_plugins::marketplace::document::{
CatalogInstallResolution, load_catalog_document, resolve_candidate_install,
@@ -31,6 +38,9 @@ use codewhale_plugins::types::{LoadedPlugin, PluginDiagnostic, PluginDiagnosticL
use super::{ApiError, RuntimeApiState};
const PLUGIN_UI_MAX_BYTES: u64 = 8 * 1024 * 1024;
const PLUGIN_UI_SLOT: &str = "customize.plugins.detail";
// ---------------------------------------------------------------------------
// Response shapes
// ---------------------------------------------------------------------------
@@ -164,6 +174,20 @@ pub(super) struct PluginActionResponse {
pub(super) note: Option<&'static str>,
}
#[derive(Debug, Serialize)]
pub(super) struct PluginUiSlotResponse {
pub(super) slot: String,
pub(super) base: String,
pub(super) entry: String,
}
enum UiFileError {
Missing,
TooLarge,
Invalid(String),
Internal(String),
}
// ---------------------------------------------------------------------------
// Request shapes
// ---------------------------------------------------------------------------
@@ -179,6 +203,11 @@ pub(super) struct InstallPluginRequest {
pub(super) expected_content_hash: Option<String>,
}
#[derive(Debug, Deserialize)]
pub(super) struct PluginUiQuery {
pub(super) slot: Option<String>,
}
#[derive(Debug, Deserialize)]
pub(super) struct DshPreviewRequest {
/// DeepSeek Harness bundle package directory; relative paths resolve
@@ -393,6 +422,332 @@ fn find_plugin(state: &RuntimeApiState, selector: &str) -> Result<LoadedPlugin,
.ok_or_else(|| ApiError::not_found(format!("plugin '{selector}' not found")))
}
fn ui_relative_path(raw: &str) -> Result<PathBuf, &'static str> {
let raw = raw.strip_prefix("./").unwrap_or(raw);
if raw.is_empty() {
return Err("it is empty");
}
if raw.starts_with('/') || raw.contains('\\') {
return Err("it must be a relative path without backslashes");
}
if raw.contains('\0') {
return Err("it contains a NUL byte");
}
let mut relative = PathBuf::new();
for component in FsPath::new(raw).components() {
match component {
std::path::Component::Normal(part) => relative.push(part),
std::path::Component::CurDir => {}
std::path::Component::ParentDir => return Err("it must not contain '..'"),
std::path::Component::RootDir | std::path::Component::Prefix(_) => {
return Err("it must be relative");
}
}
}
if relative.as_os_str().is_empty() {
return Err("it is empty");
}
Ok(relative)
}
fn ui_relative_display(relative: &FsPath) -> String {
relative
.components()
.filter_map(|component| match component {
std::path::Component::Normal(part) => Some(part.to_string_lossy().into_owned()),
_ => None,
})
.collect::<Vec<_>>()
.join("/")
}
fn ui_client_entry(manifest: &Value) -> Option<String> {
let export = manifest.get("exports")?.get("./client")?;
match export {
Value::String(target) => Some(target.clone()),
Value::Object(targets) => ["import", "default", "."]
.iter()
.find_map(|key| targets.get(*key).and_then(Value::as_str))
.map(str::to_string),
_ => None,
}
}
fn ui_content_type(path: &FsPath) -> &'static str {
match path
.extension()
.and_then(std::ffi::OsStr::to_str)
.map(str::to_ascii_lowercase)
.as_deref()
{
Some("js" | "mjs" | "cjs") => "text/javascript; charset=utf-8",
Some("css") => "text/css; charset=utf-8",
Some("json") => "application/json; charset=utf-8",
Some("svg") => "image/svg+xml",
Some("png") => "image/png",
Some("jpg" | "jpeg") => "image/jpeg",
Some("webp") => "image/webp",
Some("woff") => "font/woff",
Some("woff2") => "font/woff2",
Some("ttf") => "font/ttf",
_ => "application/octet-stream",
}
}
fn ui_asset_response(bytes: Vec<u8>, content_type: &'static str) -> Response {
let mut response = Response::new(Body::from(bytes));
let headers = response.headers_mut();
headers.insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type));
headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
headers.insert(
header::X_CONTENT_TYPE_OPTIONS,
HeaderValue::from_static("nosniff"),
);
response
}
async fn ui_file_path(root: &FsPath, relative: &FsPath) -> Result<PathBuf, UiFileError> {
if relative.as_os_str().is_empty() {
return Err(UiFileError::Invalid("it is empty".into()));
}
let mut current = root.to_path_buf();
let mut last = None;
let mut components = relative.components().peekable();
while let Some(component) = components.next() {
let std::path::Component::Normal(part) = component else {
return Err(UiFileError::Invalid(
"it must be a relative path of literal components".into(),
));
};
current.push(part);
let metadata = match tokio::fs::symlink_metadata(&current).await {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return Err(UiFileError::Missing);
}
Err(error) => return Err(UiFileError::Internal(error.to_string())),
};
if codewhale_util::path_identity::metadata_is_link_or_reparse(&metadata) {
return Err(UiFileError::Invalid(
"it must not traverse symbolic links".into(),
));
}
if components.peek().is_some() {
if !metadata.is_dir() {
return Err(UiFileError::Missing);
}
} else {
last = Some(metadata);
}
}
let Some(metadata) = last else {
return Err(UiFileError::Invalid("it is empty".into()));
};
if !metadata.is_file() {
return Err(UiFileError::Invalid("it is not a regular file".into()));
}
let canonical =
tokio::fs::canonicalize(&current)
.await
.map_err(|error| match error.kind() {
std::io::ErrorKind::NotFound => UiFileError::Missing,
_ => UiFileError::Internal(error.to_string()),
})?;
if !canonical.starts_with(root) {
return Err(UiFileError::Invalid(
"it resolves outside the staged bundle".into(),
));
}
Ok(canonical)
}
async fn ui_file_bytes(path: &FsPath) -> Result<Vec<u8>, UiFileError> {
let file = tokio::fs::File::open(path)
.await
.map_err(|error| match error.kind() {
std::io::ErrorKind::NotFound => UiFileError::Missing,
_ => UiFileError::Internal(error.to_string()),
})?;
let metadata = file
.metadata()
.await
.map_err(|error| UiFileError::Internal(error.to_string()))?;
if !metadata.is_file() {
return Err(UiFileError::Invalid("it is not a regular file".into()));
}
if metadata.len() > PLUGIN_UI_MAX_BYTES {
return Err(UiFileError::TooLarge);
}
let mut file = file.take(PLUGIN_UI_MAX_BYTES + 1);
let mut bytes = Vec::with_capacity(metadata.len() as usize);
file.read_to_end(&mut bytes)
.await
.map_err(|error| UiFileError::Internal(error.to_string()))?;
if bytes.len() as u64 > PLUGIN_UI_MAX_BYTES {
return Err(UiFileError::TooLarge);
}
Ok(bytes)
}
fn ui_file_refusal(plugin: &LoadedPlugin, display: &str, error: UiFileError) -> ApiError {
match error {
UiFileError::Missing => ApiError::not_found(format!(
"plugin '{}' has no staged file '{display}'",
plugin.name()
)),
UiFileError::TooLarge => ApiError::payload_too_large(format!(
"plugin '{}' file '{display}' exceeds the 8 MiB plugin UI limit",
plugin.name()
)),
UiFileError::Invalid(reason) => ApiError::bad_request(format!(
"plugin '{}' file '{display}' is not a readable staged file: {reason}",
plugin.name()
)),
UiFileError::Internal(reason) => ApiError::internal(format!(
"plugin '{}' file '{display}' could not be read: {reason}",
plugin.name()
)),
}
}
async fn ui_staged_root(plugin: &LoadedPlugin) -> Result<PathBuf, ApiError> {
let Some(staged_root) = plugin.staged_root.as_deref() else {
return Err(ApiError::bad_request(format!(
"plugin '{}' has no staged runtime snapshot; review, trust and enable it to stage its files",
plugin.name()
)));
};
let root = tokio::fs::canonicalize(staged_root)
.await
.map_err(|error| {
ApiError::not_found(format!(
"plugin '{}' staged runtime snapshot is unavailable: {error}",
plugin.name()
))
})?;
let metadata = tokio::fs::metadata(&root).await.map_err(|error| {
ApiError::not_found(format!(
"plugin '{}' staged runtime snapshot is unavailable: {error}",
plugin.name()
))
})?;
if !metadata.is_dir() {
return Err(ApiError::not_found(format!(
"plugin '{}' staged runtime snapshot is not a directory",
plugin.name()
)));
}
Ok(root)
}
async fn gated_plugin_ui_root(
state: &RuntimeApiState,
selector: &str,
) -> Result<(LoadedPlugin, PathBuf), ApiError> {
let plugin = find_plugin(state, selector)?;
if !plugin.trusted() {
return Err(ApiError::bad_request(format!(
"plugin '{}' is not trusted; review its capabilities and trust it before loading its client UI",
plugin.name()
)));
}
if !plugin.enabled {
return Err(ApiError::bad_request(format!(
"plugin '{}' is disabled; enable it after trust before loading its client UI",
plugin.name()
)));
}
let root = ui_staged_root(&plugin).await?;
Ok((plugin, root))
}
async fn plugin_ui_manifest(
plugin: &LoadedPlugin,
staged_root: &FsPath,
) -> Result<Option<(PathBuf, PathBuf)>, ApiError> {
for candidate in ["package.json", "source/package.json"] {
let relative = FsPath::new(candidate);
let display = ui_relative_display(relative);
match ui_file_path(staged_root, relative).await {
Ok(path) => {
let root = path.parent().unwrap_or(staged_root).to_path_buf();
return Ok(Some((root, path)));
}
Err(UiFileError::Missing) => {}
Err(error) => return Err(ui_file_refusal(plugin, &display, error)),
}
}
Ok(None)
}
fn plugin_ui_base(
state: &RuntimeApiState,
selector: &str,
headers: &HeaderMap,
uri: &Uri,
) -> Result<String, ApiError> {
let raw = headers
.get(header::HOST)
.and_then(|value| value.to_str().ok())
.or_else(|| uri.authority().map(Authority::as_str))
.ok_or_else(|| {
ApiError::bad_request(
"plugin UI requires a loopback Host header so the sandboxed client can build an absolute asset base",
)
})?;
if raw.is_empty()
|| raw.contains('@')
|| raw.contains('/')
|| raw.contains('?')
|| raw.contains('#')
|| raw.bytes().any(|byte| byte <= b' ' || byte == 0x7f)
{
return Err(ApiError::bad_request(
"plugin UI Host must be a loopback authority with no credentials, path, query or fragment",
));
}
let authority: Authority = raw
.parse()
.map_err(|_| ApiError::bad_request("plugin UI Host is not a valid loopback authority"))?;
if !super::host_header_is_loopback(raw) {
return Err(ApiError::bad_request(format!(
"plugin UI Host '{raw}' is not a loopback host; expected 127.0.0.1, [::1] or localhost"
)));
}
let port_specified = if let Some(rest) = raw.strip_prefix('[') {
match rest.split_once(']') {
Some((_, "")) => false,
Some((_, after)) if after.starts_with(':') => true,
_ => {
return Err(ApiError::bad_request(
"plugin UI Host must be a loopback authority with no credentials, path, query or fragment",
));
}
}
} else {
raw.contains(':')
};
let host_port = authority.port_u16().filter(|port| *port != 0);
if port_specified && host_port.is_none() {
return Err(ApiError::bad_request(
"plugin UI Host must carry a numeric listener port",
));
}
let host = authority.host();
let host = if host.contains(':') {
format!("[{host}]")
} else {
host.to_string()
};
let port = host_port
.or_else(|| (state.bind_port != 0).then_some(state.bind_port))
.ok_or_else(|| ApiError::bad_request("plugin UI Host is missing the listener port"))?;
Ok(format!(
"http://{host}:{port}/v1/apps/plugins/{}/ui/",
urlencoding::encode(selector)
))
}
/// Execute an install/update/uninstall through the reviewed mutation
/// controller using the server's own config for network policy, then
/// invalidate the MCP pool so merged plugin servers reload on next use.
@@ -791,6 +1146,139 @@ pub(super) async fn get_plugin(
Ok(Json(plugin_detail(&find_plugin(&state, &selector)?)))
}
pub(super) async fn get_plugin_ui(
State(state): State<RuntimeApiState>,
ConnectInfo(peer): ConnectInfo<SocketAddr>,
Path(selector): Path<String>,
Query(query): Query<PluginUiQuery>,
headers: HeaderMap,
uri: Uri,
) -> Result<Json<PluginUiSlotResponse>, ApiError> {
if !peer.ip().is_loopback() {
return Err(ApiError::forbidden(
"plugin UI metadata is available only from loopback",
));
}
let slot = query.slot.ok_or_else(|| {
ApiError::bad_request(
"the 'slot' query parameter is required; supported slot: 'customize.plugins.detail'",
)
})?;
if slot.as_str() != PLUGIN_UI_SLOT {
return Err(ApiError::bad_request(format!(
"slot '{slot}' is not supported; supported slot: 'customize.plugins.detail'"
)));
}
let (plugin, staged_root) = gated_plugin_ui_root(&state, &selector).await?;
let Some((ui_root, manifest_path)) = plugin_ui_manifest(&plugin, &staged_root).await? else {
return Err(ApiError::not_found(format!(
"plugin '{}' declares no dsh.client UI; package.json is absent from its staged bundle and source/ directory",
plugin.name()
)));
};
let manifest_display = manifest_path
.strip_prefix(&staged_root)
.map(ui_relative_display)
.unwrap_or_else(|_| "package.json".to_string());
let manifest_bytes = ui_file_bytes(&manifest_path)
.await
.map_err(|error| ui_file_refusal(&plugin, &manifest_display, error))?;
let manifest: Value = serde_json::from_slice(&manifest_bytes).map_err(|error| {
ApiError::bad_request(format!(
"plugin '{}' {manifest_display} is not valid JSON: {error}",
plugin.name()
))
})?;
if manifest
.get("dsh")
.and_then(|dsh| dsh.get("client"))
.is_none_or(Value::is_null)
{
return Err(ApiError::not_found(format!(
"plugin '{}' declares no dsh.client UI in {manifest_display}; add dsh.client and exports[\"./client\"] to expose client assets",
plugin.name()
)));
}
let entry = match ui_client_entry(&manifest) {
Some(raw) => {
let relative = ui_relative_path(&raw).map_err(|reason| {
ApiError::bad_request(format!(
"plugin '{}' dsh.client entry '{raw}' is invalid: {reason}",
plugin.name()
))
})?;
match ui_file_path(&ui_root, &relative).await {
Ok(_) => ui_relative_display(&relative),
Err(UiFileError::Missing) => {
return Err(ApiError::not_found(format!(
"plugin '{}' dsh.client entry '{raw}' does not exist in its staged bundle; update the plugin or fix exports[\"./client\"]",
plugin.name()
)));
}
Err(error) => {
return Err(ui_file_refusal(
&plugin,
&ui_relative_display(&relative),
error,
));
}
}
}
None => {
let fallback = PathBuf::from("client.js");
match ui_file_path(&ui_root, &fallback).await {
Ok(_) => ui_relative_display(&fallback),
Err(UiFileError::Missing) => {
return Err(ApiError::not_found(format!(
"plugin '{}' declares dsh.client but has no client entry; set exports[\"./client\"] or add client.js to the staged bundle",
plugin.name()
)));
}
Err(error) => {
return Err(ui_file_refusal(
&plugin,
&ui_relative_display(&fallback),
error,
));
}
}
}
};
let base = plugin_ui_base(&state, &selector, &headers, &uri)?;
Ok(Json(PluginUiSlotResponse { slot, base, entry }))
}
pub(super) async fn get_plugin_ui_asset(
State(state): State<RuntimeApiState>,
ConnectInfo(peer): ConnectInfo<SocketAddr>,
Path((selector, path)): Path<(String, String)>,
) -> Result<Response, ApiError> {
if !peer.ip().is_loopback() {
return Err(ApiError::forbidden(
"plugin UI assets are available only from loopback",
));
}
let (plugin, staged_root) = gated_plugin_ui_root(&state, &selector).await?;
let ui_root = plugin_ui_manifest(&plugin, &staged_root)
.await?
.map(|(root, _)| root)
.unwrap_or(staged_root);
let relative = ui_relative_path(&path).map_err(|reason| {
ApiError::bad_request(format!(
"plugin '{}' UI path '{path}' is invalid: {reason}",
plugin.name()
))
})?;
let display = ui_relative_display(&relative);
let staged_path = ui_file_path(&ui_root, &relative)
.await
.map_err(|error| ui_file_refusal(&plugin, &display, error))?;
let bytes = ui_file_bytes(&staged_path)
.await
.map_err(|error| ui_file_refusal(&plugin, &display, error))?;
Ok(ui_asset_response(bytes, ui_content_type(&relative)))
}
/// `POST /v1/apps/plugins/install`
pub(super) async fn install_plugin_api(
State(state): State<RuntimeApiState>,
@@ -14982,13 +14982,26 @@ function checkedBunSpecifier(specifier, closure, caller, require3) {
return specifier;
}
if (nodeModule2.isBuiltin(specifier)) return specifier.startsWith("node:") ? specifier : `node:${specifier}`;
const callerPath = fileURLToPath2(caller);
const file = specifier.startsWith("file:") || specifier.startsWith(".") || isAbsolute2(specifier) ? new URL(specifier, caller) : void 0;
const path = file ? require3 && !specifier.startsWith("file:") ? resolve3(dirname(fileURLToPath2(caller)), specifier) : resolve3(fileURLToPath2(file)) : void 0;
if (!path) throw new Error("bare dependency is absent from this reviewed composition; package its reviewed relative source");
if (file?.search || file?.hash) throw new Error('Bun does not preserve reviewed module query or fragment identity; select [extension_host] runtime = "node" for this composition');
let path;
if (file) path = require3 && !specifier.startsWith("file:") ? resolve3(dirname(callerPath), specifier) : resolve3(fileURLToPath2(file));
else if (require3) path = nodeModule2.createRequire(caller).resolve(specifier);
else path = bunResolve(specifier, dirname(callerPath), caller);
const inside = keyIn(closure, path);
if (inside === void 0 || !(inside in closure.files)) throw new Error("composition import escapes the reviewed file closure");
return require3 ? path : file.href;
if (inside === void 0 || !(inside in closure.files) || !unlinkedInside(closure, inside, path)) throw new Error("composition import escapes the reviewed file closure");
return require3 ? path : file ? file.href : pathToFileURL3(path).href;
}
function bunResolve(specifier, from2, caller) {
const bun = globalThis.Bun;
if (typeof bun?.resolveSync === "function") {
try {
return bun.resolveSync(specifier, from2);
} catch {
}
}
return nodeModule2.createRequire(caller).resolve(specifier);
}
function keyIn(closure, path) {
const canonical = insideKey(closure.canonicalRoot, path);
@@ -15066,11 +15079,15 @@ function installResolveHooks(modules) {
return { url: `${SCHEME}${key}`, format: "module", shortCircuit: true };
}
const caller = closureAt(context.parentURL);
if (caller && !nodeModule2.isBuiltin(specifier) && !specifier.startsWith(".") && !specifier.startsWith("/") && !specifier.startsWith("file:")) throw new Error("bare dependency is absent from this reviewed composition; package its reviewed relative source");
const bare = !nodeModule2.isBuiltin(specifier) && !specifier.startsWith(".") && !specifier.startsWith("/") && !specifier.startsWith("file:");
const result = nextResolve(specifier, context);
if (caller && result.url.startsWith("file:")) {
const target = closureAt(result.url);
if (!target || target.root !== caller.root || !(target.path in caller.receipt.files)) throw new Error("composition import escapes the reviewed file closure");
if (caller) {
if (result.url.startsWith("file:")) {
const target = closureAt(result.url);
if (!target || target.root !== caller.root || !(target.path in caller.receipt.files) || !unlinkedInside(caller.receipt, target.path, fileURLToPath2(result.url))) throw new Error("composition import escapes the reviewed file closure");
} else if (bare) {
throw new Error("composition import escapes the reviewed file closure");
}
}
return result;
},
@@ -18055,7 +18072,7 @@ init_define_BUILTIN_MODULE_DIGESTS();
init_lib2();
import { createHash as createHash6 } from "node:crypto";
import { readFileSync as readFileSync3 } from "node:fs";
import { createRequire as createRequire3 } from "node:module";
import { createRequire as createRequire4 } from "node:module";
import { fileURLToPath as fileURLToPath6 } from "node:url";
// node_modules/@deepseek-ai/schemastery/lib/index.mjs
@@ -18921,7 +18938,7 @@ function killHostTree() {
}
return realExit(0);
}
var { Worker } = createRequire3(import.meta.url)("node:worker_threads");
var { Worker } = createRequire4(import.meta.url)("node:worker_threads");
var watchdog = new Worker(
`const { workerData } = require('node:worker_threads')
const parent = process.ppid
@@ -160,14 +160,25 @@ function checkedBunSpecifier(specifier:string,closure:ReviewedClosure,caller:str
return specifier
}
if(nodeModule.isBuiltin(specifier))return specifier.startsWith('node:')?specifier:`node:${specifier}`
const callerPath=fileURLToPath(caller)
const file=(specifier.startsWith('file:') || specifier.startsWith('.') || isAbsolute(specifier))
?new URL(specifier,caller):undefined
const path=file?(require && !specifier.startsWith('file:')?resolve(dirname(fileURLToPath(caller)),specifier):resolve(fileURLToPath(file))):undefined
if(!path)throw new Error('bare dependency is absent from this reviewed composition; package its reviewed relative source')
if(file?.search || file?.hash)throw new Error('Bun does not preserve reviewed module query or fragment identity; select [extension_host] runtime = \"node\" for this composition')
if(file?.search || file?.hash)throw new Error('Bun does not preserve reviewed module query or fragment identity; select [extension_host] runtime = "node" for this composition')
let path:string
if(file) path=require && !specifier.startsWith('file:')?resolve(dirname(callerPath),specifier):resolve(fileURLToPath(file))
else if(require) path=nodeModule.createRequire(caller).resolve(specifier)
else path=bunResolve(specifier,dirname(callerPath),caller)
const inside=keyIn(closure,path)
if(inside===undefined || !(inside in closure.files))throw new Error('composition import escapes the reviewed file closure')
return require?path:file!.href
if(inside===undefined || !(inside in closure.files) || !unlinkedInside(closure,inside,path))throw new Error('composition import escapes the reviewed file closure')
return require?path:file?file.href:pathToFileURL(path).href
}
function bunResolve(specifier:string,from:string,caller:string):string {
const bun=(globalThis as any).Bun
if(typeof bun?.resolveSync==='function') {
try {return bun.resolveSync(specifier,from)} catch {}
}
return nodeModule.createRequire(caller).resolve(specifier)
}
// Module graph admission for an already-reviewed composition. This is an
@@ -258,11 +269,15 @@ export function installResolveHooks(modules: Record<string, Record<string, unkno
return { url: `${SCHEME}${key}`, format: 'module', shortCircuit: true }
}
const caller=closureAt(context.parentURL)
if(caller && !nodeModule.isBuiltin(specifier) && !specifier.startsWith('.') && !specifier.startsWith('/') && !specifier.startsWith('file:')) throw new Error('bare dependency is absent from this reviewed composition; package its reviewed relative source')
const bare=!nodeModule.isBuiltin(specifier) && !specifier.startsWith('.') && !specifier.startsWith('/') && !specifier.startsWith('file:')
const result=nextResolve(specifier, context)
if(caller && result.url.startsWith('file:')) {
const target=closureAt(result.url)
if(!target || target.root!==caller.root || !(target.path in caller.receipt.files)) throw new Error('composition import escapes the reviewed file closure')
if(caller) {
if(result.url.startsWith('file:')) {
const target=closureAt(result.url)
if(!target || target.root!==caller.root || !(target.path in caller.receipt.files) || !unlinkedInside(caller.receipt,target.path,fileURLToPath(result.url))) throw new Error('composition import escapes the reviewed file closure')
} else if(bare) {
throw new Error('composition import escapes the reviewed file closure')
}
}
return result
},
+159 -25
View File
@@ -529,6 +529,118 @@ pub struct LoadedBundle {
pub outcomes: Vec<DshOutcome>,
}
fn host_node_platform() -> Option<&'static str> {
match std::env::consts::OS {
"macos" => Some("darwin"),
"windows" => Some("win32"),
"linux" => Some("linux"),
_ => None,
}
}
fn node_platform(name: &str) -> Option<&'static str> {
match name {
"darwin" | "macos" => Some("darwin"),
"win32" | "windows" => Some("win32"),
"linux" => Some("linux"),
_ => None,
}
}
fn target_dsh_version() -> Result<semver::Version> {
let receipt: Json = serde_json::from_slice(crate::activation::DSH_COMPATIBILITY)
.map_err(|_| anyhow::Error::msg("The pinned DSH compatibility receipt is unreadable."))?;
let version = receipt
.get("target_dsh_version")
.and_then(Json::as_str)
.ok_or_else(|| {
anyhow::Error::msg("The pinned DSH compatibility receipt lacks `target_dsh_version`.")
})?;
semver::Version::parse(version).map_err(|_| {
anyhow::Error::msg(
"The pinned DSH compatibility receipt has an invalid `target_dsh_version`.",
)
})
}
fn admit_host_compatibility(manifest: &Value) -> Result<()> {
if let Some(declared) = manifest
.get("engines")
.and_then(|engines| engines.get("dsh"))
{
let Some(range) = declared.str() else {
return refuse(
"incompatible-version: `engines.dsh` must be a string semver range; author the supported DSH range or remove the field.",
);
};
let requirement = semver::VersionReq::parse(range).map_err(|_| {
anyhow::Error::msg(format!(
"incompatible-version: `engines.dsh` value `{}` is not a valid semver range.",
refusal_text(range)
))
})?;
let target = target_dsh_version()?;
require(
requirement.matches(&target),
&format!(
"incompatible-version: this bundle requires DSH `{}`, but this Codewhale host targets DSH {target}; import a bundle built for that host or update the host.",
refusal_text(range)
),
)?;
}
if let Some(declared) = manifest
.get("dsh")
.and_then(|dsh| dsh.get("bundle"))
.and_then(|bundle| bundle.get("platforms"))
{
let Value::Seq(items) = declared else {
return refuse(
"unsupported-platform: `dsh.bundle.platforms` must be a non-empty list of Node platform names (darwin/macos, win32/windows, linux).",
);
};
require(
!items.is_empty(),
"unsupported-platform: `dsh.bundle.platforms` must be a non-empty list of Node platform names (darwin/macos, win32/windows, linux).",
)?;
let mut platforms = Vec::new();
for item in items {
let Some(name) = item.str() else {
return refuse(
"unsupported-platform: every `dsh.bundle.platforms` entry must be a Node platform name (darwin/macos, win32/windows, linux).",
);
};
let Some(platform) = node_platform(name) else {
return refuse(format!(
"unsupported-platform: `{}` is not a supported Node platform name; use darwin/macos, win32/windows or linux.",
refusal_text(name)
));
};
platforms.push(platform);
}
let host = host_node_platform().ok_or_else(|| {
anyhow::Error::msg(format!(
"unsupported-platform: Codewhale cannot verify host platform `{}` against the bundle's declared platforms.",
std::env::consts::OS
))
})?;
require(
platforms.contains(&host),
&format!(
"unsupported-platform: this bundle declares only {}; this host is {host}.",
platforms.join(", ")
),
)?;
}
Ok(())
}
fn declares_client(manifest: &Value) -> bool {
manifest
.get("dsh")
.and_then(|dsh| dsh.get("client"))
.is_some_and(|client| !matches!(client, Value::Null))
}
pub fn load_bundle(package: &Package) -> Result<LoadedBundle> {
let manifest_bytes = read_file(&package.contained(Path::new("package.json"))?, MAX_DOCUMENT)
.map_err(|_| {
@@ -542,12 +654,9 @@ pub fn load_bundle(package: &Package) -> Result<LoadedBundle> {
matches!(bundle, Some(Value::Map(_))),
"Not a DSH bundle package: package.json lacks `dsh.bundle.patch`.",
)?;
admit_host_compatibility(&manifest)?;
let mut notes = Vec::new();
if manifest
.get("dsh")
.and_then(|dsh| dsh.get("client"))
.is_some_and(|client| !matches!(client, Value::Null))
{
if declares_client(&manifest) {
notes.push(
"package declares `dsh.client`; its UI half was not converted and stays off until Codewhale has a renderer that can keep plugin UI off the network (WebKit restrictions do not stop WebRTC)"
.to_string(),
@@ -1457,6 +1566,7 @@ impl OutputFiles {
/// Walk a contained directory without following links, bounded.
pub fn walk_files(root: &Path, mut visit: impl FnMut(&Path, bool) -> Result<()>) -> Result<()> {
let canonical_root = fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
let mut pending = vec![root.to_path_buf()];
let mut visited = 0usize;
while let Some(directory) = pending.pop() {
@@ -1470,10 +1580,23 @@ pub fn walk_files(root: &Path, mut visit: impl FnMut(&Path, bool) -> Result<()>)
)?;
let path = child.path();
let metadata = fs::symlink_metadata(&path)?;
require(
!codewhale_util::path_identity::metadata_is_link_or_reparse(&metadata),
"Source paths must not contain links or reparse points.",
)?;
if codewhale_util::path_identity::metadata_is_link_or_reparse(&metadata) {
let dependency_link = path
.strip_prefix(root)
.unwrap_or(path.as_path())
.components()
.any(|part| part.as_os_str().to_str() == Some("node_modules"));
let contained_file = dependency_link
&& fs::canonicalize(&path).is_ok_and(|target| {
target.starts_with(&canonical_root)
&& fs::metadata(&target).is_ok_and(|target| target.is_file())
});
require(
contained_file,
"Source paths must not contain links or reparse points.",
)?;
continue;
}
visit(&path, metadata.is_dir())?;
if metadata.is_dir() {
pending.push(path);
@@ -1648,6 +1771,7 @@ fn native_composition_files(
mut document: Json,
output: &mut OutputFiles,
client_outcomes: &mut Vec<DshOutcome>,
ui: bool,
) -> Result<Vec<String>> {
fn modules(rows: &[Value], names: &mut Vec<String>) -> Result<()> {
for row in rows {
@@ -1673,7 +1797,7 @@ fn native_composition_files(
}
let mut names = Vec::new();
modules(entries, &mut names)?;
if names.is_empty() {
if names.is_empty() && !ui {
return Ok(names);
}
// Portable-only import keeps absent foreign rows visible as manual ports.
@@ -1692,16 +1816,16 @@ fn native_composition_files(
_ => unresolved.push(name),
}
}
if paths.is_empty()
&& !names.iter().any(|name| {
let native_graph = !paths.is_empty()
|| names.iter().any(|name| {
name == presets::AGENT_PRESETS
|| (host_hook_bridge(name)
&& !matches!(name.as_str(), DSH_MCP_CLIENT | DSH_SKILL_FILESYSTEM))
})
{
});
if !native_graph && !ui {
return Ok(Vec::new());
}
if !unresolved.is_empty() {
if native_graph && !unresolved.is_empty() {
return refuse(format!(
"Native composition contains an unsupported or missing row with no exact admitted module ({}); no partial graph was installed.",
refusal_list(unresolved.iter().map(|name| refusal_text(name)))
@@ -1709,6 +1833,8 @@ fn native_composition_files(
}
let mut files = serde_json::Map::new();
walk_files(&package.root, |path, is_dir| {
let relative = relative_slash(path, &package.root);
let dependency = relative.split('/').any(|part| part == "node_modules");
let lower = path
.file_name()
.unwrap_or_default()
@@ -1718,18 +1844,25 @@ fn native_composition_files(
.extension()
.map(|ext| ext.to_string_lossy().to_ascii_lowercase());
require(
(!lower.starts_with('.') || lower == ".agent-presets")
(dependency || !lower.starts_with('.') || lower == ".agent-presets")
&& !matches!(
lower.as_str(),
"credentials" | "credentials.json" | "secrets.json" | "id_rsa" | "id_ed25519"
"credentials"
| "credentials.json"
| "secrets.json"
| "id_rsa"
| "id_ed25519"
| ".env"
| ".npmrc"
| ".netrc"
| ".git"
)
&& !matches!(suffix.as_deref(), Some("pem" | "key" | "p12" | "pfx")),
"Package a closed Native source without hidden files, repository metadata or credential files.",
"Package a closed Native source without credential files or repository metadata; hidden dependency files are admitted only under node_modules.",
)?;
if is_dir {
return Ok(());
}
let relative = relative_slash(path, &package.root);
let bytes = read_file(
&package.contained(Path::new(&relative))?,
output.remaining(),
@@ -1768,11 +1901,7 @@ fn native_composition_files(
anyhow::Error::msg("Configuration and skill entrypoints must be UTF-8.")
})?;
let manifest = parse_json(text)?;
if manifest
.get("dsh")
.and_then(|dsh| dsh.get("client"))
.is_some_and(|client| !matches!(client, Value::Null))
{
if declares_client(&manifest) {
client_outcomes.push(DshOutcome {
row: Some(refusal_text(&name)),
package: Some(refusal_text(
@@ -1792,6 +1921,9 @@ fn native_composition_files(
directory = parent.parent();
}
}
if !native_graph {
return Ok(Vec::new());
}
let mut selected = Vec::new();
for name in &names {
if name == presets::AGENT_PRESETS || host_hook_bridge(name) {
@@ -1874,12 +2006,14 @@ pub fn convert_package(package: &Path, output: &Path) -> Result<DshConversion> {
let loaded = load_bundle(&package)?;
let mut output_files = OutputFiles::default();
let mut client_outcomes = Vec::new();
let ui = declares_client(&loaded.manifest);
let native_rows = native_composition_files(
&package,
&loaded.entries,
loaded.composition,
&mut output_files,
&mut client_outcomes,
ui,
)?;
let requires_native = !native_rows.is_empty();
let mut notes = loaded.notes;
@@ -1982,7 +2116,7 @@ pub fn convert_package(package: &Path, output: &Path) -> Result<DshConversion> {
.to_string();
}
}
if output_files.files.is_empty() && components.servers.is_empty() {
if !ui && output_files.files.is_empty() && components.servers.is_empty() {
let skipped = refusal_list(
outcomes
.iter()
+11 -9
View File
@@ -39,11 +39,11 @@ fn component_paths(plugin: &LoadedPlugin, capability: PluginActivationCapability
/// Why `path` cannot be an extension-host entry, if it cannot.
///
/// A `native` entry is one `.mjs`, `.js` or `.mts` ES module file: the host imports
/// exactly that file and re-hashes it first. This is the one statement of the
/// rule. Discovery reports it as an error diagnostic (so `/plugin validate`
/// and the review screen show it) and activation refuses the entry, both only
/// while the activation policy supports `Native` (`[features]
/// A `native` entry is one `.mjs`, `.js`, `.cjs`, `.mts` or `.cts` module file:
/// the host imports exactly that file and re-hashes it first. This is the one
/// statement of the rule. Discovery reports it as an error diagnostic (so
/// `/plugin validate` and the review screen show it) and activation refuses the
/// entry, both only while the activation policy supports `Native` (`[features]
/// extension_host`). With the flag off, `native` stays inventory-only and any
/// path is accepted as before.
///
@@ -52,10 +52,12 @@ fn component_paths(plugin: &LoadedPlugin, capability: PluginActivationCapability
/// function touches no filesystem.
#[must_use]
pub fn native_entry_problem(path: &Path, is_regular_file: bool) -> Option<&'static str> {
const RULE: &str = "a native entry must be one .mjs, .js or .mts ES module file";
let is_module = path
.extension()
.is_some_and(|extension| extension == "mjs" || extension == "js" || extension == "mts");
const RULE: &str = "a native entry must be one .mjs, .js, .cjs, .mts or .cts module file";
let is_module = path.extension().is_some_and(|extension| {
["mjs", "js", "cjs", "mts", "cts"]
.iter()
.any(|candidate| extension == *candidate)
});
(!is_module || !is_regular_file).then_some(RULE)
}