From fd5bb17b98c465e3150255061acb0328c918933d Mon Sep 17 00:00:00 2001 From: a Date: Fri, 28 Aug 2026 17:29:21 +0000 Subject: [PATCH 1/2] feat(mtmharness): add browser secondary extensions --- .github/workflows/ci.yml | 1 - .github/workflows/release-mtmcanvas.yml | 18 +- .github/workflows/release-mtmharness.yml | 19 +- packages/mtmcanvas/README.md | 29 +- packages/mtmcanvas/cordis.patch.yml | 4 - packages/mtmcanvas/package.json | 62 +--- packages/mtmcanvas/scripts/build.mjs | 48 +-- packages/mtmcanvas/scripts/pack-check.mjs | 18 -- packages/mtmcanvas/scripts/verify-package.mjs | 55 ---- packages/mtmcanvas/src/client/CanvasView.tsx | 4 +- .../mtmcanvas/src/client/MtmCanvasAction.tsx | 45 --- packages/mtmcanvas/src/client/index.ts | 79 +++-- packages/mtmcanvas/src/client/runtime.test.ts | 76 +---- packages/mtmcanvas/src/client/runtime.ts | 142 ++------- packages/mtmcanvas/src/contract/rpc.ts | 37 --- packages/mtmcanvas/src/index.test.ts | 90 ------ packages/mtmcanvas/src/index.ts | 134 +------- .../mtmcanvas/tests/package-contract.test.ts | 28 +- packages/mtmharness/README.md | 13 +- packages/mtmharness/package.json | 2 +- .../mtmharness/scripts/verify-package.mjs | 6 + packages/mtmharness/src/client/index.test.ts | 6 +- packages/mtmharness/src/client/index.ts | 2 + .../features/coding/client/MtmCodingCard.tsx | 1 + .../src/features/coding/client/controller.ts | 1 + .../src/features/coding/client/locales.ts | 6 + .../mtmharness/src/features/coding/index.ts | 9 +- .../mtmharness/src/features/coding/types.ts | 2 + .../src/features/secondary/client.test.ts | 177 +++++++++++ .../src/features/secondary/client.ts | 291 ++++++++++++++++++ .../src/features/secondary/manifest.ts | 54 ++++ packages/mtmharness/src/index.ts | 5 + pnpm-lock.yaml | 45 +-- 33 files changed, 763 insertions(+), 746 deletions(-) delete mode 100644 packages/mtmcanvas/cordis.patch.yml delete mode 100644 packages/mtmcanvas/scripts/pack-check.mjs delete mode 100644 packages/mtmcanvas/scripts/verify-package.mjs delete mode 100644 packages/mtmcanvas/src/client/MtmCanvasAction.tsx delete mode 100644 packages/mtmcanvas/src/contract/rpc.ts delete mode 100644 packages/mtmcanvas/src/index.test.ts create mode 100644 packages/mtmharness/src/features/secondary/client.test.ts create mode 100644 packages/mtmharness/src/features/secondary/client.ts create mode 100644 packages/mtmharness/src/features/secondary/manifest.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6096ef2..597654b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -65,7 +65,6 @@ jobs: - name: Lint mtmharness package metadata run: pnpm dlx publint run "${{ steps.pack.outputs.tarball }}" --strict - - name: Verify mtmharness profile migration if: ${{ matrix.node == '22.19.0' }} run: pnpm --filter mtmharness run profile:check -- "${{ steps.pack.outputs.tarball }}" diff --git a/.github/workflows/release-mtmcanvas.yml b/.github/workflows/release-mtmcanvas.yml index 93c9148..8bfba6a 100644 --- a/.github/workflows/release-mtmcanvas.yml +++ b/.github/workflows/release-mtmcanvas.yml @@ -16,7 +16,6 @@ concurrency: env: PACKAGE_NAME: mtmcanvas - PACKAGE_DIR: packages/mtmcanvas TAG_PREFIX: mtmcanvas-v jobs: @@ -63,7 +62,7 @@ jobs: git merge-base --is-ancestor "$GITHUB_SHA" origin/main printf 'PACKAGE_VERSION=%s\n' "$package_version" >> "$GITHUB_ENV" - - name: Check mtmcanvas + - name: Check mtmcanvas browser extension run: pnpm --filter mtmcanvas run check - name: Pack release tarball @@ -79,8 +78,19 @@ jobs: printf 'tarball=%s\n' "$tarball" >> "$GITHUB_OUTPUT" printf 'LOCAL_INTEGRITY=%s\n' "$local_integrity" >> "$GITHUB_ENV" - - name: Verify tarball contract - run: node "$PACKAGE_DIR/scripts/verify-package.mjs" "${{ steps.pack.outputs.tarball }}" + - name: Verify browser extension tarball + run: | + set -euo pipefail + entries="$(tar -tzf "${{ steps.pack.outputs.tarball }}")" + ! grep -Eq '^package/(cordis\.patch\.yml|lib/(index|client)\.cjs)$' <<<"$entries" + grep -qx 'package/lib/client.js' <<<"$entries" + unpack="$RUNNER_TEMP/mtmcanvas-unpack" + mkdir -p "$unpack" + tar -xzf "${{ steps.pack.outputs.tarball }}" -C "$unpack" + for declaration in package/lib/types/index.d.ts package/lib/types/client/index.d.ts; do test -f "$unpack/$declaration"; done + export UNPACK_ROOT="$unpack" + node --input-type=module -e 'import fs from "node:fs"; import path from "node:path"; const root = process.env.UNPACK_ROOT; const pkg = JSON.parse(fs.readFileSync(path.join(root, "package/package.json"), "utf8")); if (pkg.dsh !== undefined || JSON.stringify(pkg.mtmharness?.secondary) !== JSON.stringify({ id: "mtmcanvas", apiVersion: 1, client: "./lib/client.js" }) || pkg.exports?.["."]?.import !== "./lib/client.js" || pkg.exports?.["./client"]?.import !== "./lib/client.js") process.exit(1);' + node --input-type=module -e 'import fs from "node:fs"; import path from "node:path"; const source = fs.readFileSync(path.join(process.env.UNPACK_ROOT, "package/lib/client.js"), "utf8"); if (!source.includes("export") || /^\s*import\s/m.test(source)) process.exit(1);' - name: Lint package metadata run: pnpm dlx publint run "${{ steps.pack.outputs.tarball }}" --strict diff --git a/.github/workflows/release-mtmharness.yml b/.github/workflows/release-mtmharness.yml index 6c991ea..0adcfb7 100644 --- a/.github/workflows/release-mtmharness.yml +++ b/.github/workflows/release-mtmharness.yml @@ -63,8 +63,23 @@ jobs: git merge-base --is-ancestor "$GITHUB_SHA" origin/main printf 'PACKAGE_VERSION=%s\n' "$package_version" >> "$GITHUB_ENV" - - name: Check workspace - run: pnpm --filter mtmharness run check + - name: Check workspace and secondary client contract + run: | + set -euo pipefail + pnpm --filter mtmharness run check + pnpm --filter mtmcanvas run check + # The pinned Canvas artifact must already be published before mtmharness. + canvas_version="$(node -p "require('./packages/mtmcanvas/package.json').version")" + expected_integrity="$(sed -n 's/.*clientIntegrity: \"\([^\"]*\)\".*/\1/p' packages/mtmharness/src/features/secondary/manifest.ts)" + local_integrity="sha256-$(openssl dgst -sha256 -binary packages/mtmcanvas/lib/client.js | base64 -w0)" + test -n "$expected_integrity" + test "$local_integrity" = "$expected_integrity" + test "$(npm view "mtmcanvas@${canvas_version}" version --json | jq -r .)" = "$canvas_version" + remote="$RUNNER_TEMP/mtmcanvas-client.js" + curl --fail --silent --show-error --output "$remote" "https://unpkg.com/mtmcanvas@${canvas_version}/lib/client.js" + remote_integrity="sha256-$(openssl dgst -sha256 -binary "$remote" | base64 -w0)" + test "$remote_integrity" = "$expected_integrity" + cmp "packages/mtmcanvas/lib/client.js" "$remote" - name: Pack release tarball id: pack diff --git a/packages/mtmcanvas/README.md b/packages/mtmcanvas/README.md index 80fa285..b9aca68 100644 --- a/packages/mtmcanvas/README.md +++ b/packages/mtmcanvas/README.md @@ -1,32 +1,23 @@ # mtmcanvas -Experimental independent DSH Web Canvas plugin extracted from the Canvas domain currently hosted by `mtmharness`. +Experimental browser-only frontend extension for `mtmharness`. -The package publishes two DSH faces: +The package is not a standard DSH plugin: it has no `dsh` manifest or profile patch. `mtmharness` owns its runtime loading, version pin, integrity check, and lifecycle. -- Host half: `lib/index.js`, which mounts the existing file-backed Canvas RPC through DSH's abstract `ctx.fs` service. -- Browser half: `lib/client.cjs`, a `window.__ModuleLoader__.load` bundle that registers the Canvas sidebar action. +## Extension contract -This package is the independent Canvas implementation for the dynamic-loading experiment. The published `mtmharness` package no longer statically mounts Canvas; install both packages when the Canvas surface is required. +The package publishes one self-contained native ESM artifact at `lib/client.js`. It exports `mount(context)`, where `context` contains the extension id, version, an owned DOM root, the host `Document`, an `AbortSignal`, a cleanup-registration callback, and `apiVersion: 1`. The function returns an optional cleanup function. The extension does not receive DSH or Node.js internals. -## Install Into DSH Web +The first experiment keeps Canvas data in browser memory. File persistence and host capabilities are intentionally deferred until the frontend ABI is proven. The artifact runs in the host page; SHA-256 integrity identifies the reviewed bytes but does not sandbox the code. -```bash -dsh plugin --profile web add mtmcanvas -dsh --profile web --dump-config +## Use Through mtmharness -# Restart the Web profile after changing Bundle membership. -``` +Install only `mtmharness` into the DSH Web profile. The Dynamic Canvas setting loads the exact Canvas artifact at runtime; it does not modify the profile or create another DSH Loader entry. -The package patch inserts the `mtmcanvas` Loader row. The official Web client module table discovers the `dsh.client` declaration and serves the exported `./client` bundle. - -The separate `standalone/` client in `mtmharness` is not part of this plugin. It is the cloud multi-user client and does not receive the file-backed Host implementation. +The default experiment uses the published URL on unpkg, but the loader accepts any exact HTTPS static-host URL with CORS enabled. The artifact is fetched, checked against its SHA-256 integrity value, imported as native ESM, and mounted into an owned root. ## Development -```bash -pnpm --filter mtmcanvas run check -pnpm --filter mtmcanvas run pack:check -``` + pnpm --filter mtmcanvas run check -The browser artifact uses DSH's React singleton and dynamic module table. It does not create a React root, router, authentication flow, or local filesystem. `ctx.fs` is the Host capability boundary and can be backed by a remote implementation later. +The package metadata under `mtmharness.secondary` is consumed by the owning harness runtime. It is not a `dsh.bundle` or `dsh.client` declaration. diff --git a/packages/mtmcanvas/cordis.patch.yml b/packages/mtmcanvas/cordis.patch.yml deleted file mode 100644 index 69b6206..0000000 --- a/packages/mtmcanvas/cordis.patch.yml +++ /dev/null @@ -1,4 +0,0 @@ -# Installable profile layer for the experimental mtmcanvas Host/Client plugin. -- insert: - - id: mtmcanvas - name: mtmcanvas diff --git a/packages/mtmcanvas/package.json b/packages/mtmcanvas/package.json index 8e45825..9689f5a 100644 --- a/packages/mtmcanvas/package.json +++ b/packages/mtmcanvas/package.json @@ -1,48 +1,37 @@ { "name": "mtmcanvas", - "version": "0.1.0", - "description": "Experimental Canvas DSH Web plugin extracted from mtmharness.", + "version": "0.2.0", + "description": "Experimental browser-only frontend extension for mtmharness.", "type": "module", "engines": { "node": ">=22.19.0", "pnpm": ">=11.7.0" }, - "main": "./lib/index.js", + "main": "./lib/client.js", "types": "./lib/types/index.d.ts", "exports": { ".": { "types": "./lib/types/index.d.ts", - "default": "./lib/index.js" + "import": "./lib/client.js", + "default": "./lib/client.js" }, "./client": { "types": "./lib/types/client/index.d.ts", - "default": "./lib/client.cjs" + "import": "./lib/client.js", + "default": "./lib/client.js" }, - "./cordis.patch.yml": "./cordis.patch.yml", "./package.json": "./package.json" }, - "dsh": { - "bundle": { - "patch": "./cordis.patch.yml" - }, - "client": { - "platform": "web", - "inject": [ - "@deepseek-ai/dsh-client-connection", - "@deepseek-ai/dsh-client-runtime", - "@deepseek-ai/dsh-client-ui-primitives", - "@deepseek-ai/dsh-client-ui-sidebar", - "@deepseek-ai/dsh-client-ui-layout", - "@deepseek-ai/dsh-client-ui-slots" - ] + "mtmharness": { + "secondary": { + "id": "mtmcanvas", + "apiVersion": 1, + "client": "./lib/client.js" } }, "files": [ - "lib/index.js", "lib/client.js", - "lib/client.cjs", "lib/types/**/*.d.ts", - "cordis.patch.yml", "README.md", "package.json", "LICENSE" @@ -51,10 +40,8 @@ "build": "node scripts/build.mjs", "typecheck": "tsc --noEmit", "test": "vitest run", - "verify:package": "node scripts/verify-package.mjs", - "check": "pnpm run typecheck && pnpm run test && pnpm run build && pnpm run verify:package", - "pack:check": "node scripts/pack-check.mjs", - "prepack": "pnpm run build && pnpm run verify:package" + "check": "pnpm run typecheck && pnpm run test && pnpm run build", + "prepack": "pnpm run build" }, "license": "MIT", "publishConfig": { @@ -65,28 +52,7 @@ "url": "git+https://github.com/codeh007/mtmdsh.git", "directory": "packages/mtmcanvas" }, - "peerDependencies": { - "@deepseek-ai/cordis": "^4.0.1", - "@deepseek-ai/dsh-client-connection": "^0.1.1-rc.2", - "@deepseek-ai/dsh-client-runtime": "^0.1.1-rc.2", - "@deepseek-ai/dsh-client-ui-primitives": "^0.1.1-rc.2", - "@deepseek-ai/dsh-client-ui-sidebar": "^0.1.1-rc.2", - "@deepseek-ai/dsh-client-ui-layout": "^0.1.1-rc.2", - "@deepseek-ai/dsh-client-ui-slots": "^0.1.1-rc.2", - "@deepseek-ai/dsh-fs": "^0.1.1-rc.2", - "@deepseek-ai/dsh-host-directory-picker": "^0.1.1-rc.2", - "react": ">=18.2.0 <20" - }, "devDependencies": { - "@deepseek-ai/cordis": "4.0.1", - "@deepseek-ai/dsh-client-connection": "0.1.1-rc.2", - "@deepseek-ai/dsh-client-runtime": "0.1.1-rc.2", - "@deepseek-ai/dsh-client-ui-primitives": "0.1.1-rc.2", - "@deepseek-ai/dsh-client-ui-sidebar": "0.1.1-rc.2", - "@deepseek-ai/dsh-client-ui-layout": "0.1.1-rc.2", - "@deepseek-ai/dsh-client-ui-slots": "0.1.1-rc.2", - "@deepseek-ai/dsh-fs": "0.1.1-rc.2", - "@deepseek-ai/dsh-host-directory-picker": "0.1.1-rc.2", "@types/node": "22.20.1", "@types/react": "~18.3.1", "@types/react-dom": "~18.3.0", diff --git a/packages/mtmcanvas/scripts/build.mjs b/packages/mtmcanvas/scripts/build.mjs index a9fdddd..945892e 100644 --- a/packages/mtmcanvas/scripts/build.mjs +++ b/packages/mtmcanvas/scripts/build.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node import { execFileSync } from "node:child_process"; -import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, rmSync } from "node:fs"; import { resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { build } from "esbuild"; @@ -9,56 +9,24 @@ import { build } from "esbuild"; const packageRoot = fileURLToPath(new URL("..", import.meta.url)); const libRoot = resolve(packageRoot, "lib"); const tsc = resolve(packageRoot, "node_modules/.bin/tsc"); -const clientTemp = resolve(libRoot, "client.bundle.cjs"); -const packageName = "mtmcanvas"; rmSync(libRoot, { recursive: true, force: true }); mkdirSync(libRoot, { recursive: true }); if (!existsSync(tsc)) throw new Error("mtmcanvas build: local TypeScript executable is missing"); execFileSync(tsc, ["--project", resolve(packageRoot, "tsconfig.json")], { cwd: packageRoot, stdio: "inherit" }); - -await build({ - entryPoints: [resolve(packageRoot, "src/index.ts")], - outfile: resolve(libRoot, "index.js"), - bundle: true, - format: "esm", - platform: "node", - packages: "external", - target: "es2022", - logLevel: "info", -}); +for (const declaration of ["lib/types/index.d.ts", "lib/types/client/index.d.ts"]) { + if (!existsSync(resolve(packageRoot, declaration))) throw new Error("mtmcanvas build: missing " + declaration); +} await build({ entryPoints: [resolve(packageRoot, "src/client/index.ts")], - outfile: clientTemp, + outfile: resolve(libRoot, "client.js"), bundle: true, - format: "cjs", + format: "esm", platform: "browser", - target: "es2020", - external: ["react", "react/*", "@deepseek-ai/*"], + target: "es2022", legalComments: "none", logLevel: "info", }); -const clientSource = readFileSync(clientTemp, "utf8"); -const indented = clientSource.split("\n").map((line) => " " + line).join("\n"); -const artifact = [ - "window.__ModuleLoader__.load({", - " id: " + JSON.stringify(packageName) + ",", - " factory: (require) => {", - " var module = { exports: {} };", - " var exports = module.exports;", - indented, - " return module.exports;", - " }", - "});", - "", -].join("\n"); -if (!artifact.includes("window.__ModuleLoader__.load") || !artifact.includes("id: \"" + packageName + "\"")) { - throw new Error("mtmcanvas build: generated client artifact does not have the DSH loader contract"); -} -writeFileSync(resolve(libRoot, "client.js"), artifact); -writeFileSync(resolve(libRoot, "client.cjs"), artifact); -rmSync(clientTemp, { force: true }); - -console.log("built mtmcanvas Host and Client artifacts"); +console.log("built mtmcanvas browser ESM artifact"); diff --git a/packages/mtmcanvas/scripts/pack-check.mjs b/packages/mtmcanvas/scripts/pack-check.mjs deleted file mode 100644 index 2142cda..0000000 --- a/packages/mtmcanvas/scripts/pack-check.mjs +++ /dev/null @@ -1,18 +0,0 @@ -#!/usr/bin/env node - -import { execFileSync } from "node:child_process"; -import { mkdtempSync, readdirSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { fileURLToPath } from "node:url"; - -const packageRoot = fileURLToPath(new URL("..", import.meta.url)); -const destination = mkdtempSync(join(tmpdir(), "mtmcanvas-pack-")); -try { - execFileSync("pnpm", ["pack", "--pack-destination", destination], { cwd: packageRoot, stdio: "inherit" }); - const tarball = readdirSync(destination).find((name) => name.endsWith(".tgz")); - if (tarball === undefined) throw new Error("mtmcanvas pack: pnpm did not create a tarball"); - execFileSync(process.execPath, [join(packageRoot, "scripts/verify-package.mjs"), join(destination, tarball)], { cwd: packageRoot, stdio: "inherit" }); -} finally { - rmSync(destination, { recursive: true, force: true }); -} diff --git a/packages/mtmcanvas/scripts/verify-package.mjs b/packages/mtmcanvas/scripts/verify-package.mjs deleted file mode 100644 index e5b9a0e..0000000 --- a/packages/mtmcanvas/scripts/verify-package.mjs +++ /dev/null @@ -1,55 +0,0 @@ -#!/usr/bin/env node - -import { execFileSync } from "node:child_process"; -import { existsSync, readFileSync } from "node:fs"; -import { resolve } from "node:path"; -import { fileURLToPath } from "node:url"; - -const packageRoot = fileURLToPath(new URL("..", import.meta.url)); -const manifest = JSON.parse(readFileSync(resolve(packageRoot, "package.json"), "utf8")); -const fail = (message) => { throw new Error("mtmcanvas package: " + message); }; - -if (manifest.private === true) fail("package must be publishable"); -if (manifest.name !== "mtmcanvas") fail("unexpected package name"); -if (!/^\d+\.\d+\.\d+$/.test(manifest.version)) fail("version must be stable SemVer"); -if (manifest.dsh?.bundle?.patch !== "./cordis.patch.yml") fail("dsh.bundle.patch must point to cordis.patch.yml"); -if (manifest.dsh?.client?.platform !== "web") fail("dsh.client.platform must be web"); -if (!Array.isArray(manifest.dsh?.client?.inject)) fail("dsh.client.inject must be an array"); -if (manifest.exports?.["."]?.default !== "./lib/index.js") fail("exports root must point to lib/index.js"); -if (manifest.exports?.["./client"]?.default !== "./lib/client.cjs") fail("exports ./client must point to lib/client.cjs"); -for (const path of ["cordis.patch.yml", "lib/index.js", "lib/client.js", "lib/client.cjs", "lib/types/index.d.ts", "lib/types/client/index.d.ts"]) { - if (!existsSync(resolve(packageRoot, path))) fail("missing build output " + path); -} -const patch = readFileSync(resolve(packageRoot, "cordis.patch.yml"), "utf8"); -if (!patch.includes("id: mtmcanvas") || !patch.includes("name: mtmcanvas")) fail("profile patch must insert the mtmcanvas Loader row"); -const host = readFileSync(resolve(packageRoot, "lib/index.js"), "utf8"); -for (const required of ["mtmcanvas", "mtm-canvas: file-backed RPC", "ctx.fs"]) { - if (!host.includes(required)) fail("Host artifact is missing " + required); -} -const client = readFileSync(resolve(packageRoot, "lib/client.js"), "utf8"); -for (const required of ["window.__ModuleLoader__.load", 'id: "mtmcanvas"', "sidebar.footer.action"]) { - if (!client.includes(required)) fail("Client artifact is missing " + required); -} - -const tarball = process.argv[2]; -if (tarball !== undefined) { - const entries = execFileSync("tar", ["-tzf", resolve(tarball)], { encoding: "utf8" }) - .split("\n") - .filter((entry) => entry.startsWith("package/") && !entry.endsWith("/")); - for (const entry of [ - "package/LICENSE", - "package/README.md", - "package/cordis.patch.yml", - "package/lib/index.js", - "package/lib/client.js", - "package/lib/client.cjs", - "package/lib/types/index.d.ts", - "package/lib/types/client/index.d.ts", - "package/package.json", - ]) { - if (!entries.includes(entry)) fail("tarball is missing " + entry); - } - if (entries.some((entry) => entry.includes(".test."))) fail("tarball must not contain test declarations"); -} - -console.log("verified mtmcanvas@" + manifest.version + (tarball === undefined ? "" : " tarball")); diff --git a/packages/mtmcanvas/src/client/CanvasView.tsx b/packages/mtmcanvas/src/client/CanvasView.tsx index 53da793..b1872b0 100644 --- a/packages/mtmcanvas/src/client/CanvasView.tsx +++ b/packages/mtmcanvas/src/client/CanvasView.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState, type DragEvent, type ReactElement } from "react"; +import { useEffect, useRef, useState, type DragEvent, type FormEvent, type ReactElement } from "react"; import type { CanvasNode } from "../contract/canvas.ts"; import type { CanvasActions, CanvasViewState } from "./runtime.ts"; @@ -31,7 +31,7 @@ export function CanvasView({ state, actions }: { state: CanvasViewState; actions if (selected?.kind === "prompt") setDraft(selected.prompt); }, [selected]); - function create(event: React.FormEvent): void { + function create(event: FormEvent): void { event.preventDefault(); const name = filename.endsWith(".canvas") ? filename : filename + ".canvas"; actions.create(name); diff --git a/packages/mtmcanvas/src/client/MtmCanvasAction.tsx b/packages/mtmcanvas/src/client/MtmCanvasAction.tsx deleted file mode 100644 index 2e35bee..0000000 --- a/packages/mtmcanvas/src/client/MtmCanvasAction.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import type { PropsRuntime, InjectFace } from "@deepseek-ai/dsh-client-ui-slots"; -import { Button, Modal } from "@deepseek-ai/dsh-client-ui-primitives"; -import { CanvasView } from "./CanvasView.tsx"; -import type { CanvasRuntime, CanvasViewState } from "./runtime.ts"; -import { useState, type ReactElement } from "react"; - -type MtmCanvasActionInjected = { - readonly actions: CanvasRuntime; - readonly hooks: { readonly canvas: CanvasRuntime }; -}; - -export type MtmCanvasActionProps = PropsRuntime<"sidebar.footer.action"> & InjectFace; - -/** Open the file-backed Canvas editor from the DSH Web sidebar. */ -export function MtmCanvasAction({ wide, actions, useCanvas }: MtmCanvasActionProps): ReactElement { - const [open, setOpen] = useState(false); - const state = useCanvas((snapshot): CanvasViewState => snapshot); - const label = "Open Canvas"; - return ( - <> - - { setOpen(false); }} - title="Canvas" - closeLabel="Close Canvas" - className="mtm-modal" - contentClassName="mtm-modal-content" - > - - - - ); -} diff --git a/packages/mtmcanvas/src/client/index.ts b/packages/mtmcanvas/src/client/index.ts index d8e466e..a358bdb 100644 --- a/packages/mtmcanvas/src/client/index.ts +++ b/packages/mtmcanvas/src/client/index.ts @@ -1,31 +1,60 @@ -import type { ClientContext } from "@deepseek-ai/dsh-client-runtime/client"; -import type { ConnectionHandle } from "@deepseek-ai/dsh-client-connection/client"; -import type {} from "@deepseek-ai/dsh-client-ui-sidebar/client"; -import type {} from "@deepseek-ai/dsh-client-ui-layout/client"; -import { MtmCanvasAction } from "./MtmCanvasAction.tsx"; +import { createElement, useSyncExternalStore } from "react"; +import { createRoot } from "react-dom/client"; +import { CanvasView } from "./CanvasView.tsx"; import { CanvasRuntime } from "./runtime.ts"; import { MTM_CANVAS_CSS } from "./styles.ts"; -/** Mount the browser Canvas runtime and its plugin-owned styles. */ -export const inject = ["slots", "connection"]; +export interface MtmharnessFrontendExtensionContext { + readonly apiVersion: 1; + readonly id: string; + readonly version: string; + readonly root: HTMLElement; + readonly document: Document; + readonly signal: AbortSignal; + readonly registerCleanup: (cleanup: () => void | Promise) => void; +} + +export type MtmharnessFrontendExtensionCleanup = void | (() => void | Promise); + +function CanvasExtension({ runtime }: { runtime: CanvasRuntime }) { + const state = useSyncExternalStore(runtime.subscribe, runtime.getSnapshot, runtime.getSnapshot); + return createElement(CanvasView, { state, actions: runtime }); +} -export function apply(ctx: ClientContext): void { - const connection = ctx.get("connection") as ConnectionHandle | undefined; - if (connection === undefined) throw new Error("mtm-canvas: DSH connection service is unavailable"); - const runtime = new CanvasRuntime(connection.rpc); - ctx.effect(() => () => { runtime.dispose(); }, "mtm-canvas: client runtime"); - ctx.effect(() => { - if (typeof document === "undefined") return () => {}; - const style = document.createElement("style"); - style.dataset.plugin = "mtm-canvas"; +/** Mount the browser-only Canvas experiment through the mtmharness ABI. */ +export function mount(context: MtmharnessFrontendExtensionContext): () => void { + const runtime = new CanvasRuntime(); + let reactRoot: ReturnType | undefined; + let style: HTMLStyleElement | undefined; + let disposed = false; + const dispose = (): void => { + if (disposed) return; + disposed = true; + context.signal.removeEventListener("abort", dispose); + runtime.dispose(); + reactRoot?.unmount(); + style?.remove(); + }; + context.registerCleanup(dispose); + try { + context.root.style.position = "fixed"; + context.root.style.inset = "16px"; + context.root.style.zIndex = "1000"; + context.root.style.overflow = "hidden"; + context.root.style.border = "1px solid #cbd5e1"; + context.root.style.borderRadius = "8px"; + context.root.style.background = "#f8fafc"; + context.root.style.boxShadow = "0 16px 40px #17203333"; + style = context.document.createElement("style"); + style.dataset.mtmSecondaryExtension = context.id; style.textContent = MTM_CANVAS_CSS; - document.head.append(style); - return () => { style.remove(); }; - }, "mtm-canvas: styles"); - ctx.slots.inject("sidebar.footer.action", () => ctx.slots.register({ - name: "sidebar.footer.action", - id: "mtmcanvas", - order: 11, - inject: () => ({ actions: runtime, hooks: { canvas: runtime } }), - }, MtmCanvasAction)); + context.document.head.append(style); + reactRoot = createRoot(context.root); + context.signal.addEventListener("abort", dispose, { once: true }); + reactRoot.render(createElement(CanvasExtension, { runtime })); + return dispose; + } catch (error) { + dispose(); + throw error; + } } diff --git a/packages/mtmcanvas/src/client/runtime.test.ts b/packages/mtmcanvas/src/client/runtime.test.ts index 2cf5f11..2165deb 100644 --- a/packages/mtmcanvas/src/client/runtime.test.ts +++ b/packages/mtmcanvas/src/client/runtime.test.ts @@ -1,72 +1,26 @@ import { describe, expect, it } from "vitest"; -import { createCanvasDocument } from "../contract/canvas.ts"; import { CanvasRuntime } from "./runtime.ts"; -type Deferred = { promise: Promise; resolve(value: T): void; reject(error: unknown): void }; - -function deferred(): Deferred { - let resolve!: (value: T) => void; - let reject!: (error: unknown) => void; - const promise = new Promise((resolvePromise, rejectPromise) => { - resolve = resolvePromise; - reject = rejectPromise; - }); - return { promise, resolve, reject }; -} - -function canvasResponse(name: string, version: string) { - return { ok: true, value: { name, version, document: createCanvasDocument(name.replace(/\.canvas$/u, "")) } }; -} - -async function settle(): Promise { - for (let index = 0; index < 8; index += 1) await Promise.resolve(); -} - -function harness() { - const pending: Array<{ payload: unknown; deferred: Deferred }> = []; - const rpc = { - call: async (_channel: string, _endpoint: string, payload: unknown): Promise => { - const item = { payload, deferred: deferred() }; - pending.push(item); - return item.deferred.promise; - }, - }; - return { pending, rpc }; -} - describe("CanvasRuntime", () => { - it("keeps the latest opened file when responses arrive out of order", async () => { - const { pending, rpc } = harness(); - const runtime = new CanvasRuntime(rpc as never); - pending[0]?.deferred.resolve({ ok: true, value: [] }); - await settle(); - - runtime.open("a.canvas"); - runtime.open("b.canvas"); - pending[2]?.deferred.resolve(canvasResponse("b.canvas", "v2")); - pending[1]?.deferred.resolve(canvasResponse("a.canvas", "v1")); - await settle(); - - expect(runtime.getSnapshot().name).toBe("b.canvas"); - runtime.dispose(); + it("starts with an in-memory demo canvas", () => { + const runtime = new CanvasRuntime(); + expect(runtime.getSnapshot()).toMatchObject({ name: "demo.canvas", version: "0", loading: false }); + expect(runtime.getSnapshot().document?.canvasId).toBe("demo"); }); - it("preserves local edits and exposes a conflict after a stale save", async () => { - const { pending, rpc } = harness(); - const runtime = new CanvasRuntime(rpc as never); - pending[0]?.deferred.resolve({ ok: true, value: [] }); - await settle(); - - runtime.open("demo.canvas"); - pending[1]?.deferred.resolve(canvasResponse("demo.canvas", "v1")); - await settle(); + it("updates the selected document and advances its local version", () => { + const runtime = new CanvasRuntime(); runtime.updatePrompt("prompt-1", "local edit"); + expect(runtime.getSnapshot().document?.nodes[0]?.prompt).toBe("local edit"); runtime.save(); - pending[2]?.deferred.resolve({ ok: false, error: { code: "FS_STALE_VERSION", message: "file changed since it was read" } }); - await settle(); + expect(runtime.getSnapshot().version).toBe("1"); + expect(runtime.getSnapshot().files[0]?.version).toBe("1"); + }); - expect(runtime.getSnapshot()).toMatchObject({ name: "demo.canvas", conflict: true, error: "file changed since it was read" }); - expect(runtime.getSnapshot().document?.nodes[0]?.prompt).toBe("local edit"); - runtime.dispose(); + it("rejects unsafe filenames without changing the selected canvas", () => { + const runtime = new CanvasRuntime(); + runtime.create("../escape"); + expect(runtime.getSnapshot().error).toBe("Canvas filename is invalid"); + expect(runtime.getSnapshot().name).toBe("demo.canvas"); }); }); diff --git a/packages/mtmcanvas/src/client/runtime.ts b/packages/mtmcanvas/src/client/runtime.ts index d1d6034..5918c6d 100644 --- a/packages/mtmcanvas/src/client/runtime.ts +++ b/packages/mtmcanvas/src/client/runtime.ts @@ -1,7 +1,4 @@ -import type { ObservableSnapshot } from "@deepseek-ai/dsh-client-runtime/client"; -import type { ClientConnectionRpc } from "@deepseek-ai/dsh-client-connection/client"; -import { MTM_CANVAS_CHANNEL, type CanvasFileWire, type CanvasReadWire } from "../contract/rpc.ts"; -import { createCanvasDocument, createNodeId, validateCanvasDocument, type CanvasDocument, type CanvasPosition } from "../contract/canvas.ts"; +import { createCanvasDocument, createNodeId, type CanvasDocument, type CanvasPosition } from "../contract/canvas.ts"; export interface CanvasFile { name: string; @@ -29,47 +26,23 @@ export interface CanvasActions { save(): void; } -function isRecord(value: unknown): value is Record { - return value !== null && typeof value === "object" && !Array.isArray(value); -} - -function parseFiles(value: unknown): CanvasFile[] { - if (!Array.isArray(value)) throw new Error("Canvas file listing is invalid"); - return value.map((item) => { - if (!isRecord(item) || typeof item.name !== "string" || typeof item.version !== "string") throw new Error("Canvas file listing is invalid"); - return { name: item.name, version: item.version } satisfies CanvasFileWire; - }); -} - -function parseRead(value: unknown): CanvasReadWire { - if (!isRecord(value) || typeof value.name !== "string" || typeof value.version !== "string") throw new Error("Canvas read response is invalid"); - return { name: value.name, version: value.version, document: validateCanvasDocument(value.document) }; -} - -function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} - -function errorCode(error: unknown): string | undefined { - const value = error as { code?: unknown }; - return typeof value.code === "string" ? value.code : undefined; -} +const CANVAS_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,120}\.canvas$/u; -function isStaleError(error: unknown): boolean { - return errorCode(error) === "FS_STALE_VERSION" || errorMessage(error).includes("FS_STALE_VERSION"); -} - -export class CanvasRuntime implements ObservableSnapshot, CanvasActions { - private view: CanvasViewState = { files: [], loading: true }; +/** In-memory Canvas state for the first browser-only extension experiment. */ +export class CanvasRuntime implements CanvasActions { + private view: CanvasViewState; private readonly listeners = new Set<() => void>(); - private readonly abortController = new AbortController(); private disposed = false; - private listSequence = 0; - private selectionSequence = 0; - private saveSequence = 0; - constructor(private readonly rpc: ClientConnectionRpc) { - this.refresh(); + constructor() { + const name = "demo.canvas"; + this.view = { + files: [{ name, version: "0" }], + name, + version: "0", + document: createCanvasDocument("demo"), + loading: false, + }; } getSnapshot = (): CanvasViewState => this.view; @@ -80,55 +53,32 @@ export class CanvasRuntime implements ObservableSnapshot, Canva }; dispose(): void { - if (this.disposed) return; this.disposed = true; - this.abortController.abort(); this.listeners.clear(); } refresh(): void { - const sequence = ++this.listSequence; - const selectionSequence = this.selectionSequence; - this.set({ loading: true, error: undefined }); - void this.call({ kind: "list" }) - .then((value) => { - if (sequence !== this.listSequence) return; - const files = parseFiles(value); - this.set({ files, loading: false }); - if (this.view.name === undefined && selectionSequence === this.selectionSequence && files[0] !== undefined) this.open(files[0].name); - }) - .catch((error) => { - if (sequence === this.listSequence) this.set({ loading: false, error: errorMessage(error) }); - }); + if (!this.disposed) this.set({ loading: false, error: undefined }); } open(name: string): void { - const sequence = ++this.selectionSequence; - this.set({ error: undefined, conflict: undefined }); - void this.call({ kind: "read", name }) - .then((value) => { - if (sequence !== this.selectionSequence) return; - const read = parseRead(value); - this.set({ name: read.name, version: read.version, document: read.document, error: undefined, conflict: undefined }); - }) - .catch((error) => { - if (sequence === this.selectionSequence) this.set({ error: errorMessage(error) }); - }); + const file = this.view.files.find((item) => item.name === name); + if (file === undefined) { + this.set({ error: "Canvas file was not found" }); + return; + } + this.set({ name, version: file.version, document: createCanvasDocument(name.slice(0, -".canvas".length)), error: undefined }); } create(name: string): void { - const sequence = ++this.selectionSequence; - const document = createCanvasDocument(name.replace(/\.canvas$/u, "")); - this.set({ error: undefined, conflict: undefined }); - void this.call({ kind: "create", name, document }) - .then((value) => { - const read = parseRead(value); - if (sequence === this.selectionSequence) this.set({ name: read.name, version: read.version, document: read.document, error: undefined, conflict: undefined }); - this.refresh(); - }) - .catch((error) => { - if (sequence === this.selectionSequence) this.set({ error: errorMessage(error) }); - }); + const fileName = name.endsWith(".canvas") ? name : name + ".canvas"; + if (!CANVAS_NAME.test(fileName)) { + this.set({ error: "Canvas filename is invalid" }); + return; + } + const file = { name: fileName, version: "0" }; + const files = [...this.view.files.filter((item) => item.name !== fileName), file]; + this.set({ files, name: fileName, version: file.version, document: createCanvasDocument(fileName.slice(0, -".canvas".length)), error: undefined }); } addPrompt(): void { @@ -158,23 +108,10 @@ export class CanvasRuntime implements ObservableSnapshot, Canva } save(): void { - const { document, name, version } = this.view; - if (document === undefined || name === undefined || version === undefined) return; - const sequence = ++this.saveSequence; - const selectionSequence = this.selectionSequence; - const savedDocument = structuredClone(document); - void this.call({ kind: "write", name, version, document: savedDocument }) - .then((value) => { - if (sequence !== this.saveSequence || selectionSequence !== this.selectionSequence || this.view.name !== name) return; - const read = parseRead(value); - const localChanged = this.view.document?.revision !== savedDocument.revision; - this.set({ version: read.version, ...(localChanged ? {} : { document: read.document }), error: undefined, conflict: undefined }); - this.refresh(); - }) - .catch((error) => { - if (sequence !== this.saveSequence || selectionSequence !== this.selectionSequence || this.view.name !== name) return; - this.set({ error: errorMessage(error), conflict: isStaleError(error) }); - }); + if (this.view.name === undefined || this.view.document === undefined) return; + const version = String(Number.parseInt(this.view.version ?? "0", 10) + 1); + const files = this.view.files.map((file) => file.name === this.view.name ? { ...file, version } : file); + this.set({ files, version, error: undefined }); } private patch(id: string, patch: Partial>): void { @@ -188,20 +125,9 @@ export class CanvasRuntime implements ObservableSnapshot, Canva this.set({ document }); } - private async call(args: unknown): Promise { - if (this.disposed) throw new Error("Canvas runtime is disposed"); - const result = await this.rpc.call(MTM_CANVAS_CHANNEL, "request", { args }, this.abortController.signal); - if (!result.ok) { - const error = new Error(result.error.message) as Error & { code?: string }; - if (typeof result.error.code === "string") error.code = result.error.code; - throw error; - } - return result.value; - } - private set(patch: Partial): void { if (this.disposed) return; this.view = { ...this.view, ...patch }; - for (const listener of this.listeners) listener(); + for (const listener of [...this.listeners]) listener(); } } diff --git a/packages/mtmcanvas/src/contract/rpc.ts b/packages/mtmcanvas/src/contract/rpc.ts deleted file mode 100644 index 8cfdc86..0000000 --- a/packages/mtmcanvas/src/contract/rpc.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { CanvasDocument } from "./canvas.ts"; - -export const MTM_CANVAS_CHANNEL = "/mtm-canvas"; -export type CanvasRpcRequest = - | { kind: "list" } - | { kind: "read"; name: string } - | { kind: "create"; name: string; document: unknown } - | { kind: "write"; name: string; version: string; document: unknown }; -export interface CanvasFileWire { name: string; version: string } -export interface CanvasReadWire { name: string; version: string; document: CanvasDocument } -export interface CanvasWriteWire extends CanvasReadWire {} - -function isRecord(value: unknown): value is Record { - return value !== null && typeof value === "object" && !Array.isArray(value); -} -function exactKeys(value: Record, keys: readonly string[], label: string): void { - const allowed = new Set(keys); - if (Object.keys(value).some((key) => !allowed.has(key))) throw new Error(label + " contains unsupported fields"); -} -function nameValue(value: unknown): string { - if (typeof value !== "string" || value.length === 0 || value.length > 128) throw new Error("canvas name is invalid"); - return value; -} -function versionValue(value: unknown): string { - if (typeof value !== "string" || value.length === 0 || value.length > 256) throw new Error("canvas version is invalid"); - return value; -} -export function parseCanvasRpcRequest(value: unknown): CanvasRpcRequest { - if (!isRecord(value) || typeof value.kind !== "string") throw new Error("invalid canvas request"); - switch (value.kind) { - case "list": exactKeys(value, ["kind"], "canvas list request"); return { kind: "list" }; - case "read": exactKeys(value, ["kind", "name"], "canvas read request"); return { kind: "read", name: nameValue(value.name) }; - case "create": exactKeys(value, ["kind", "name", "document"], "canvas create request"); return { kind: "create", name: nameValue(value.name), document: value.document }; - case "write": exactKeys(value, ["kind", "name", "version", "document"], "canvas write request"); return { kind: "write", name: nameValue(value.name), version: versionValue(value.version), document: value.document }; - default: throw new Error("unsupported canvas request"); - } -} diff --git a/packages/mtmcanvas/src/index.test.ts b/packages/mtmcanvas/src/index.test.ts deleted file mode 100644 index 89eeab5..0000000 --- a/packages/mtmcanvas/src/index.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { FsVersion } from "@deepseek-ai/dsh-fs"; -import { describe, expect, it } from "vitest"; -import { createCanvasDocument } from "./contract/canvas.ts"; -import { createCanvasRpcHandler } from "./index.ts"; - -const ROOT = "/workspace/.mtmcanvas"; - -type Stored = { content: string; version: number }; - -function bench(options: { listFails?: boolean; changeDuringRead?: boolean } = {}) { - const files = new Map(); - let rootExists = false; - let revision = 0; - const fs = { - async resolve(displayPath: string) { - const resolved = displayPath === "." ? "/workspace" : displayPath; - return { targetKey: resolved, displayPath: resolved }; - }, - async stat(target: { displayPath: string }) { - if (target.displayPath === ROOT) return rootExists ? { type: "directory", version: FsVersion("root") } : undefined; - const stored = files.get(target.displayPath); - return stored === undefined ? undefined : { type: "file", version: FsVersion("v" + stored.version), size: stored.content.length }; - }, - async listDir(target: { displayPath: string }) { - if (options.listFails) throw new Error("directory listing unavailable"); - if (!rootExists || target.displayPath !== ROOT) throw new Error("directory missing"); - return [...files.entries()].map(([path, stored]) => ({ name: path.slice(ROOT.length + 1), type: "file", target: { targetKey: path, displayPath: path }, version: FsVersion("v" + stored.version), size: stored.content.length })); - }, - async readText(target: { displayPath: string }) { - const stored = files.get(target.displayPath); - if (stored === undefined) throw new Error("file missing"); - if (options.changeDuringRead) stored.version += 1; - return stored.content; - }, - async writeText(target: { displayPath: string }, content: string, expected?: { kind: string; version?: unknown }) { - const current = files.get(target.displayPath); - if (expected?.kind === "createIfAbsent" && current !== undefined) throw Object.assign(new Error("exists"), { code: "FS_NOT_OBSERVED" }); - if (expected?.kind === "replaceIfVersion" && String(expected.version) !== "v" + current?.version) throw Object.assign(new Error("changed"), { code: "FS_STALE_VERSION" }); - revision += 1; - files.set(target.displayPath, { content, version: revision }); - return { operation: current === undefined ? "create" : "update", version: FsVersion("v" + revision), before: current?.content ?? null, after: content }; - }, - }; - const directoryPicker = { capability: () => ({ kind: "browse", list: async () => ({}) , createDirectory: async () => { rootExists = true; return ROOT; } }) }; - const handler = createCanvasRpcHandler({ fs, directoryPicker } as never); - return { files, handler }; -} - -async function request(handler: ReturnType, args: unknown) { - return handler("request", { args }, new AbortController().signal); -} - -describe("Canvas filesystem RPC", () => { - it("initializes the directory, creates, lists, and reads a canvas file", async () => { - const { handler } = bench(); - const created = await request(handler, { kind: "create", name: "demo.canvas", document: createCanvasDocument("demo") }); - expect(created.ok).toBe(true); - await expect(request(handler, { kind: "list" })).resolves.toMatchObject({ ok: true, value: [{ name: "demo.canvas" }] }); - await expect(request(handler, { kind: "read", name: "demo.canvas" })).resolves.toMatchObject({ ok: true, value: { name: "demo.canvas" } }); - }); - - it("returns a structured stale-version RPC failure", async () => { - const { handler } = bench(); - const created = await request(handler, { kind: "create", name: "demo.canvas", document: createCanvasDocument("demo") }); - if (!created.ok) throw new Error("create failed"); - const document = createCanvasDocument("demo"); - await expect(request(handler, { kind: "write", name: "demo.canvas", version: "stale", document })).resolves.toMatchObject({ ok: false, error: { code: "internal", message: expect.stringContaining("FS_STALE_VERSION") } }); - }); - - it("does not require directory listing for single-file operations", async () => { - const { handler } = bench({ listFails: true }); - const created = await request(handler, { kind: "create", name: "demo.canvas", document: createCanvasDocument("demo") }); - expect(created).toMatchObject({ ok: true, value: { name: "demo.canvas" } }); - const version = created.ok ? String((created.value as { version: string }).version) : ""; - await expect(request(handler, { kind: "read", name: "demo.canvas" })).resolves.toMatchObject({ ok: true, value: { name: "demo.canvas" } }); - await expect(request(handler, { kind: "write", name: "demo.canvas", version, document: createCanvasDocument("demo", 2) })).resolves.toMatchObject({ ok: true, value: { name: "demo.canvas" } }); - }); - - it("rejects a file that changes during read", async () => { - const { handler } = bench({ changeDuringRead: true }); - const created = await request(handler, { kind: "create", name: "demo.canvas", document: createCanvasDocument("demo") }); - expect(created).toMatchObject({ ok: true }); - await expect(request(handler, { kind: "read", name: "demo.canvas" })).resolves.toMatchObject({ ok: false, error: { code: "internal", message: expect.stringContaining("FS_STALE_VERSION") } }); - }); - - it("rejects path traversal names", async () => { - const { handler } = bench(); - await expect(request(handler, { kind: "read", name: "../escape.canvas" })).resolves.toMatchObject({ ok: false, error: { code: "internal" } }); - }); -}); diff --git a/packages/mtmcanvas/src/index.ts b/packages/mtmcanvas/src/index.ts index 09a3b52..a7cb7b0 100644 --- a/packages/mtmcanvas/src/index.ts +++ b/packages/mtmcanvas/src/index.ts @@ -1,120 +1,14 @@ -import type { Context } from "@deepseek-ai/cordis"; -import type {} from "@deepseek-ai/dsh-client-connection"; -import { FsError, FsVersion } from "@deepseek-ai/dsh-fs"; -import type {} from "@deepseek-ai/dsh-fs"; -import type {} from "@deepseek-ai/dsh-host-directory-picker"; -import { MTM_CANVAS_CHANNEL, parseCanvasRpcRequest } from "./contract/rpc.ts"; -import { validateCanvasDocument, type CanvasDocument } from "./contract/canvas.ts"; - -const CANVAS_DIRECTORY = ".mtmcanvas"; -const NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,120}\.canvas$/u; - -type RpcResult = - | { ok: true; value: T } - | { ok: false; error: { code: "internal"; message: string; details: Record } }; - -function errorText(error: unknown): string { - const value = error as { code?: unknown }; - const message = error instanceof Error ? error.message : String(error); - return typeof value.code === "string" && value.code !== "internal" ? value.code + ": " + message : message; -} - -function failure(error: unknown): RpcResult { - return { ok: false, error: { code: "internal", message: errorText(error), details: {} } }; -} - -function checkName(name: string): void { - if (!NAME.test(name) || name.includes("..")) throw new Error("canvas name must be a direct .canvas child"); -} - -function canvasIdForName(name: string): string { - return name.slice(0, -".canvas".length); -} - -function assertDocumentName(name: string, document: CanvasDocument): void { - if (document.canvasId !== canvasIdForName(name)) throw new Error("canvas document id does not match its file name"); -} - -function childPath(parent: string, name: string): string { - return parent.endsWith("/") ? parent + name : parent + "/" + name; -} - -async function ensureDirectory(ctx: Context, signal: AbortSignal): Promise>> { - const workspace = await ctx.fs.resolve(".", { signal }); - const rootPath = childPath(workspace.displayPath, CANVAS_DIRECTORY); - const target = await ctx.fs.resolve(rootPath, { signal }); - const current = await ctx.fs.stat(target, signal); - if (current?.type === "directory") return target; - if (current !== undefined) throw new Error("canvas storage path is not a directory"); - - const capability = ctx.directoryPicker.capability(); - if (capability.kind !== "browse") throw new Error("canvas storage directory cannot be created by this Host"); - try { - const createdPath = await capability.createDirectory(workspace.displayPath, CANVAS_DIRECTORY); - if (createdPath !== rootPath) throw new Error("canvas storage directory was created at an unexpected path"); - } catch (error) { - const raced = await ctx.fs.stat(target, signal); - if (raced?.type !== "directory") throw error; - } - return target; -} - -async function readDocument(ctx: Context, directory: Awaited>, name: string, signal: AbortSignal): Promise<{ version: string; document: CanvasDocument }> { - checkName(name); - const target = await ctx.fs.resolve(childPath(directory.displayPath, name), { signal }); - const before = await ctx.fs.stat(target, signal); - if (before === undefined) throw new FsError("canvas document was not found", "FS_NOT_FOUND"); - if (before.type !== "file") throw new FsError("canvas document is not a regular file", "FS_NOT_REGULAR_FILE"); - const raw = JSON.parse(await ctx.fs.readText(target, signal)) as unknown; - const document = validateCanvasDocument(raw); - assertDocumentName(name, document); - const after = await ctx.fs.stat(target, signal); - if (after?.type !== "file") throw new FsError("canvas document disappeared while reading", "FS_NOT_FOUND"); - if (String(before.version) !== String(after.version)) throw new FsError("canvas document changed while reading", "FS_STALE_VERSION"); - return { version: String(after.version), document }; -} - -export const name = "mtmcanvas"; -export const inject = ["connection", "fs", "directoryPicker"]; - -export function createCanvasRpcHandler(ctx: Context) { - return async (endpoint: string, payload: unknown, signal: AbortSignal): Promise> => { - if (endpoint !== "request") return failure(new Error("unknown canvas endpoint")); - try { - const request = parseCanvasRpcRequest((payload as { args?: unknown } | null)?.args); - if (request.kind !== "list") checkName(request.name); - const directory = await ensureDirectory(ctx, signal); - if (request.kind === "list") { - const entries = await ctx.fs.listDir(directory, signal); - return { - ok: true, - value: entries - .filter((entry) => entry.type === "file" && NAME.test(entry.name)) - .map((entry) => ({ name: entry.name, version: String(entry.version) })), - }; - } - - const target = await ctx.fs.resolve(childPath(directory.displayPath, request.name), { signal }); - if (request.kind === "read") return { ok: true, value: { name: request.name, ...(await readDocument(ctx, directory, request.name, signal)) } }; - - const document = validateCanvasDocument(request.document); - assertDocumentName(request.name, document); - const content = JSON.stringify(document); - const outcome = - request.kind === "create" - ? await ctx.fs.writeText(target, content, { kind: "createIfAbsent" }, signal) - : await ctx.fs.writeText(target, content, { kind: "replaceIfVersion", version: FsVersion(request.version) }, signal); - return { ok: true, value: { name: request.name, version: String(outcome.version), document } }; - } catch (error) { - return failure(error); - } - }; -} - -/** Mount the file-backed Canvas RPC on the existing loopback Connection seam. */ -export function apply(ctx: Context): void { - ctx.effect(() => { - const remove = ctx.connection.rpc.handle(MTM_CANVAS_CHANNEL, createCanvasRpcHandler(ctx), { authority: "loopback" }); - return remove; - }, "mtm-canvas: file-backed RPC"); -} +export { mount } from "./client/index.ts"; +export type { + MtmharnessFrontendExtensionCleanup, + MtmharnessFrontendExtensionContext, +} from "./client/index.ts"; +export type { + CanvasConnection, + CanvasDocument, + CanvasNode, + CanvasNodeKind, + CanvasPosition, + CanvasSize, + CanvasViewport, +} from "./contract/canvas.ts"; diff --git a/packages/mtmcanvas/tests/package-contract.test.ts b/packages/mtmcanvas/tests/package-contract.test.ts index a2e0cec..0aa51a6 100644 --- a/packages/mtmcanvas/tests/package-contract.test.ts +++ b/packages/mtmcanvas/tests/package-contract.test.ts @@ -1,19 +1,27 @@ -import { readFileSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import { resolve } from "node:path"; import { describe, expect, it } from "vitest"; const packageRoot = resolve(import.meta.dirname, ".."); -describe("mtmcanvas package contract", () => { - it("declares an installable DSH Bundle and Web Client face", () => { +describe("mtmcanvas secondary package contract", () => { + it("publishes only the mtmharness browser extension", () => { const manifest = JSON.parse(readFileSync(resolve(packageRoot, "package.json"), "utf8")) as { - exports: { ".": { default?: string }; "./client": { default?: string } }; - dsh?: { bundle?: { patch?: string }; client?: { platform?: string; inject?: string[] } }; + dsh?: unknown; + main?: string; + type?: string; + files?: string[]; + exports: { ".": { import?: string; default?: string }; "./client": { import?: string; default?: string } }; + mtmharness?: { secondary?: { id?: string; apiVersion?: number; client?: string } }; }; - expect(manifest.dsh?.bundle?.patch).toBe("./cordis.patch.yml"); - expect(manifest.dsh?.client?.platform).toBe("web"); - expect(manifest.dsh?.client?.inject).toContain("@deepseek-ai/dsh-client-ui-sidebar"); - expect(manifest.exports["."]?.default).toBe("./lib/index.js"); - expect(manifest.exports["./client"]?.default).toBe("./lib/client.cjs"); + expect(manifest.dsh).toBeUndefined(); + expect(manifest.type).toBe("module"); + expect(manifest.main).toBe("./lib/client.js"); + expect(manifest.exports["."]).toMatchObject({ import: "./lib/client.js", default: "./lib/client.js" }); + expect(manifest.exports["./client"]).toMatchObject({ import: "./lib/client.js", default: "./lib/client.js" }); + expect(manifest.mtmharness?.secondary).toEqual({ id: "mtmcanvas", apiVersion: 1, client: "./lib/client.js" }); + expect(manifest.files).toEqual(expect.arrayContaining(["lib/client.js", "lib/types/**/*.d.ts"])); + expect(manifest.files).not.toContain("cordis.patch.yml"); + expect(existsSync(resolve(packageRoot, "cordis.patch.yml"))).toBe(false); }); }); diff --git a/packages/mtmharness/README.md b/packages/mtmharness/README.md index 634ce41..0501c7a 100644 --- a/packages/mtmharness/README.md +++ b/packages/mtmharness/README.md @@ -18,22 +18,21 @@ Install the package into a web profile: dsh plugin --profile web add mtmharness dsh --profile web --dump-config -Restart the DSH Web host after changing profile composition. The `MTM` action appears in the sidebar footer. The host owns the React root, session connection, and lifecycle. Install the independent `mtmcanvas` package to add the Canvas action and its Host/Client entry. +Restart the DSH Web host after changing profile composition. The `MTM` action appears in the sidebar footer. The host owns the React root, session connection, and lifecycle. The plugin enables the Connect control panel by default and keeps its registry and `/mtm-connect` RPC on the DSH Host loopback boundary. The independent static/embed client remains a separate application surface and is not part of the DSH plugin. -### Independent Canvas plugin +## Secondary Extensions -Canvas is maintained as the separate `mtmcanvas` DSH package. Add it after installing or upgrading `mtmharness`: +`mtmharness` owns a runtime frontend-extension loader. The `Dynamic Canvas` setting is off by default; enabling it fetches the exact pinned `mtmcanvas` native ESM artifact, verifies its SHA-256 integrity, and mounts it through the `mount(context) -> cleanup` ABI. The extension contract passes only an owned DOM root, document, version, abort signal, and cleanup-registration callback; it does not expose DSH or Node.js internals. The ESM still runs with normal page privileges, so integrity is an identity check, not a browser security boundary; once native import starts, browser evaluation cannot be cancelled. Disabling the setting awaits cleanup and removes the owned root. - dsh plugin --profile web add mtmharness - dsh plugin --profile web add mtmcanvas +`mtmcanvas` is an extension artifact, not a standard DSH plugin. Do not add it with `dsh plugin`; install only `mtmharness`. The default URL uses unpkg, but the manifest accepts any exact HTTPS static-host URL with CORS enabled. The first browser-only experiment keeps Canvas data in memory; persistence and host capabilities are deferred. -Profiles created from an older `mtmharness` release should remove retired `mtm-connect` and `mtm-coding` rows before adding the current packages. The committed profile smoke still covers the old-row cleanup and duplicate install path: +Profiles created from an older `mtmharness` release should remove retired `mtmcanvas`, `mtm-connect`, and `mtm-coding` rows before adding the current package. The committed profile smoke still covers the old-row cleanup and duplicate install path: pnpm --filter mtmharness run profile:check -- /path/to/mtmharness.tgz -`mtmcanvas` now owns the Canvas Host/Client implementation; `mtm-connect` and `mtm-coding` remain historical package names and are not compatibility aliases. +`mtmcanvas` remains a separately published artifact so its browser code can be retrieved at runtime; its package has no `dsh.bundle` or `dsh.client` declaration. Publish the pinned `mtmcanvas` version before `mtmharness`; the mtmharness release gate checks the local, CDN, and manifest SHA-256 values. ## Static App diff --git a/packages/mtmharness/package.json b/packages/mtmharness/package.json index f4a0631..d6f953f 100644 --- a/packages/mtmharness/package.json +++ b/packages/mtmharness/package.json @@ -1,6 +1,6 @@ { "name": "mtmharness", - "version": "0.5.5", + "version": "0.6.0", "description": "Unified DeepSeek Harness Web plugin with Connect, Codebase Memory, Modern Go, Ponytail, and independent static/embed clients.", "type": "module", "engines": { "node": ">=22.19.0", "pnpm": ">=11.7.0" }, diff --git a/packages/mtmharness/scripts/verify-package.mjs b/packages/mtmharness/scripts/verify-package.mjs index 3a2fb55..2fa23f7 100644 --- a/packages/mtmharness/scripts/verify-package.mjs +++ b/packages/mtmharness/scripts/verify-package.mjs @@ -44,6 +44,8 @@ for (const path of [ "lib/types/index.d.ts", "lib/types/client/index.d.ts", "lib/types/features/coding/index.d.ts", + "lib/types/features/secondary/client.d.ts", + "lib/types/features/secondary/manifest.d.ts", "resources/go-modern-guidelines/scripts/VERSION", "resources/go-modern-guidelines/LICENSE", "resources/go-modern-guidelines/scripts/run-tool.sh", @@ -78,6 +80,8 @@ for (const required of [ "RTK", "shell.overlay", "mtmdsh-launcher-overlay", + "mtmcanvas@0.2.0", + "secondary client artifact integrity mismatch", "https://unpkg.com/mtmharness@latest/dist/standalone/index.html", ]) { if (!client.includes(required)) fail("client artifact is missing unified feature surface: " + required); @@ -124,6 +128,8 @@ if (tarball !== undefined) { "package/lib/types/client/index.d.ts", "package/lib/types/index.d.ts", "package/lib/types/features/coding/index.d.ts", + "package/lib/types/features/secondary/client.d.ts", + "package/lib/types/features/secondary/manifest.d.ts", "package/resources/go-modern-guidelines/scripts/VERSION", "package/resources/go-modern-guidelines/LICENSE", "package/resources/go-modern-guidelines/scripts/run-tool.sh", diff --git a/packages/mtmharness/src/client/index.test.ts b/packages/mtmharness/src/client/index.test.ts index e21c52f..8732fa8 100644 --- a/packages/mtmharness/src/client/index.test.ts +++ b/packages/mtmharness/src/client/index.test.ts @@ -17,6 +17,7 @@ function clientBench(): { registered: Registered[]; cleanups: Array<() => void | status: "ready", value: { codebaseMemoryEnabled: false, + dynamicCanvasEnabled: false, codebaseMemoryAugmentHooks: true, modernGoEnabled: true, modernGoCommand: "", @@ -35,8 +36,8 @@ function clientBench(): { registered: Registered[]; cleanups: Array<() => void | }; const ctx = { get(name: string) { - if (name !== "connection") throw new Error("unexpected service: " + name); - return { rpc: { call: async () => ({ ok: true, value: snapshot }) } }; + if (name === "connection") return { rpc: { call: async () => ({ ok: true, value: snapshot }) } }; + throw new Error("unexpected service: " + name); }, provide() {}, locale: { @@ -86,6 +87,7 @@ async function hostBench(): Promise<{ provided: Record; cleanup const cleanups: Array<() => void | Promise> = []; const settings = { codebaseMemoryEnabled: false, + dynamicCanvasEnabled: false, codebaseMemoryAugmentHooks: true, modernGoEnabled: false, modernGoCommand: "", diff --git a/packages/mtmharness/src/client/index.ts b/packages/mtmharness/src/client/index.ts index 485be8f..9cad880 100644 --- a/packages/mtmharness/src/client/index.ts +++ b/packages/mtmharness/src/client/index.ts @@ -6,6 +6,7 @@ import type {} from "@deepseek-ai/dsh-client-ui-settings-plugins/client"; import type {} from "@deepseek-ai/dsh-client-ui-sidebar/client"; import type {} from "@deepseek-ai/dsh-client-ui-layout/client"; import { apply as applyCoding } from "../features/coding/client/index.tsx"; +import { apply as applySecondary } from "../features/secondary/client.ts"; import { apply as applyConnect } from "../features/connect/client/index.ts"; import type { MtmConnectPanelActions } from "../features/connect/client/MtmConnectPanel.tsx"; import { MtmHarnessAction, type MtmHarnessActionInjected } from "./MtmHarnessAction.tsx"; @@ -19,6 +20,7 @@ export const inject = ["slots", "connection", "locale", "settingsScope"]; export function apply(ctx: ClientContext): void { if (ctx.get("connection") === undefined) throw new Error("mtmharness: DSH connection service is unavailable"); applyCoding(ctx); + applySecondary(ctx); const runtime = applyConnect(ctx); const actions: MtmConnectPanelActions = { selectConnection: (connectionId) => { runtime.selectConnection(connectionId); }, diff --git a/packages/mtmharness/src/features/coding/client/MtmCodingCard.tsx b/packages/mtmharness/src/features/coding/client/MtmCodingCard.tsx index e7c91a3..deec838 100644 --- a/packages/mtmharness/src/features/coding/client/MtmCodingCard.tsx +++ b/packages/mtmharness/src/features/coding/client/MtmCodingCard.tsx @@ -111,6 +111,7 @@ export function MtmCodingCard(props: MtmCodingCardProps) {
{disabled ?

{t("readOnly")}

: null} { props.edit("codebaseMemoryEnabled", String(value)); }} onReset={() => { props.resetField("codebaseMemoryEnabled"); }} /> + { props.edit("dynamicCanvasEnabled", String(value)); }} onReset={() => { props.resetField("dynamicCanvasEnabled"); }} /> { props.edit("codebaseMemoryAugmentHooks", String(value)); }} onReset={() => { props.resetField("codebaseMemoryAugmentHooks"); }} /> { props.edit("modernGoEnabled", String(value)); }} onReset={() => { props.resetField("modernGoEnabled"); }} /> { props.edit("modernGoCommand", value); }} onReset={() => { props.resetField("modernGoCommand"); }} /> diff --git a/packages/mtmharness/src/features/coding/client/controller.ts b/packages/mtmharness/src/features/coding/client/controller.ts index 2a3fd59..3a7c0d4 100644 --- a/packages/mtmharness/src/features/coding/client/controller.ts +++ b/packages/mtmharness/src/features/coding/client/controller.ts @@ -7,6 +7,7 @@ export const MODE_VALUES: readonly PonytailMode[] = ["off", "lite", "full", "ult export const RTK_MODE_VALUES: readonly RtkMode[] = ["off", "guidance", "auto", "rewrite"]; const FIELD_NAMES = [ "codebaseMemoryEnabled", + "dynamicCanvasEnabled", "codebaseMemoryAugmentHooks", "modernGoEnabled", "modernGoCommand", diff --git a/packages/mtmharness/src/features/coding/client/locales.ts b/packages/mtmharness/src/features/coding/client/locales.ts index 0265ae0..4ab641d 100644 --- a/packages/mtmharness/src/features/coding/client/locales.ts +++ b/packages/mtmharness/src/features/coding/client/locales.ts @@ -4,6 +4,8 @@ export type MtmCodingLocaleKey = | "description" | "codebaseMemoryEnabled" | "codebaseMemoryEnabledHint" + | "dynamicCanvasEnabled" + | "dynamicCanvasEnabledHint" | "codebaseMemoryAugmentHooks" | "codebaseMemoryAugmentHooksHint" | "modernGoEnabled" @@ -47,6 +49,8 @@ export const en: Record = { description: "Codebase Memory, Modern Go, and Ponytail coding assistance.", codebaseMemoryEnabled: "Codebase Memory", codebaseMemoryEnabledHint: "Expose graph-first code discovery tools and guidance.", + dynamicCanvasEnabled: "Dynamic Canvas", + dynamicCanvasEnabledHint: "Load the pinned Canvas extension at runtime from its published artifact.", codebaseMemoryAugmentHooks: "Codebase Memory context augmentation", codebaseMemoryAugmentHooksHint: "Add bounded repository context around session and read/search events.", modernGoEnabled: "Modern Go Guidelines", @@ -91,6 +95,8 @@ export const zh: Record = { description: "统一配置 Codebase Memory、Modern Go 与 Ponytail 编程辅助。", codebaseMemoryEnabled: "Codebase Memory", codebaseMemoryEnabledHint: "启用图谱优先的代码发现工具和指导。", + dynamicCanvasEnabled: "动态画布", + dynamicCanvasEnabledHint: "从已发布的固定版本载荷中运行时加载 Canvas 扩展。", codebaseMemoryAugmentHooks: "Codebase Memory 上下文增强", codebaseMemoryAugmentHooksHint: "在会话和读/搜索事件周围加入有边界的仓库上下文。", modernGoEnabled: "Modern Go Guidelines", diff --git a/packages/mtmharness/src/features/coding/index.ts b/packages/mtmharness/src/features/coding/index.ts index db33e28..15c2c07 100644 --- a/packages/mtmharness/src/features/coding/index.ts +++ b/packages/mtmharness/src/features/coding/index.ts @@ -172,16 +172,19 @@ export async function apply(ctx: Context, rawConfig: MtmCodingConfig = {}): Prom } }; - await reconcile(settings.get()); - const stopWatching = settings.watch(() => { - if (stopped) return; + const queueReconcile = (): Promise => { reconciling = reconciling .then(() => reconcile(settings.get())) .catch((error: unknown) => { ctx.logger.error("mtm-coding settings reconciliation failed: " + String(error)); }); return reconciling; + }; + const stopWatching = settings.watch(() => { + if (stopped) return; + return queueReconcile(); }); + await queueReconcile(); ctx.effect(() => async () => { stopped = true; diff --git a/packages/mtmharness/src/features/coding/types.ts b/packages/mtmharness/src/features/coding/types.ts index 8fc7402..3916fd4 100644 --- a/packages/mtmharness/src/features/coding/types.ts +++ b/packages/mtmharness/src/features/coding/types.ts @@ -6,6 +6,7 @@ export type RtkMode = "off" | "guidance" | "auto" | "rewrite"; export interface MtmCodingSettings { codebaseMemoryEnabled: boolean; + dynamicCanvasEnabled: boolean; codebaseMemoryAugmentHooks: boolean; modernGoEnabled: boolean; modernGoCommand: string; @@ -41,6 +42,7 @@ const Reconnect = z.object({ export const MtmCodingSettingsSchema: z = z.object({ codebaseMemoryEnabled: z.boolean().default(true), + dynamicCanvasEnabled: z.boolean().default(false), codebaseMemoryAugmentHooks: z.boolean().default(true), modernGoEnabled: z.boolean().default(true), modernGoCommand: z.string().default(""), diff --git a/packages/mtmharness/src/features/secondary/client.test.ts b/packages/mtmharness/src/features/secondary/client.test.ts new file mode 100644 index 0000000..8377594 --- /dev/null +++ b/packages/mtmharness/src/features/secondary/client.test.ts @@ -0,0 +1,177 @@ +import { describe, expect, it, vi } from "vitest"; +import { assertSecondaryManifest, type MtmSecondaryExtensionManifest } from "./manifest.ts"; +import { loadSecondaryModule, MtmSecondaryClientRuntime } from "./client.ts"; + +const MANIFEST: MtmSecondaryExtensionManifest = { + apiVersion: 1, + id: "mtmcanvas", + version: "0.2.0", + clientUrl: "https://static.example.test/extensions/canvas.js", + clientIntegrity: "sha256-" + "A".repeat(43) + "=", +}; + +function bench() { + const cleanup = vi.fn(); + const mount = vi.fn(({ root }: { root: HTMLElement; registerCleanup: (cleanup: () => void | Promise) => void }) => { + root.textContent = "loaded"; + return cleanup; + }); + const importer = vi.fn(async () => ({ mount })); + const fetcher = vi.fn(async () => new Response("export function mount() {}", { status: 200 })); + const runtime = new MtmSecondaryClientRuntime({ + document, + fetch: fetcher, + digest: async () => MANIFEST.clientIntegrity, + importModule: importer, + }, MANIFEST); + return { cleanup, fetcher, importer, mount, runtime }; +} + +describe("secondary extension manifest", () => { + it("accepts an exact HTTPS artifact on an arbitrary static host", () => { + expect(() => assertSecondaryManifest(MANIFEST)).not.toThrow(); + }); + + it.each([ + { clientUrl: "http://static.example.test/extensions/canvas.js", message: "HTTPS" }, + { clientUrl: "https://static.example.test/extensions/canvas.js#fragment", message: "HTTPS" }, + { clientIntegrity: "sha256-invalid", message: "sha256" }, + { apiVersion: 2, message: "API" }, + ])("rejects unsafe metadata", (patch) => { + expect(() => assertSecondaryManifest({ ...MANIFEST, ...patch })).toThrow(patch.message); + }); + + it("rejects malformed input at the trust boundary", () => { + expect(() => assertSecondaryManifest(null)).toThrow("manifest is invalid"); + }); +}); + +describe("secondary ESM loading", () => { + it("verifies bytes before importing the native module", async () => { + const imported = vi.fn(async () => ({ mount() {} })); + const fetcher = vi.fn(async () => new Response("export function mount() {}", { status: 200 })); + await expect(loadSecondaryModule(MANIFEST, { fetch: fetcher, digest: async () => MANIFEST.clientIntegrity, importModule: imported })).resolves.toEqual({ mount: expect.any(Function) }); + expect(fetcher).toHaveBeenCalledWith(MANIFEST.clientUrl, { redirect: "error", signal: undefined }); + expect(imported).toHaveBeenCalledTimes(1); + }); + + it("does not import tampered bytes", async () => { + const imported = vi.fn(async () => ({ mount() {} })); + const fetcher = vi.fn(async () => new Response("tampered", { status: 200 })); + await expect(loadSecondaryModule(MANIFEST, { fetch: fetcher, digest: async () => "sha256-" + "B".repeat(43) + "=", importModule: imported })).rejects.toThrow("integrity mismatch"); + expect(imported).not.toHaveBeenCalled(); + }); + + it("imports a self-contained native ESM module", async () => { + const fetcher = vi.fn(async () => new Response("export function mount() {}", { status: 200 })); + const originalCreateObjectURL = URL.createObjectURL; + Object.defineProperty(URL, "createObjectURL", { configurable: true, value: undefined }); + try { + const extension = await loadSecondaryModule(MANIFEST, { fetch: fetcher, digest: async () => MANIFEST.clientIntegrity }); + expect(extension.mount).toEqual(expect.any(Function)); + } finally { + Object.defineProperty(URL, "createObjectURL", { configurable: true, value: originalCreateObjectURL }); + } + }); + + it("does not import after cancellation during digest", async () => { + let resolveDigest!: (value: string) => void; + const digest = new Promise((resolve) => { resolveDigest = resolve; }); + const imported = vi.fn(async () => ({ mount() {} })); + const controller = new AbortController(); + const loading = loadSecondaryModule(MANIFEST, { + fetch: async () => new Response("export function mount() {}", { status: 200 }), + digest: async () => digest, + importModule: imported, + }, controller.signal); + await Promise.resolve(); + controller.abort(); + resolveDigest(MANIFEST.clientIntegrity); + await expect(loading).rejects.toMatchObject({ name: "AbortError" }); + expect(imported).not.toHaveBeenCalled(); + }); + + it("rejects a module without the mount export", async () => { + const fetcher = vi.fn(async () => new Response("export const value = 1", { status: 200 })); + await expect(loadSecondaryModule(MANIFEST, { fetch: fetcher, digest: async () => MANIFEST.clientIntegrity, importModule: async () => ({}) })).rejects.toThrow("mount(context)"); + }); +}); + +describe("secondary extension lifecycle", () => { + it("mounts into an owned root and removes the root on disable", async () => { + const state = bench(); + await state.runtime.setEnabled(true); + expect(state.runtime.getSnapshot()).toEqual({ desired: true, status: "enabled" }); + expect(state.mount).toHaveBeenCalledWith(expect.objectContaining({ apiVersion: 1, id: MANIFEST.id, version: MANIFEST.version })); + expect(document.querySelector("[data-mtm-secondary-extension=mtmcanvas]")?.textContent).toBe("loaded"); + + await state.runtime.setEnabled(false); + expect(state.runtime.getSnapshot()).toEqual({ desired: false, status: "disabled" }); + expect(state.cleanup).toHaveBeenCalledOnce(); + expect(document.querySelector("[data-mtm-secondary-extension=mtmcanvas]")).toBeNull(); + }); + + it("cleans up when mount fails", async () => { + const state = bench(); + state.mount.mockImplementation(() => { throw new Error("mount failed"); }); + await state.runtime.setEnabled(true); + expect(state.runtime.getSnapshot()).toEqual({ desired: true, status: "failed", error: "mount failed" }); + expect(document.querySelector("[data-mtm-secondary-extension=mtmcanvas]")).toBeNull(); + }); + + it("runs registered cleanup when mount fails", async () => { + const state = bench(); + const externalCleanup = vi.fn(); + state.mount.mockImplementation(({ registerCleanup }) => { + registerCleanup(externalCleanup); + throw new Error("mount failed after setup"); + }); + await state.runtime.setEnabled(true); + expect(externalCleanup).toHaveBeenCalledOnce(); + expect(state.runtime.getSnapshot()).toMatchObject({ status: "failed", error: "mount failed after setup" }); + }); + + it("aborts a pending fetch when disabled", async () => { + let signal: AbortSignal | undefined; + const fetcher = vi.fn((_: string | URL, init?: RequestInit) => new Promise((_, reject) => { + signal = init?.signal; + signal?.addEventListener("abort", () => reject(new DOMException("cancelled", "AbortError")), { once: true }); + })); + const runtime = new MtmSecondaryClientRuntime({ document, fetch: fetcher, digest: async () => MANIFEST.clientIntegrity, importModule: async () => ({ mount() {} }) }, MANIFEST); + const enabling = runtime.setEnabled(true); + await Promise.resolve(); + const disabling = runtime.setEnabled(false); + await Promise.all([enabling, disabling]); + expect(signal?.aborted).toBe(true); + expect(runtime.getSnapshot()).toEqual({ desired: false, status: "disabled" }); + }); + + it("coalesces rapid changes to the latest state", async () => { + const state = bench(); + await Promise.all([state.runtime.setEnabled(true), state.runtime.setEnabled(false), state.runtime.setEnabled(true)]); + expect(state.importer).toHaveBeenCalledOnce(); + expect(state.mount).toHaveBeenCalledOnce(); + expect(state.runtime.getSnapshot().status).toBe("enabled"); + await state.runtime.dispose(); + }); + + it("retries a failed cleanup before reporting disabled", async () => { + const state = bench(); + await state.runtime.setEnabled(true); + state.cleanup.mockImplementationOnce(() => { throw new Error("transient cleanup failure"); }); + await state.runtime.setEnabled(false); + expect(state.runtime.getSnapshot()).toMatchObject({ desired: false, status: "failed", error: "transient cleanup failure" }); + expect(document.querySelector("[data-mtm-secondary-extension=mtmcanvas]")).toBeNull(); + await state.runtime.setEnabled(false); + expect(state.runtime.getSnapshot()).toEqual({ desired: false, status: "disabled" }); + expect(state.cleanup).toHaveBeenCalledTimes(2); + }); + + it("does not mount after disposal", async () => { + const state = bench(); + await state.runtime.dispose(); + await state.runtime.setEnabled(true); + expect(state.fetcher).not.toHaveBeenCalled(); + expect(state.runtime.getSnapshot()).toEqual({ desired: false, status: "disabled" }); + }); +}); diff --git a/packages/mtmharness/src/features/secondary/client.ts b/packages/mtmharness/src/features/secondary/client.ts new file mode 100644 index 0000000..f5a9201 --- /dev/null +++ b/packages/mtmharness/src/features/secondary/client.ts @@ -0,0 +1,291 @@ +/** + * mtmharness-owned loader for trusted browser frontend extensions. + * + * Extensions use a small mount/cleanup contract and never receive DSH internals. + * The artifact is fetched as bytes so the host can verify SHA-256 before importing + * it as native ESM; the release must therefore publish a self-contained bundle. + */ +import type { ClientContext } from "@deepseek-ai/dsh-client-runtime/client"; +import { assertSecondaryManifest, MTM_CANVAS_EXTENSION, type MtmSecondaryExtensionManifest } from "./manifest.js"; +export { assertSecondaryManifest } from "./manifest.js"; + +/** Stable browser ABI exposed to a secondary extension. */ +export interface MtmharnessFrontendExtensionContext { + readonly apiVersion: 1; + readonly id: string; + readonly version: string; + readonly root: HTMLElement; + readonly document: Document; + readonly signal: AbortSignal; + readonly registerCleanup: (cleanup: () => void | Promise) => void; +} + +export type MtmharnessFrontendExtensionCleanup = void | (() => void | Promise); + +/** Native ESM module shape required from a secondary artifact. */ +export interface MtmharnessFrontendExtension { + mount(context: MtmharnessFrontendExtensionContext): MtmharnessFrontendExtensionCleanup | Promise; +} + +export interface MtmSecondarySnapshot { + readonly desired: boolean; + readonly status: "disabled" | "loading" | "enabled" | "failed"; + readonly error?: string; +} + +/** Injectable network/import seams used by focused lifecycle tests. */ +export interface MtmSecondaryClientOptions { + readonly document?: Document; + readonly fetch?: typeof fetch; + readonly importModule?: (url: string) => Promise; + readonly digest?: (bytes: Uint8Array) => Promise; +} + +type Cleanup = () => void | Promise; + +type SecondarySettingsScope = { + getSnapshot(): { value?: { dynamicCanvasEnabled?: boolean } }; + subscribe(listener: () => void): () => void; +}; + +type SecondarySettingsBinder = { + bind(spec: { namespace: string }): SecondarySettingsScope & { getSnapshot(): { value?: T } }; +}; + +const MAX_ARTIFACT_BYTES = 2_000_000; + +function errorText(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function isAbortError(error: unknown): boolean { + return typeof error === "object" && error !== null && "name" in error && error.name === "AbortError"; +} + +function throwIfAborted(signal: AbortSignal | undefined): void { + if (signal?.aborted) throw signal.reason ?? new DOMException("secondary extension load was cancelled", "AbortError"); +} + +async function sha256(bytes: Uint8Array): Promise { + const subtle = globalThis.crypto?.subtle; + if (subtle === undefined) throw new Error("secondary extension SHA-256 is unavailable"); + const digest = new Uint8Array(await subtle.digest("SHA-256", bytes.buffer as ArrayBuffer)); + let binary = ""; + for (const byte of digest) binary += String.fromCharCode(byte); + return "sha256-" + btoa(binary); +} + +function extensionExports(value: unknown): MtmharnessFrontendExtension { + const mount = typeof value === "object" && value !== null ? (value as { mount?: unknown }).mount : undefined; + if (typeof mount !== "function") throw new Error("secondary client artifact must export mount(context)"); + return { mount: mount as MtmharnessFrontendExtension["mount"] }; +} + +/** Fetch, verify, and import one self-contained native ESM artifact. */ +export async function loadSecondaryModule( + manifest: MtmSecondaryExtensionManifest, + options: MtmSecondaryClientOptions = {}, + signal?: AbortSignal, +): Promise { + assertSecondaryManifest(manifest); + throwIfAborted(signal); + const fetcher = options.fetch ?? globalThis.fetch; + const response = await fetcher(manifest.clientUrl, { redirect: "error", signal }); + if (!response.ok) throw new Error("secondary client artifact returned HTTP " + response.status); + const length = Number(response.headers.get("content-length")); + if (Number.isFinite(length) && length > MAX_ARTIFACT_BYTES) throw new Error("secondary client artifact size is invalid"); + const bytes = new Uint8Array(await response.arrayBuffer()); + throwIfAborted(signal); + if (bytes.byteLength === 0 || bytes.byteLength > MAX_ARTIFACT_BYTES) throw new Error("secondary client artifact size is invalid"); + const integrity = await (options.digest ?? sha256)(bytes); + throwIfAborted(signal); + if (integrity !== manifest.clientIntegrity) throw new Error("secondary client artifact integrity mismatch"); + + const source = new TextDecoder().decode(bytes); + const sourceUrl = typeof URL.createObjectURL === "function" + ? URL.createObjectURL(new Blob([source], { type: "text/javascript" })) + : "data:text/javascript;charset=utf-8," + encodeURIComponent(source); + try { + throwIfAborted(signal); + // Dynamic import has no cancellation API; the checkpoint prevents starting it after abort. + const importModule = options.importModule ?? ((url: string) => import(/* @vite-ignore */ url)); + return extensionExports(await importModule(sourceUrl)); + } finally { + if (sourceUrl.startsWith("blob:")) URL.revokeObjectURL(sourceUrl); + } +} + +/** Load and unload one trusted frontend artifact with serialized transitions. */ +export class MtmSecondaryClientRuntime { + private readonly listeners = new Set<() => void>(); + private snapshot: MtmSecondarySnapshot = { desired: false, status: "disabled" }; + private desired = false; + private root: HTMLDivElement | undefined; + private cleanup: Cleanup | undefined; + private loadAbort: AbortController | undefined; + private queue: Promise = Promise.resolve(); + private disposed = false; + + constructor( + private readonly options: MtmSecondaryClientOptions = {}, + private readonly manifest: MtmSecondaryExtensionManifest = MTM_CANVAS_EXTENSION, + ) { + assertSecondaryManifest(manifest); + } + + getSnapshot = (): MtmSecondarySnapshot => this.snapshot; + + subscribe = (listener: () => void): (() => void) => { + this.listeners.add(listener); + return () => { this.listeners.delete(listener); }; + }; + + setEnabled(enabled: boolean): Promise { + if (this.disposed) return Promise.resolve(); + this.desired = enabled; + if (!enabled) this.loadAbort?.abort(); + const operation = this.queue.then(() => this.reconcile()); + this.queue = operation.then(() => undefined, () => undefined); + return operation; + } + + async dispose(): Promise { + if (this.disposed) return; + this.disposed = true; + this.desired = false; + this.loadAbort?.abort(); + await this.queue; + try { + await this.unload(); + } finally { + this.publish({ desired: false, status: "disabled" }); + this.listeners.clear(); + } + } + + private async reconcile(): Promise { + if (this.disposed) return; + if (!this.desired) { + try { + await this.unload(); + this.publish({ desired: false, status: "disabled" }); + } catch (error) { + this.publish({ desired: false, status: "failed", error: errorText(error) }); + } + return; + } + if (this.root !== undefined) return; + try { + await this.load(); + if (!this.disposed && this.desired && this.root !== undefined) this.publish({ desired: true, status: "enabled" }); + } catch (error) { + try { + await this.unload(); + } catch (cleanupError) { + error = new AggregateError([error, cleanupError], "secondary client load cleanup failed"); + } + if (this.disposed || !this.desired || isAbortError(error)) return; + this.publish({ desired: true, status: "failed", error: errorText(error) }); + } + } + + private async load(): Promise { + this.publish({ desired: true, status: "loading" }); + const doc = this.options.document ?? (typeof document === "undefined" ? undefined : document); + if (doc?.body === null || doc === undefined) throw new Error("secondary extension document body is unavailable"); + const controller = new AbortController(); + this.loadAbort = controller; + try { + const extension = await loadSecondaryModule(this.manifest, this.options, controller.signal); + if (this.disposed || !this.desired) return; + const root = doc.createElement("div"); + root.dataset.mtmSecondaryExtension = this.manifest.id; + doc.body.append(root); + this.root = root; + const registeredCleanups: Cleanup[] = []; + let returnedCleanup: Cleanup | undefined; + let acceptingCleanups = true; + const registerCleanup = (cleanup: Cleanup): void => { + if (typeof cleanup !== "function") throw new Error("secondary extension cleanup must be a function"); + if (!acceptingCleanups) throw new Error("secondary extension cleanup registration is closed"); + registeredCleanups.push(cleanup); + }; + const cleanupAll: Cleanup = async () => { + acceptingCleanups = false; + let failed = false; + let failure: unknown; + if (returnedCleanup !== undefined) { + try { + await returnedCleanup(); + returnedCleanup = undefined; + } catch (error) { + failed = true; + failure = error; + } + } + for (let index = registeredCleanups.length - 1; index >= 0; index -= 1) { + try { + await registeredCleanups[index]!(); + registeredCleanups.splice(index, 1); + } catch (error) { + failed = true; + failure ??= error; + } + } + if (failed) throw failure; + }; + this.cleanup = cleanupAll; + const mountedCleanup = await extension.mount({ + apiVersion: this.manifest.apiVersion, + id: this.manifest.id, + version: this.manifest.version, + root, + document: doc, + signal: controller.signal, + registerCleanup, + }); + if (typeof mountedCleanup === "function") returnedCleanup = mountedCleanup; + else if (mountedCleanup !== undefined) throw new Error("secondary extension mount must return a cleanup function"); + acceptingCleanups = false; + if (this.disposed || !this.desired) await this.unload(); + } finally { + if (this.loadAbort === controller) this.loadAbort = undefined; + } + } + + private async unload(): Promise { + const cleanup = this.cleanup; + const root = this.root; + try { + if (cleanup !== undefined) await cleanup(); + } finally { + root?.remove(); + } + this.cleanup = undefined; + this.root = undefined; + } + + private publish(next: MtmSecondarySnapshot): void { + this.snapshot = next; + for (const listener of [...this.listeners]) listener(); + } +} + +/** Mount the secondary controller under the primary mtmharness client fiber. */ +export function apply(ctx: ClientContext): void { + const settingsScope = ctx.settingsScope as SecondarySettingsBinder | undefined; + if (settingsScope === undefined) throw new Error("mtmharness: secondary settings service is unavailable"); + const settings = settingsScope.bind<{ dynamicCanvasEnabled?: boolean }>({ namespace: "mtm-coding" }); + const runtime = new MtmSecondaryClientRuntime({ document: typeof document === "undefined" ? undefined : document }); + const reconcile = (): void => { + void runtime.setEnabled(settings.getSnapshot().value?.dynamicCanvasEnabled === true); + }; + const unsubscribe = settings.subscribe(reconcile); + reconcile(); + ctx.effect(() => async () => { + unsubscribe(); + await runtime.dispose(); + }, "mtmharness: secondary extension lifecycle"); +} + +export const inject = ["settingsScope"]; diff --git a/packages/mtmharness/src/features/secondary/manifest.ts b/packages/mtmharness/src/features/secondary/manifest.ts new file mode 100644 index 0000000..f8e1c11 --- /dev/null +++ b/packages/mtmharness/src/features/secondary/manifest.ts @@ -0,0 +1,54 @@ +/** Trusted runtime metadata for mtmharness frontend extensions. */ +export interface MtmSecondaryExtensionManifest { + /** Version of the mtmharness frontend extension ABI. */ + readonly apiVersion: 1; + /** Stable extension identifier. */ + readonly id: string; + /** Exact extension release version. */ + readonly version: string; + /** Exact browser artifact URL. */ + readonly clientUrl: string; + /** Native Subresource Integrity value for the browser artifact. */ + readonly clientIntegrity: string; +} + +const ID = /^[a-z][a-z0-9._-]{0,63}$/u; +const VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/u; +const INTEGRITY = /^sha256-[A-Za-z0-9+/]{43}=$/u; + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function assertArtifactUrl(value: string): void { + let url: URL; + try { + url = new URL(value); + } catch { + throw new Error("secondary client URL must be an exact HTTPS URL"); + } + if (url.protocol !== "https:" || url.username || url.password || url.hash) { + throw new Error("secondary client URL must be an exact HTTPS URL"); + } +} + +/** Validate trusted, administrator-controlled metadata before browser loading. */ +export function assertSecondaryManifest(value: unknown): asserts value is MtmSecondaryExtensionManifest { + if (!isRecord(value) || typeof value.id !== "string" || typeof value.version !== "string" || typeof value.clientUrl !== "string" || typeof value.clientIntegrity !== "string") { + throw new Error("secondary extension manifest is invalid"); + } + if (value.apiVersion !== 1) throw new Error("secondary extension API version is unsupported"); + if (!ID.test(value.id)) throw new Error("secondary extension id is invalid"); + if (!VERSION.test(value.version) || value.version.includes("latest")) throw new Error("secondary extension version must be pinned"); + if (!INTEGRITY.test(value.clientIntegrity)) throw new Error("secondary extension integrity must be sha256"); + assertArtifactUrl(value.clientUrl); +} + +/** The published Canvas artifact used by the first secondary extension experiment. */ +export const MTM_CANVAS_EXTENSION = { + apiVersion: 1, + id: "mtmcanvas", + version: "0.2.0", + clientUrl: "https://unpkg.com/mtmcanvas@0.2.0/lib/client.js", + clientIntegrity: "sha256-TDJa0tdb9LK87hCigE0aruLJnuNqRG7Ls2UfuHWsKU4=", +} as const satisfies MtmSecondaryExtensionManifest; diff --git a/packages/mtmharness/src/index.ts b/packages/mtmharness/src/index.ts index ab091af..20bdeda 100644 --- a/packages/mtmharness/src/index.ts +++ b/packages/mtmharness/src/index.ts @@ -35,6 +35,11 @@ export { rtkEnvironment, } from "./features/coding/rtk-runtime.ts"; export type { MtmCodingConfig, MtmCodingSettings, PonytailMode, RtkMode } from "./features/coding/types.ts"; +export type { + MtmharnessFrontendExtension, + MtmharnessFrontendExtensionCleanup, + MtmharnessFrontendExtensionContext, +} from "./features/secondary/client.ts"; export const name = "mtmharness"; export const inject = ["connection", "settings"]; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bfc1de5..0ff2884 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,33 +14,6 @@ importers: packages/mtmcanvas: devDependencies: - '@deepseek-ai/cordis': - specifier: 4.0.1 - version: 4.0.1(@deepseek-ai/cordis-plugin-include@1.0.6)(@deepseek-ai/cordis-plugin-loader@1.0.2) - '@deepseek-ai/dsh-client-connection': - specifier: 0.1.1-rc.2 - version: 0.1.1-rc.2(9b40f28fccfb9ee3f86188afc9586c29) - '@deepseek-ai/dsh-client-runtime': - specifier: 0.1.1-rc.2 - version: 0.1.1-rc.2(601e9129357dc48738583e18d75d19b2) - '@deepseek-ai/dsh-client-ui-layout': - specifier: 0.1.1-rc.2 - version: 0.1.1-rc.2(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-client-runtime@0.1.1-rc.2(601e9129357dc48738583e18d75d19b2))(@deepseek-ai/dsh-client-ui-theme@0.1.1-rc.2(a6c39110759121c55549be9a5dac7bf7))(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.1)) - '@deepseek-ai/dsh-client-ui-primitives': - specifier: 0.1.1-rc.2 - version: 0.1.1-rc.2(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.1)) - '@deepseek-ai/dsh-client-ui-sidebar': - specifier: 0.1.1-rc.2 - version: 0.1.1-rc.2(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-client-locale@0.1.1-rc.2(048e7c438545728704e82d9acbc5c221))(@deepseek-ai/dsh-client-runtime@0.1.1-rc.2(601e9129357dc48738583e18d75d19b2))(@deepseek-ai/dsh-client-ui-layout@0.1.1-rc.2(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-client-runtime@0.1.1-rc.2(601e9129357dc48738583e18d75d19b2))(@deepseek-ai/dsh-client-ui-theme@0.1.1-rc.2(a6c39110759121c55549be9a5dac7bf7))(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.1)))(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.1)) - '@deepseek-ai/dsh-client-ui-slots': - specifier: 0.1.1-rc.2 - version: 0.1.1-rc.2(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.1)) - '@deepseek-ai/dsh-fs': - specifier: 0.1.1-rc.2 - version: 0.1.1-rc.2(369bb7c3e084bdd2eec52747b4523f36) - '@deepseek-ai/dsh-host-directory-picker': - specifier: 0.1.1-rc.2 - version: 0.1.1-rc.2(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.1)) '@types/node': specifier: 22.20.1 version: 22.20.1 @@ -555,15 +528,6 @@ packages: '@deepseek-ai/dsh-invariants': ^0.1.1-rc.2 '@deepseek-ai/dsh-typert-protocol': ^0.1.1-rc.2 - '@deepseek-ai/dsh-fs@0.1.1-rc.2': - resolution: {integrity: sha512-8j+6MffvCHATLQrhAVfc9rKyunKu/O7mjjJzmdsUSdID7V4iUYMwqPamhlAyI+tfohZu/vcforKzCRIZGmCYug==} - peerDependencies: - '@deepseek-ai/cordis': ^4.0.1 - '@deepseek-ai/dsh-brand': ^0.1.1-rc.2 - '@deepseek-ai/dsh-invariants': ^0.1.1-rc.2 - '@deepseek-ai/dsh-llm': ^0.1.1-rc.2 - '@deepseek-ai/dsh-sandbox': ^0.1.1-rc.2 - '@deepseek-ai/dsh-goal@0.1.1-rc.2': resolution: {integrity: sha512-lSHTh4vfS6eRb9to/y+bjRf2+0QkNpY3tHJ29HMTewR9fJYZsEVVu4Hc+GPhPEjF7RpiD35/sKx+akijtDasyg==} peerDependencies: @@ -3083,14 +3047,6 @@ snapshots: '@deepseek-ai/dsh-typert-protocol': 0.1.1-rc.2(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.1)) zod: 4.4.3 - '@deepseek-ai/dsh-fs@0.1.1-rc.2(369bb7c3e084bdd2eec52747b4523f36)': - dependencies: - '@deepseek-ai/cordis': 4.0.1(@deepseek-ai/cordis-plugin-include@1.0.6)(@deepseek-ai/cordis-plugin-loader@1.0.2) - '@deepseek-ai/dsh-brand': 0.1.1-rc.2(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.1)) - '@deepseek-ai/dsh-invariants': 0.1.1-rc.2(@deepseek-ai/cordis@4.0.1) - '@deepseek-ai/dsh-llm': 0.1.1-rc.2(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-attachment@0.1.1-rc.2(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-brand@0.1.1-rc.2(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.1)))(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.1)))(@deepseek-ai/dsh-brand@0.1.1-rc.2(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.1)))(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.1))(@deepseek-ai/dsh-timeout@0.1.1-rc.2(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.1))) - '@deepseek-ai/dsh-sandbox': 0.1.1-rc.2(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.1))(@deepseek-ai/dsh-llm@0.1.1-rc.2(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-attachment@0.1.1-rc.2(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-brand@0.1.1-rc.2(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.1)))(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.1)))(@deepseek-ai/dsh-brand@0.1.1-rc.2(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.1)))(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.1))(@deepseek-ai/dsh-timeout@0.1.1-rc.2(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.1))))(@deepseek-ai/dsh-session@0.1.1-rc.2(a4e4bb24a1f3580ac25e11cfa3c6b8cc)) - '@deepseek-ai/dsh-goal@0.1.1-rc.2(a40f169b35a63b1d25a94e0ed91514fe)': dependencies: '@deepseek-ai/cordis': 4.0.1(@deepseek-ai/cordis-plugin-include@1.0.6)(@deepseek-ai/cordis-plugin-loader@1.0.2) @@ -3270,6 +3226,7 @@ snapshots: '@deepseek-ai/dsh-invariants': 0.1.1-rc.2(@deepseek-ai/cordis@4.0.1) '@deepseek-ai/dsh-llm': 0.1.1-rc.2(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-attachment@0.1.1-rc.2(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-brand@0.1.1-rc.2(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.1)))(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.1)))(@deepseek-ai/dsh-brand@0.1.1-rc.2(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.1)))(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.1))(@deepseek-ai/dsh-timeout@0.1.1-rc.2(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.1))) '@deepseek-ai/dsh-session': 0.1.1-rc.2(a4e4bb24a1f3580ac25e11cfa3c6b8cc) + optional: true '@deepseek-ai/dsh-scope@0.1.1-rc.2(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.1))': dependencies: From 7307fac9c10d9edd3086297f469f5c6667f9603e Mon Sep 17 00:00:00 2001 From: a Date: Fri, 28 Aug 2026 17:44:40 +0000 Subject: [PATCH 2/2] test(mtmharness): harden browser extension contract --- .github/workflows/ci.yml | 1 + packages/mtmcanvas/README.md | 2 +- packages/mtmcanvas/scripts/build.mjs | 2 ++ packages/mtmharness/README.md | 2 +- packages/mtmharness/scripts/verify-package.mjs | 2 -- packages/mtmharness/src/features/secondary/client.ts | 12 +++++++----- 6 files changed, 12 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 597654b..6096ef2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -65,6 +65,7 @@ jobs: - name: Lint mtmharness package metadata run: pnpm dlx publint run "${{ steps.pack.outputs.tarball }}" --strict + - name: Verify mtmharness profile migration if: ${{ matrix.node == '22.19.0' }} run: pnpm --filter mtmharness run profile:check -- "${{ steps.pack.outputs.tarball }}" diff --git a/packages/mtmcanvas/README.md b/packages/mtmcanvas/README.md index b9aca68..7d5a680 100644 --- a/packages/mtmcanvas/README.md +++ b/packages/mtmcanvas/README.md @@ -14,7 +14,7 @@ The first experiment keeps Canvas data in browser memory. File persistence and h Install only `mtmharness` into the DSH Web profile. The Dynamic Canvas setting loads the exact Canvas artifact at runtime; it does not modify the profile or create another DSH Loader entry. -The default experiment uses the published URL on unpkg, but the loader accepts any exact HTTPS static-host URL with CORS enabled. The artifact is fetched, checked against its SHA-256 integrity value, imported as native ESM, and mounted into an owned root. +The default experiment uses the published URL on unpkg, but the loader accepts any exact HTTPS static-host URL with CORS enabled. The host CSP must allow `connect-src` to the artifact origin and `script-src blob:` for the fetched ESM. The artifact is fetched, checked against its SHA-256 integrity value, imported as native ESM, and mounted into an owned root. ## Development diff --git a/packages/mtmcanvas/scripts/build.mjs b/packages/mtmcanvas/scripts/build.mjs index 945892e..5646a62 100644 --- a/packages/mtmcanvas/scripts/build.mjs +++ b/packages/mtmcanvas/scripts/build.mjs @@ -28,5 +28,7 @@ await build({ legalComments: "none", logLevel: "info", }); +const artifact = await import(resolve(libRoot, "client.js")); +if (typeof artifact.mount !== "function") throw new Error("mtmcanvas build: client artifact must export mount(context)"); console.log("built mtmcanvas browser ESM artifact"); diff --git a/packages/mtmharness/README.md b/packages/mtmharness/README.md index 0501c7a..7ff6c3b 100644 --- a/packages/mtmharness/README.md +++ b/packages/mtmharness/README.md @@ -26,7 +26,7 @@ The plugin enables the Connect control panel by default and keeps its registry a `mtmharness` owns a runtime frontend-extension loader. The `Dynamic Canvas` setting is off by default; enabling it fetches the exact pinned `mtmcanvas` native ESM artifact, verifies its SHA-256 integrity, and mounts it through the `mount(context) -> cleanup` ABI. The extension contract passes only an owned DOM root, document, version, abort signal, and cleanup-registration callback; it does not expose DSH or Node.js internals. The ESM still runs with normal page privileges, so integrity is an identity check, not a browser security boundary; once native import starts, browser evaluation cannot be cancelled. Disabling the setting awaits cleanup and removes the owned root. -`mtmcanvas` is an extension artifact, not a standard DSH plugin. Do not add it with `dsh plugin`; install only `mtmharness`. The default URL uses unpkg, but the manifest accepts any exact HTTPS static-host URL with CORS enabled. The first browser-only experiment keeps Canvas data in memory; persistence and host capabilities are deferred. +`mtmcanvas` is an extension artifact, not a standard DSH plugin. Do not add it with `dsh plugin`; install only `mtmharness`. The default URL uses unpkg, but the manifest accepts any exact HTTPS static-host URL with CORS enabled. The host CSP must allow `connect-src` to the artifact origin and `script-src blob:` for the fetched ESM. The first browser-only experiment keeps Canvas data in memory; persistence and host capabilities are deferred. Profiles created from an older `mtmharness` release should remove retired `mtmcanvas`, `mtm-connect`, and `mtm-coding` rows before adding the current package. The committed profile smoke still covers the old-row cleanup and duplicate install path: diff --git a/packages/mtmharness/scripts/verify-package.mjs b/packages/mtmharness/scripts/verify-package.mjs index 2fa23f7..a1c7c3c 100644 --- a/packages/mtmharness/scripts/verify-package.mjs +++ b/packages/mtmharness/scripts/verify-package.mjs @@ -80,8 +80,6 @@ for (const required of [ "RTK", "shell.overlay", "mtmdsh-launcher-overlay", - "mtmcanvas@0.2.0", - "secondary client artifact integrity mismatch", "https://unpkg.com/mtmharness@latest/dist/standalone/index.html", ]) { if (!client.includes(required)) fail("client artifact is missing unified feature surface: " + required); diff --git a/packages/mtmharness/src/features/secondary/client.ts b/packages/mtmharness/src/features/secondary/client.ts index f5a9201..2410eeb 100644 --- a/packages/mtmharness/src/features/secondary/client.ts +++ b/packages/mtmharness/src/features/secondary/client.ts @@ -102,16 +102,18 @@ export async function loadSecondaryModule( if (integrity !== manifest.clientIntegrity) throw new Error("secondary client artifact integrity mismatch"); const source = new TextDecoder().decode(bytes); - const sourceUrl = typeof URL.createObjectURL === "function" - ? URL.createObjectURL(new Blob([source], { type: "text/javascript" })) - : "data:text/javascript;charset=utf-8," + encodeURIComponent(source); + const importModule = options.importModule ?? ((url: string) => import(/* @vite-ignore */ url)); + if (typeof URL.createObjectURL !== "function" || typeof URL.revokeObjectURL !== "function") { + throwIfAborted(signal); + return extensionExports(await importModule("data:text/javascript;charset=utf-8," + encodeURIComponent(source))); + } + const sourceUrl = URL.createObjectURL(new Blob([source], { type: "text/javascript" })); try { throwIfAborted(signal); // Dynamic import has no cancellation API; the checkpoint prevents starting it after abort. - const importModule = options.importModule ?? ((url: string) => import(/* @vite-ignore */ url)); return extensionExports(await importModule(sourceUrl)); } finally { - if (sourceUrl.startsWith("blob:")) URL.revokeObjectURL(sourceUrl); + URL.revokeObjectURL(sourceUrl); } }