|
|
@@ -31,7 +31,10 @@ const preparedManifestSchema = z.object({
|
|
|
mcpServers: z.string().min(1).optional(),
|
|
|
}).strict()
|
|
|
const preparedConfigSchema = z.object({
|
|
|
- baseUrl: z.url(),
|
|
|
+ // Wrappers pass import.meta.url, which is always file: for an installed
|
|
|
+ // package; any other scheme would only fail later inside fileURLToPath with
|
|
|
+ // an uncontextualized TypeError, so reject it at this validation boundary.
|
|
|
+ baseUrl: z.url({ protocol: /^file$/ }),
|
|
|
manifest: preparedManifestSchema,
|
|
|
}).strict()
|
|
|
|
|
|
@@ -70,7 +73,14 @@ export function parsePreparedPluginConfig(value: unknown): PreparedPluginConfig
|
|
|
}
|
|
|
}
|
|
|
|
|
|
-function isOutside(root: string, candidate: string): boolean {
|
|
|
+/**
|
|
|
+ * Whether `candidate` resolves outside `root` — the containment check shared
|
|
|
+ * by prepare-time asset copying and runtime prepared-path resolution.
|
|
|
+ * @param root - directory that must contain the candidate.
|
|
|
+ * @param candidate - absolute path to test.
|
|
|
+ * @returns true when the candidate escapes the root.
|
|
|
+ */
|
|
|
+export function isOutside(root: string, candidate: string): boolean {
|
|
|
const path = relative(root, candidate)
|
|
|
/* v8 ignore next -- Different-drive Windows relative paths cannot be produced on POSIX coverage hosts. */
|
|
|
return path === '..' || path.startsWith(`..${sep}`) || isAbsolute(path)
|
|
|
@@ -95,11 +105,22 @@ async function sourcePath(pluginDirectory: string, sourceRoot: string, configure
|
|
|
}
|
|
|
|
|
|
function wrapperSource(manifest: PreparedPluginManifest): string {
|
|
|
+ // The manifest is static, so the wrapper's service dependencies are too:
|
|
|
+ // declaring them gates the wrapper fiber until the composition provides
|
|
|
+ // them, which means the runtime's SkillLocal/McpClient children activate
|
|
|
+ // within the wrapper's own load epoch and their failures (duplicate
|
|
|
+ // provider names, damaged packages) reject the wrapper's Loader
|
|
|
+ // transaction instead of leaving a silently PENDING or FAILED child.
|
|
|
+ const inject = [
|
|
|
+ 'loader',
|
|
|
+ ...manifest.skills.length > 0 ? ['skills'] : [],
|
|
|
+ ...manifest.mcpServers === undefined ? [] : ['tools'],
|
|
|
+ ]
|
|
|
return [
|
|
|
'// Generated by dsh-plugin-prepare. Do not edit.',
|
|
|
`const manifest = ${JSON.stringify(manifest)}`,
|
|
|
`export const name = ${JSON.stringify(manifest.name)}`,
|
|
|
- "export const inject = ['loader']",
|
|
|
+ `export const inject = ${JSON.stringify(inject)}`,
|
|
|
'export async function apply(ctx) {',
|
|
|
` const runtime = ctx.loader.builtins[${JSON.stringify(REPOSITORY_PLUGIN_BUILTIN)}]`,
|
|
|
` if (runtime === undefined) throw new Error(${JSON.stringify(`missing Cordis builtin ${REPOSITORY_PLUGIN_BUILTIN}`)})`,
|
|
|
@@ -111,6 +132,10 @@ function wrapperSource(manifest: PreparedPluginManifest): string {
|
|
|
|
|
|
/**
|
|
|
* Validate and package one `.dsh-plugin` directory into static assets plus a fixed wrapper.
|
|
|
+ * Outputs are staged and committed by rename, but the final publish (remove
|
|
|
+ * old outputs, rename assets, rename entry) is not one atomic step: a crash
|
|
|
+ * mid-publish can leave assets without an entry or neither. Rerunning prepare
|
|
|
+ * repairs the package; partial outputs are never importable as a plugin.
|
|
|
* @param directory - `.dsh-plugin` package directory; defaults to the prepare process cwd.
|
|
|
* @returns the generated static manifest.
|
|
|
*/
|