diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9801fce4..8261ac2f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -85,6 +85,31 @@ jobs: - name: Verify API inventory and reports run: npm run api:check + web-visual: + name: Web visual and accessibility + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: 22.16.0 + cache: npm + + - name: Install + run: npm ci + + - name: Install Chromium + run: npx playwright install --with-deps chromium + + - name: Build Web workspace + run: npm run build -w @rdk-moss/agent + + - name: Run Playwright visual and accessibility gate + run: npm run test:filter -w @rdk-moss/agent -- --filter web-ui-browser + verify: name: Verify (${{ matrix.os }}) runs-on: ${{ matrix.os }} diff --git a/CHANGELOG.md b/CHANGELOG.md index d21f1177..888db906 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,11 +8,30 @@ Categories: **Added** · **Changed** · **Fixed** · **Removed** · **Internal** ## [Unreleased] +### Fixed + +- **Cross-platform plugin process lifecycle**: active Worker RPC calls now keep their worker alive + until every pending promise settles, and exact-version plugin installation runs npm's JavaScript + CLI directly on Windows instead of failing through the `npm.cmd` shim. + ### Added -- **Zero-friction Web workspace**: `moss web` starts a local browser experience with streaming chat, - visible tool evidence, cancellation, and live redacted runtime capabilities without sending model - credentials to the browser. +- **Complete local Web workbench**: `moss web` now provides the responsive three-column Moss design + system, durable searchable sessions, resume/rename/export/delete/fork/rewind, cursor-based SSE, + rich conversation and tool evidence, bounded text/image attachments, generated-file downloads, + approval and question takeover, modes/Goal/Todo/queue/steering, jobs/workflows, trajectory and + verdict details, seven consistent settings sections, keyboard/reduced-motion support, and + fixed-viewport Playwright visual/accessibility gates. Provider credentials never enter the + browser, and every mutation requires same-Host, same-Origin, per-server CSRF authorization. +- **Hot-reloadable plugin platform and audited DeepSeek compatibility**: plugins can contribute + tools, Skills, experts, prompts, commands, providers, MCP presets, schema configuration, secrets, + and typed Web slots. Exact-version npm candidates use immutable roots; import/setup/calls run + behind terminable Worker RPC and active-call leases; enable/disable/config changes drain and + atomically retain the last-good composition. The Web settings page and CLI share add/remove/ + enable/disable/list/doctor behavior. Real DeepSeek Harness `package.json + cordis.patch.yml` + packages are inspected without executing Cordis: only an audited `SKILL.md` or explicit Moss + adapter data is mapped, native client/runtime ABI fails closed, and the attributed + `official:deepseek-harness` Skill/command/exact MCP preset remains opt-in. - **Durable Web task history**: Web tasks now receive ordered run identities, persisted tool evidence, honest completion/verification states, restart interruption recovery, and inspectable run-history endpoints. `npm run demo:capabilities` exercises a plugin, inline Skill, read-only diff --git a/docs/README.md b/docs/README.md index d0f6d384..9bb32003 100644 --- a/docs/README.md +++ b/docs/README.md @@ -28,6 +28,8 @@ the runtime implements that intent. - [`env-vars.md`](./env-vars.md): environment variables and ownership. - [`deepseek-harness-review.md`](./deepseek-harness-review.md): evidence-based plugin lifecycle lessons, staged Moss adoption, and explicit non-goals. +- [`deepseek-harness-plugin-audit.md`](./deepseek-harness-plugin-audit.md): revision-pinned ecosystem + audit, reusable declarative subset, license evidence, and fail-closed Cordis compatibility rules. ## Evaluation and evolution diff --git a/docs/deepseek-harness-plugin-audit.md b/docs/deepseek-harness-plugin-audit.md new file mode 100644 index 00000000..a40c3e7c --- /dev/null +++ b/docs/deepseek-harness-plugin-audit.md @@ -0,0 +1,56 @@ +# DeepSeek Harness plugin compatibility audit + +Audit date: 2026-08-18. Sources were read directly from GitHub. The official product reference is +[`deepseek-ai/deepseek-harness`](https://github.com/deepseek-ai/deepseek-harness); the ecosystem +inventory is +[`awesome-dsh-plugin/awesome-dsh-plugin`](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin). +At the audited revision the inventory contained 1,247 YAML entries. Its own warning is controlling: +listing means installability and maintenance checks, not a security review. + +## Compatibility rule + +An official DSH bundle targets Cordis, `cordis.patch.yml`, the `@deepseek-ai/dsh-*` host services, +and DSH client slots. Moss uses different runtime and Web contracts. It must therefore never execute +a DSH bundle as if the ABIs were interchangeable. + +Moss imports only declarative contributions that can be mapped without changing their meaning: + +- `SKILL.md` content becomes a Moss Skill with its contained source path recorded; license metadata + remains an audit input and is not synthesized when the package does not declare it. +- MCP server declarations become an explicit Moss MCP preset. +- data-only command templates and JSON Schema configuration become their Moss equivalents. +- native DSH runtime, Cordis service, and client-slot modules are rejected with a precise + compatibility diagnostic. They may be ported deliberately to Moss APIs after source review. + +Installation stays explicit. Imported third-party JavaScript remains trusted code, not sandboxed +code. Moss runs it behind a termination-bounded Worker/RPC lifecycle so import/setup hangs cannot +block the core host, but that boundary is deliberately not described as a permission sandbox. A +catalog entry never grants automatic execution. + +## Reviewed candidates + +| Candidate | License | Decision | Reason | +| ---------------------------------------------- | ------------ | -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `HenryZ838978/deepseek-harness` 0.2.0 | MIT | Bundled, disabled by default | Its protocol Skill is host-neutral. Moss also exposes its exact-version `@deepseek-harness/mcp@0.2.0` server as an MCP preset and a `deepseek-protocol` command. | +| `awesome-dsh-plugin/dsh-find-plugin` 0.3.6 | MIT | Do not execute; reuse catalog ideas | It requires `@deepseek-ai/cordis` and `@deepseek-ai/dsh-tools`. Moss plugin discovery must use Moss manifests and explicit installs. | +| `liuyuelintop/dsh-conversation-exporter` 0.2.0 | MIT | Native Moss implementation retained | The DSH client module depends on DSH runtime/conversation slots. Moss now provides server-owned Markdown export, avoiding a parallel exporter. | +| `Anionex/dsh-turn-rewind` 0.1.1 | BSD-3-Clause | Native Moss implementation retained | Its persistent ledger is strong prior art, but its Cordis and DSH client ABI is incompatible. Moss uses its existing conversation/file checkpoints and exposes non-destructive rewind. | +| `AcidGr/dsh-web-mobile-fix` 1.0.2 | MIT | Behavior ported into the Moss design system | The package is a DSH CSS overlay. Moss implements first-class session/details drawers, settings layout, focus containment, and 44 px controls instead of loading a parallel theme. | +| `GooodWei/context-vista` 0.1.0 | MIT | Behavior ported into Moss conversation/details UI | The package requires DSH commands, settings, locale, and conversation services. Moss renders its own durable usage/context projection and `/context` control. | +| `HongMing-Huang/dsh-file-upload` 0.4.2 | MIT | Capability ported behind Moss attachment contracts | Its document conversion is useful prior art, but the plugin depends on DSH Cordis credentials, filesystem, tools, runtime, and React 18. Moss keeps upload security and message persistence in its own host. | + +## Rejected loading patterns + +- A plugin declaring `dsh.bundle.patch` is not sufficient evidence of Moss compatibility. +- The compatibility reader audits the real `package.json + cordis.patch.yml` package shape. Optional + `moss.dsh-adapter.json` is a Moss-owned data adapter, not a native DSH manifest or ABI claim. +- A package with `dsh.client.inject` cannot be mounted into a Moss ShadowRoot by renaming imports. +- Theme or layout plugins cannot inject global CSS; Moss tokens and controlled components remain the + single design system. +- Marketplace or GitHub topic results are never auto-installed. +- Plugins without a verified license, exact source revision, manifest containment, side-effect + metadata, or successful candidate activation remain disabled. + +This audit is repeated before adding a bundled compatibility source. The completion gate verifies +that unsupported DSH bundles fail closed and that accepted Skill/MCP mappings are reversible and +owned by one plugin generation. diff --git a/docs/user-guide/23-runtime-plugins.md b/docs/user-guide/23-runtime-plugins.md index 71080632..5072a347 100644 --- a/docs/user-guide/23-runtime-plugins.md +++ b/docs/user-guide/23-runtime-plugins.md @@ -1,7 +1,7 @@ # Runtime plugins for embedding hosts `createMossRuntime()` accepts host-trusted `MossPlugin` objects. A plugin can -contribute tools, inline skills, read-only sub-agent experts, prompt layers, and +contribute tools, inline skills, read-only sub-agent experts, prompt layers, Web UI slots, and owned cleanup effects without mutating process-global registries. ```ts @@ -42,6 +42,53 @@ await runtime.plugins.unload('example/review'); await runtime.close(); ``` +## Explicit CLI installation + +Trusted plugins can also ship a `moss.plugin.json` v1 at their package root: + +```json +{ + "schemaVersion": 1, + "id": "example/review", + "version": "1.0.0", + "runtime": { "module": "./dist/plugin.js", "export": "plugin" }, + "web": { + "contributions": [ + { "id": "review-settings", "slot": "settings.plugin", "module": "./dist/settings.js" } + ] + }, + "configSchema": "./config.schema.json" +} +``` + +Install and inspect it explicitly: + +```bash +moss plugins add ./my-plugin +moss plugins add @example/moss-review@1.0.0 +moss plugins add official:deepseek-harness +moss plugins list +moss plugins doctor +moss plugins enable deepseek/harness +moss plugins disable example/review +``` + +New plugins are installed **disabled** so installation cannot silently activate executable code. +Local paths remain linked to their resolved root. npm sources require an exact semantic version, +install under the Moss config directory, and run with lifecycle scripts disabled. `doctor` performs +containment, manifest, schema, module-resolution, and compatibility checks without executing a +disabled plugin. `enable` performs candidate activation inside the bounded Worker/RPC lifecycle +before publishing the state change. Registry mutations use a cross-process lock so simultaneous +CLI/Web updates do not lose records. Manifest/runtime failures are reported and isolated so the core +workbench can still start. Web enable, disable, and remove drain active calls and publish a new +composition generation without restarting the server; a failed candidate or drain timeout keeps the +last-good generation active. + +`official:deepseek-harness` is an MIT-licensed compatibility Skill adapted from the independently +maintained `HenryZ838978/deepseek-harness` project. It adds protocol guidance for DeepSeek reasoning, +streamed parallel tools, token limits, cache stability, and endpoint selection; it does not install +credentials or claim that third-party JavaScript is sandboxed. + ## Fastest path: generate a validated tool Use the scaffold when starting a new host project: @@ -70,12 +117,19 @@ generated host and must not be committed. does not return prompt content, expert instructions, model routing, budgets, credentials, or arbitrary plugin configuration. - Executable plugins are trusted host code. Moss does not discover or execute - JavaScript from the workspace, and the plugin host is not a sandbox. + JavaScript automatically from the workspace, and the plugin host is not a sandbox. +- Web contributions use stable Moss slots and package-relative modules. Plugin UI must consume the + public `--moss-*` tokens. Advanced modules export `mount(root, context)` and receive an isolated + `ShadowRoot`; JSON Schema configuration remains the preferred default surface. +- A Web contribution must currently be one browser-ready ESM file. Bundle relative and bare imports + into that file before publishing; the v1 loopback host does not serve multi-file module graphs. -Dynamic unload while a plugin tool call is still active is not yet an HMR -contract. Hosts should close or drain agent work before unloading a plugin. A -future quiescence layer will gate new calls, abort/drain active leases, and then -release resources. +Dynamic unload gates new calls and waits for active Tool/provider/command leases. A timeout keeps the +old generation active; successful Web enable/disable publishes a composition event and browser asset +URLs carry that generation for cache busting. Installed JavaScript import/setup and later calls run +through a termination-bounded Worker/RPC boundary. This prevents sync/async startup hangs from +blocking the core host, but it is not a permission sandbox and installed code remains user-authorized +trusted code. ## Cordis direction diff --git a/docs/user-guide/24-web-ui.md b/docs/user-guide/24-web-ui.md index 829c3cf5..4bba9145 100644 --- a/docs/user-guide/24-web-ui.md +++ b/docs/user-guide/24-web-ui.md @@ -12,6 +12,12 @@ moss web Open `http://127.0.0.1:3080`. To select another port, run `moss web 4080`. +The React workbench uses one responsive three-column layout: sessions on the left, conversation in +the center, and runtime/tool details on the right. Narrow desktop and tablet widths collapse the +detail rail and then the session rail instead of switching to a separately styled interface. Light +and dark themes share the same `--moss-*` design tokens, focus treatment, state colors, and reduced +motion behavior. + ## What the screen proves - **Timeline** streams the assistant response instead of waiting for the whole turn. @@ -23,9 +29,25 @@ Open `http://127.0.0.1:3080`. To select another port, run `moss web 4080`. count, and verification instead of treating a fluent model answer as proof. - Selecting a recent run opens its durable event timeline. The list is keyboard accessible, and the detail endpoint supports sequence cursors for incremental recovery. - -The server binds to loopback by default, rejects non-local mutation origins, sets a restrictive -Content Security Policy, and never sends provider configuration or credentials to the browser. +- **Settings** exposes Runtime, Models, Permissions, and Plugins using the same controls and density + as the conversation surface. Credential values are process-owned and are never returned. +- **Sessions** can be searched, resumed, renamed, exported, explicitly deleted, forked, or rewound + into a new non-destructive branch. Draft, scroll, details, and panel state are retained per task. +- **Interaction controls** present approval and user questions in the composer, and expose the same + plan/default/accept-edits policy, Goal, Todo, queue, steering, workflow, job, model, Skill, and + sub-agent inventories owned by the configured runtime. +- **Attachments** upload bounded UTF-8 text or signature-checked images into an instance-local + store. Generated workspace files are copied through a realpath-contained artifact endpoint before + download; a browser-supplied path is never opened as an arbitrary host path. +- **Plugins** can be installed, diagnosed, configured, enabled, disabled, and removed without + restarting the Web server. UI modules receive typed slot ownership and the Moss controlled + component entry instead of defining a parallel theme. + +The server binds to loopback by default. Every mutation requires the exact Host, same Origin, and a +random per-server CSRF token obtained by the bootstrapped same-origin client. It sets a restrictive +Content Security Policy and never sends provider configuration or credential values to the browser. +API keys and plugin secrets are write/delete-only; status responses reveal only whether a value is +configured. ## Long tasks @@ -59,6 +81,12 @@ non-zero if any contribution was not used or if the final answer was not grounde ## Current limits -- Durable session browsing and fork/rewind controls are planned rather than silently approximated. -- Plugin hot reload is not exposed until active tool-call leases and quiescent unload are complete. - Remote binding and multi-user authentication are intentionally absent from this local-first slice. +- Third-party JavaScript plugins are explicitly trusted code. Worker containment makes a stuck + import/setup/call terminable, but it is not a permissions sandbox; a plugin can still use its Node + process authority and an advanced browser module runs in the same page origin. +- Native DeepSeek Harness Cordis runtime/client modules are not ABI-compatible with Moss. Moss can + import only an audited package's declarative `SKILL.md` or explicit Moss adapter data; unsupported + client slots fail closed. +- The previous compact Web surface remains available at `/?legacy=1` for one release cycle as a + rollback path. It uses the current CSRF/session APIs and will be removed after that window. diff --git a/openspec/changes/add-zero-friction-web-ui/completion-matrix.md b/openspec/changes/add-zero-friction-web-ui/completion-matrix.md new file mode 100644 index 00000000..7d734dec --- /dev/null +++ b/openspec/changes/add-zero-friction-web-ui/completion-matrix.md @@ -0,0 +1,126 @@ +# Moss Web completion matrix + +This matrix is the acceptance source for the DeepSeek Harness parity change. The reference is +`deepseek-ai/deepseek-harness` at commit `47f943859bef60e4160492346772ded9b24f765a`. Reference source +and screenshots are evidence only; Moss keeps its own name, logo, copy, safety explanations, and +implementation. + +The only accepted visible differences are Moss branding, Moss-only capabilities, and safety text +required by Moss. Every other difference needs an explicit entry in this file before release. + +## Reference views + +The visual suite owns three fixed viewports: desktop `1440x960`, narrow desktop `1120x800`, and +tablet `820x1180`. The complete suite covers the empty workbench, active run, expanded tool, +approval, plan, settings, plugin inventory, responsive conversation, component gallery, and modal; +the home shell is captured at all three widths. Reference images live under +`packages/moss-agent/test/visual/reference/deepseek-47f9438/`; Moss baselines live beside them under +`packages/moss-agent/test/visual/baseline/`. + +## Completion rules + +- `implemented` means the production browser path is wired to the real runtime. +- `tested` means a focused unit or HTTP test proves the behavior and a browser test proves the user + path where one exists. +- `verified` means the fixed-viewport visual, keyboard, accessibility, Windows, security, and + isolation gates required by that row pass. +- A row is complete only when all three columns are checked. A green CI run does not waive an + unchecked row. + +## Plan 0 — visual baseline + +| Requirement | Implemented | Tested | Verified | +| ----------------------------------------------------------------------------------- | ----------- | ------ | -------- | +| Reference commit and provenance are pinned | [x] | [x] | [x] | +| Desktop, narrow desktop, and tablet reference captures | [x] | [x] | [x] | +| Empty, running, tool, approval, plan, settings, and plugin fixtures | [x] | [x] | [x] | +| Layout, component, interaction, state, motion, responsive, and keyboard diff report | [x] | [x] | [x] | + +## Plan 1 — design system and shell + +| Requirement | Implemented | Tested | Verified | +| ------------------------------------------------------------------------------------------------------- | ----------- | ------ | -------- | +| React and Vite remain inside `@rdk-moss/agent` | [x] | [x] | [x] | +| Token-owned color, typography, spacing, radius, shadow, layer, width, and state | [x] | [x] | [x] | +| Controlled Button, Input, Menu, Modal, Disclosure, Card, Tabs, Toast, Tooltip, Code, Diff, and Terminal | [x] | [x] | [x] | +| Resizable three-column frame, independent collapse, concession, and persisted layout | [x] | [x] | [x] | +| Mobile session and details drawers | [x] | [x] | [x] | +| Focus, scrollbars, empty, skeleton, offline, toast, and reduced-motion states | [x] | [x] | [x] | +| Internal component gallery route | [x] | [x] | [x] | + +## Plan 2 — sessions and workspaces + +| Requirement | Implemented | Tested | Verified | +| --------------------------------------------------------------------------------------- | ----------- | ------ | -------- | +| Workspace selector, session history/search/new/status/settings | [x] | [x] | [x] | +| Resume, rename, Markdown export, confirmed delete, fork, and non-destructive rewind | [x] | [x] | [x] | +| Per-session draft, scroll, selected detail, and panel state | [x] | [x] | [x] | +| Empty workspace, no session, loading failure, damaged history, and interrupted recovery | [x] | [x] | [x] | + +## Plan 3 — conversation + +| Requirement | Implemented | Tested | Verified | +| ---------------------------------------------------------------------------------- | ----------- | ------ | -------- | +| User, assistant, reasoning, retry, compaction, error, usage, and context meter | [x] | [x] | [x] | +| Markdown, code, table, JSON, diff, terminal, read, edit, search, and Web renderers | [x] | [x] | [x] | +| Tool tree, running/success/failure states, summary, and details | [x] | [x] | [x] | +| Attachments, images, produced files, download, copy, and feedback | [x] | [x] | [x] | +| Durable cursor SSE reconnect and refresh attachment to an active run | [x] | [x] | [x] | + +## Plan 4 — interaction controls + +| Requirement | Implemented | Tested | Verified | +| ----------------------------------------------------------- | ----------- | ------ | -------- | +| Approval and user-question composer takeover | [x] | [x] | [x] | +| Permission preset and plan/default/accept-edits modes | [x] | [x] | [x] | +| Todo, Goal, Queue, and Steering | [x] | [x] | [x] | +| Slash commands, Skill/Subagent mention, and model selection | [x] | [x] | [x] | +| Background jobs and workflow runs | [x] | [x] | [x] | +| Trajectory, execution evidence, and completion-gate verdict | [x] | [x] | [x] | + +## Plan 5 — settings + +| Requirement | Implemented | Tested | Verified | +| ------------------------------------------------------------------------- | ----------- | ------ | -------- | +| General, Models, Permissions, Skills, MCP, Plugins, and Runtime Inventory | [x] | [x] | [x] | +| Shared label, help, dirty, validation, save, and error behavior | [x] | [x] | [x] | +| Write/delete-only API keys and plugin secrets | [x] | [x] | [x] | +| CLI configuration parsing, defaults, and validation are reused | [x] | [x] | [x] | + +## Plan 6 — plugins + +| Requirement | Implemented | Tested | Verified | +| -------------------------------------------------------------------------------------------------- | ----------- | ------ | -------- | +| Manifest v1 with explicit local and exact-version npm install | [x] | [x] | [x] | +| CLI and Web add/remove/enable/disable/list/doctor | [x] | [x] | [x] | +| Tool, Skill, Expert, Prompt, Command, Provider, MCP preset, config, and Web contributions | [x] | [x] | [x] | +| Navigation, session, message, tool, composer, details, and settings slots receive typed owner data | [x] | [x] | [x] | +| JSON Schema settings use Moss controls; advanced UI is token scoped | [x] | [x] | [x] | +| Failure isolation, last-good composition, and core startup survival | [x] | [x] | [x] | + +## Plan 7 — reload and gates + +| Requirement | Implemented | Tested | Verified | +| ------------------------------------------------------------ | ----------- | ------ | -------- | +| Active-call leases and quiescent unload | [x] | [x] | [x] | +| Runtime enable/disable/update and cache-busted client reload | [x] | [x] | [x] | +| Fixed-viewport visual regression gate | [x] | [x] | [x] | +| Built-in, official-plugin, and plugin-template token gate | [x] | [x] | [x] | +| Preview/default switch and one-release legacy rollback | [x] | [x] | [x] | + +## Repository release gate + +Completion additionally requires Playwright user journeys, keyboard and accessibility checks, +HTTP/SSE/plugin lifecycle/security/Windows/dual-instance coverage, focused tests for each slice, a +real browser run, `npm run check`, `npm run verify`, updated API reports and user documentation, and +an `Unreleased` changelog entry. The PR may only be called complete after every row above is checked +and the hosted CI result for the final pushed commit is green. + +## Local verification evidence + +- Plugin lifecycle and DSH compatibility: 10 focused test files passed. +- Web services, security, public API, capabilities, and Playwright: 13 focused test files passed. +- Real smoke: the bundled `official:deepseek-harness` source installed, enabled, passed doctor, + and listed from an isolated config directory; the packaged CLI/PTY smoke passed. +- Full gate: `npm run verify` passed, including 9 core files, 295 agent files, 9 scaffold tests, + API reports, TypeDoc, token/maintainability gates, and browser visual/accessibility coverage. diff --git a/openspec/changes/add-zero-friction-web-ui/design.md b/openspec/changes/add-zero-friction-web-ui/design.md index 86330c8c..10164cd4 100644 --- a/openspec/changes/add-zero-friction-web-ui/design.md +++ b/openspec/changes/add-zero-friction-web-ui/design.md @@ -1,13 +1,22 @@ # Design -The first slice is a thin host adapter over the existing `MossAgent`; it does not create another -agent loop. Node's built-in HTTP server owns loopback transport, while static browser assets remain -dependency-free and credential-free. A newline-delimited event stream avoids a new WebSocket stack -and works with `fetch()` cancellation. - -The UI follows the useful product ideas in DeepSeek Harness—session-first navigation, visible tool -activity, interruptibility, and progressive disclosure—without copying its implementation. Moss -adds a live capability rail so users can understand which plugin tools are truly available. +The host remains a thin adapter over the existing `MossAgent`; it does not create another agent +loop. Node's built-in HTTP server owns loopback transport. React and Vite build deterministic browser +assets inside `@rdk-moss/agent`, while the browser bundle remains credential-free. Durable browser +projection uses SSE with monotonic cursors and `Last-Event-ID`; POST requests create work while GET +streams reconnect to it. + +The UI follows the useful product ideas in DeepSeek Harness—session-first navigation, a responsive +three-column frame, visible tool activity, interruptibility, settings, and progressive +disclosure—without copying trademarks, source, or protected assets. A central `--moss-*` token +system owns color, space, radii, widths, focus, state, and reduced-motion behavior so built-in and +plugin surfaces do not form parallel themes. + +Explicitly installed trusted plugins use `moss.plugin.json` v1 and a config-owned registry. Local +paths and npm package specs are user initiated. Manifest/import failures are isolated from core +startup. A composition generation is prepared and validated before activation. Active-call leases +close admission, drain in-flight calls, and retain the last-good generation if activation or unload +cannot finish safely. Web modules reload against the same generation with cache-busted URLs. The next simplification gate is evidence based: consolidate ACP and Web event projection only after both transports have contract tests, then measure deleted lines and protocol drift. Documentation is diff --git a/openspec/changes/add-zero-friction-web-ui/proposal.md b/openspec/changes/add-zero-friction-web-ui/proposal.md index c83bcc0c..120ace02 100644 --- a/openspec/changes/add-zero-friction-web-ui/proposal.md +++ b/openspec/changes/add-zero-friction-web-ui/proposal.md @@ -3,19 +3,25 @@ ## User outcome A user with a configured Moss model can run `moss web`, open one local URL, and complete a long -agent task while seeing streamed reasoning status, tool calls, evidence, cancellation, and the live -plugin/tool inventory. The browser never receives provider credentials. +agent task in a consistent three-column React workbench while seeing streamed reasoning status, +tool calls, evidence, cancellation, sessions, settings, and the live plugin/tool inventory. The +browser never receives provider credentials. + +The complete acceptance scope is enumerated in +[`completion-matrix.md`](completion-matrix.md); a smaller green test surface is not completion. ## Non-goals - Copying DeepSeek trademarks, source code, or exact visual assets. - Exposing the server beyond loopback by default. - Replacing the TUI, ACP, or embedding API. -- Claiming HMR before active tool-call quiescence exists. +- Running third-party JavaScript as if it were a security sandbox. Explicitly installed plugins are + trusted code, but failures remain isolated from the core workbench. ## Success evidence -- Built-artifact HTTP integration covers boot, session creation, streaming, tools, cancellation, - invalid input, and loopback binding. -- A browser screenshot verifies the usable visual hierarchy at desktop width. +- Built-artifact HTTP integration covers the full session, interaction, settings, plugin, resumable + streaming, cancellation, invalid-input, security, and loopback contracts. +- A real browser contract verifies the three-column hierarchy, design tokens, interaction path, and + responsive layout at fixed viewport widths. - The existing long-horizon harness and full repository verification remain green. diff --git a/openspec/changes/add-zero-friction-web-ui/tasks.md b/openspec/changes/add-zero-friction-web-ui/tasks.md index 8887253b..35ab1de0 100644 --- a/openspec/changes/add-zero-friction-web-ui/tasks.md +++ b/openspec/changes/add-zero-friction-web-ui/tasks.md @@ -1,11 +1,17 @@ # Tasks - [x] Specify loopback, credential, streaming, cancellation, and evidence boundaries. -- [x] Add the dependency-free Web host and responsive browser client. +- [x] Replace the inline client with an in-package React/Vite workbench and centralized design tokens. - [x] Add `moss web [port]` with actionable startup output. - [x] Exercise built artifacts through real HTTP streaming and tool events. - [x] Add a task-oriented user guide and navigation entry. - [ ] Consolidate ACP/Web event projection after both protocol contracts are stable. -- [ ] Add durable browser session browsing and fork/rewind controls. +- [x] Add durable browser session browsing, search, draft composition, and run-detail restoration. +- [ ] Add fork, rename, export, delete confirmation, and non-destructive rewind controls. +- [x] Add `moss.plugin.json` v1, explicit local/npm installation, CLI lifecycle commands, startup + isolation, Web inventory, and restart-gated enable/disable/remove. +- [x] Add stable Web slots, theme tokens, and scoped contribution metadata to the beta public API. +- [x] Mount explicitly installed advanced plugin UI modules inside token-inheriting ShadowRoots. +- [x] Exercise the bundled client through a real Chromium-compatible browser. - [ ] Add active-call leases before plugin reload controls. - [ ] Run a multi-release documentation deletion audit based on search and support evidence. diff --git a/openspec/changes/add-zero-friction-web-ui/visual-parity-audit.md b/openspec/changes/add-zero-friction-web-ui/visual-parity-audit.md new file mode 100644 index 00000000..64d83b75 --- /dev/null +++ b/openspec/changes/add-zero-friction-web-ui/visual-parity-audit.md @@ -0,0 +1,57 @@ +# Moss Web visual parity audit + +Reference: `deepseek-ai/deepseek-harness` commit +`47f943859bef60e4160492346772ded9b24f765a`. The reference was built from source and captured through +its fixture mode. Moss retains its own brand, copy, safety explanations, and implementation. + +## Fixed viewports and fixtures + +| Fixture | Desktop 1440×960 | Narrow 1120×800 | Tablet 820×1180 | Moss assertion | +| --------------- | ---------------------- | ------------------------------------------------ | ------------------------------------------------ | ------------------------------------------------------------- | +| Empty workbench | `desktop-home.png` | `narrow-home.png` | `tablet-home.png` | Three columns concede in the order details → sidebar → center | +| Active run | `desktop-running.png` | responsive behavior covered by shell fixture | responsive behavior covered by shell fixture | Streaming and interruption remain visible after reconnect | +| Expanded tool | `desktop-tool.png` | responsive behavior covered by shell fixture | responsive behavior covered by shell fixture | Summary stays in the timeline; full evidence opens in details | +| Approval | `desktop-approval.png` | composer takeover covered by interaction fixture | composer takeover covered by interaction fixture | Focus enters the decision panel and returns to composer | +| Plan | `desktop-plan.png` | responsive behavior covered by shell fixture | responsive behavior covered by shell fixture | Mode, todo state, and current step use shared status tokens | +| Settings | `desktop-settings.png` | settings navigation covered by keyboard fixture | settings navigation uses a drawer | Forms share label, help, dirty, validation, and save states | +| Plugins | `desktop-plugins.png` | plugin cards use the narrow grid | plugin cards use the single-column grid | Built-in and extension UI consume the same design system | + +The reference files live in `packages/moss-agent/test/visual/reference/deepseek-47f9438/`. Generated +Moss baselines and pixel diffs live in sibling `baseline/` and `diff/` directories. Reference images +are evidence for composition and interaction, not golden pixels: product branding and text differ, so +the automated gate compares Moss against reviewed Moss baselines while browser assertions compare +the structural rules below against the reference. + +The reviewed Moss set is `desktop-home`, `desktop-running`, `desktop-tool`, `desktop-approval`, +`desktop-plan`, `desktop-settings`, `desktop-plugins`, `narrow-home`, `narrow-conversation`, +`tablet-home`, `tablet-gallery`, and `tablet-gallery-dialog`. The Playwright journey reaches each +state through the real loopback HTTP/SSE, approval, question, attachment, plugin, and layout paths. + +## Parity matrix + +| Surface | Reference behavior | Moss contract | Allowed difference | +| ------------------- | ------------------------------------------------------------------ | -------------------------------------------------------------------------------- | --------------------------------------- | +| Layout | Persistent session rail, flexible conversation, contextual details | `AppFrame` owns widths, resize handles, collapse, concession, and mobile drawers | Moss product mark | +| Typography | Compact sans-serif hierarchy and monospace evidence | Public typography tokens; no component-local font scale | Installed platform font fallback | +| Density | Tight navigation and tool evidence, roomier reading column | Public spacing/control-height tokens | Safety copy may add one explanatory row | +| Shape and elevation | Low-radius controls, bordered cards, restrained overlays | Public radius, border, shadow, and layer tokens | None | +| Color | Neutral work surface with semantic status accents | Theme and semantic-state tokens, including focus and offline | Moss accent hue | +| Navigation | Sidebar tasks/settings and contextual details | Same keyboard order, selected semantics, and responsive concession | Moss-only destinations | +| Conversation | User/assistant blocks, reasoning disclosure, tool tree, usage | Typed timeline nodes and controlled renderers | Moss tool/provider names | +| Composer | Prompt, attachments, modes, pending interaction takeover | One composer state machine; approval/question prevents accidental send | Required permission explanation | +| Settings | Section navigation and consistent forms | Seven stable sections and schema-generated plugin fields | Moss runtime inventory fields | +| Plugin UI | Contributions appear as native surfaces | Stable slots, owner metadata, token-scoped mount root, controlled schema forms | Unsupported Cordis ABI is rejected | +| Feedback | Hover/focus/selected/disabled/loading/streaming/result states | Central component/state tokens and ARIA live regions | None | +| Motion | Short transitions that communicate state | Motion tokens plus `prefers-reduced-motion` suppression | None | +| Keyboard | Logical tab order, visible focus, Escape dismissal | Focus trap/restore, disclosure semantics, skip link, keyboard resize | None | + +## Release checks + +- Component gallery exercises every controlled component and semantic state. +- Playwright executes fixed-viewport screenshots, keyboard-only journeys, responsive drawers, + reconnect, approval/question takeover, settings validation, and plugin reload. +- Accessibility checks reject serious/critical violations and assert status announcements. +- The token scanner rejects hard-coded theme values in built-in client code, bundled plugin UI, and + generated plugin templates. +- Any intentional divergence must be documented in this file and reviewed before its Moss baseline + is updated. diff --git a/package-lock.json b/package-lock.json index 31910f22..15e4cda4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -54,6 +54,19 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/@axe-core/playwright": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.13.0.tgz", + "integrity": "sha512-6YLx+kxXu5GJceG4ozFg+33a2EMTdjYwWGloJ3sb9Kta5pp+ZNS53uxGVog5JetIY8s++P5UrtX+cri+u0VAVg==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "axe-core": "~4.13.0" + }, + "peerDependencies": { + "playwright-core": ">= 1.0.0" + } + }, "node_modules/@colors/colors": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", @@ -64,6 +77,448 @@ "node": ">=0.1.90" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -473,6 +928,26 @@ "integrity": "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==", "license": "BSD-2-Clause" }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, "node_modules/@opentelemetry/api": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", @@ -1033,37 +1508,426 @@ "@protobufjs/aspromise": "^1.1.1" } }, - "node_modules/@protobufjs/float": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", - "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", - "license": "BSD-3-Clause" + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz", + "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", + "license": "BSD-3-Clause" + }, + "node_modules/@rdk-moss/agent": { + "resolved": "packages/moss-agent", + "link": true + }, + "node_modules/@rdk-moss/core": { + "resolved": "packages/moss", + "link": true + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz", + "integrity": "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz", + "integrity": "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz", + "integrity": "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz", + "integrity": "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz", + "integrity": "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz", + "integrity": "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz", + "integrity": "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz", + "integrity": "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz", + "integrity": "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz", + "integrity": "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz", + "integrity": "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz", + "integrity": "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz", + "integrity": "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz", + "integrity": "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz", + "integrity": "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz", + "integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz", + "integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz", + "integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz", + "integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz", + "integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] }, - "node_modules/@protobufjs/path": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", - "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", - "license": "BSD-3-Clause" + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz", + "integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] }, - "node_modules/@protobufjs/pool": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", - "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", - "license": "BSD-3-Clause" + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz", + "integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] }, - "node_modules/@protobufjs/utf8": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz", - "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", - "license": "BSD-3-Clause" + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz", + "integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] }, - "node_modules/@rdk-moss/agent": { - "resolved": "packages/moss-agent", - "link": true + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz", + "integrity": "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] }, - "node_modules/@rdk-moss/core": { - "resolved": "packages/moss", - "link": true + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz", + "integrity": "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] }, "node_modules/@rushstack/node-core-library": { "version": "5.23.3", @@ -1340,6 +2204,26 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/pngjs": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/@types/pngjs/-/pngjs-6.0.5.tgz", + "integrity": "sha512-0k5eKfrA83JOZPppLtS2C7OUtyNAl2wKNxfyYl9Q5g9lPkgBl/9hNyAu6HuEH2J4XmIv2znEpkDd0SaZVxW6iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/proper-lockfile": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@types/proper-lockfile/-/proper-lockfile-4.1.4.tgz", + "integrity": "sha512-uo2ABllncSqg9F1D4nugVl9v93RmjxF6LJzQLMLDdPaXCUIDPeOJ21Gbqi43xNKzBi/WQ0Q0dICqufzQbMjipQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/retry": "*" + } + }, "node_modules/@types/react": { "version": "19.2.0", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.0.tgz", @@ -1350,6 +2234,23 @@ "csstype": "^3.0.2" } }, + "node_modules/@types/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/retry": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.5.tgz", + "integrity": "sha512-3xSjTp3v03X/lSQLkczaN9UIEwJMoMCA1+Nb5HfbJEQWogdeQIyVtTvxPXDQjZ5zws8rFQfVfRdz03ARihPJgw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/turndown": { "version": "5.0.6", "resolved": "https://registry.npmjs.org/@types/turndown/-/turndown-5.0.6.tgz", @@ -1747,6 +2648,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/axe-core": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.13.0.tgz", + "integrity": "sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -2171,6 +3082,48 @@ "benchmarks" ] }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -2698,6 +3651,21 @@ "node": ">=14.14" } }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -2759,7 +3727,6 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, "license": "ISC" }, "node_modules/has-flag": { @@ -3323,6 +4290,25 @@ "thenify-all": "^1.0.0" } }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -3495,6 +4481,105 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pixelmatch": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/pixelmatch/-/pixelmatch-7.2.0.tgz", + "integrity": "sha512-xhcb4yHu9sM/G7foGzoLtXYcC0zHEaOXXjRKhGup0fw78Nf2Tkiapv4EQyMzrbcmQPsllAI7DbFY2UT7PlI9Pg==", + "dev": true, + "license": "ISC", + "dependencies": { + "pngjs": "^7.0.0" + }, + "bin": { + "pixelmatch": "bin/pixelmatch" + } + }, + "node_modules/playwright": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/pngjs": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz", + "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.19.0" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -3521,6 +4606,17 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, "node_modules/protobufjs": { "version": "7.6.5", "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.5.tgz", @@ -3687,14 +4783,26 @@ } }, "node_modules/react": { - "version": "19.2.6", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", - "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", "license": "MIT", "engines": { "node": ">=0.10.0" } }, + "node_modules/react-dom": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.8" + } + }, "node_modules/react-reconciler": { "version": "0.33.0", "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.33.0.tgz", @@ -3780,6 +4888,61 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/rollup": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz", + "integrity": "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.62.4", + "@rollup/rollup-android-arm64": "4.62.4", + "@rollup/rollup-darwin-arm64": "4.62.4", + "@rollup/rollup-darwin-x64": "4.62.4", + "@rollup/rollup-freebsd-arm64": "4.62.4", + "@rollup/rollup-freebsd-x64": "4.62.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.4", + "@rollup/rollup-linux-arm-musleabihf": "4.62.4", + "@rollup/rollup-linux-arm64-gnu": "4.62.4", + "@rollup/rollup-linux-arm64-musl": "4.62.4", + "@rollup/rollup-linux-loong64-gnu": "4.62.4", + "@rollup/rollup-linux-loong64-musl": "4.62.4", + "@rollup/rollup-linux-ppc64-gnu": "4.62.4", + "@rollup/rollup-linux-ppc64-musl": "4.62.4", + "@rollup/rollup-linux-riscv64-gnu": "4.62.4", + "@rollup/rollup-linux-riscv64-musl": "4.62.4", + "@rollup/rollup-linux-s390x-gnu": "4.62.4", + "@rollup/rollup-linux-x64-gnu": "4.62.4", + "@rollup/rollup-linux-x64-musl": "4.62.4", + "@rollup/rollup-openbsd-x64": "4.62.4", + "@rollup/rollup-openharmony-arm64": "4.62.4", + "@rollup/rollup-win32-arm64-msvc": "4.62.4", + "@rollup/rollup-win32-ia32-msvc": "4.62.4", + "@rollup/rollup-win32-x64-gnu": "4.62.4", + "@rollup/rollup-win32-x64-msvc": "4.62.4", + "fsevents": "~2.3.2" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -3878,6 +5041,16 @@ "node": ">=0.10.0" } }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", @@ -4244,6 +5417,81 @@ "punycode": "^2.1.0" } }, + "node_modules/vite": { + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, "node_modules/webdriver-bidi-protocol": { "version": "0.4.2", "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.2.tgz", @@ -4616,22 +5864,33 @@ "marked-terminal": "^7.0.0", "micromatch": "^4.0.8", "picocolors": "^1.1.1", + "proper-lockfile": "4.1.2", "puppeteer-core": "^25.1.0", "react": "^19.2.6", + "react-dom": "^19.2.8", "string-width": "^8.2.1", "turndown": "^7.2.0", - "undici": "^7.29.0" + "undici": "^7.29.0", + "yaml": "^2.9.0" }, "bin": { "moss": "dist/cli.js" }, "devDependencies": { + "@axe-core/playwright": "^4.13.0", "@types/marked-terminal": "^6.1.1", "@types/micromatch": "^4.0.10", + "@types/pngjs": "^6.0.5", + "@types/proper-lockfile": "4.1.4", "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.4", "@types/turndown": "^5.0.5", "ink-testing-library": "^4.0.0", - "typescript": "^5.7.3" + "pixelmatch": "^7.2.0", + "playwright": "^1.62.1", + "pngjs": "^7.0.0", + "typescript": "^5.7.3", + "vite": "^7.3.6" }, "engines": { "node": ">=22.16.0" diff --git a/package.json b/package.json index 2734f814..02dc3c3e 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,8 @@ "check:pr-title": "node scripts/check-pr-title.mjs", "check:agent-harness-benchmark": "node scripts/check-agent-harness-benchmark.mjs", "check:leaderboard-readiness": "node scripts/check-leaderboard-readiness.mjs", - "check": "npm run format:check && npm run lint && npm run typecheck && npm run check:boundaries && npm run check:hygiene && npm run check:vendored && npm run check:maintainability && npm run test:standards", + "check:moss-web-tokens": "node scripts/check-moss-web-tokens.mjs", + "check": "npm run format:check && npm run lint && npm run typecheck && npm run check:boundaries && npm run check:hygiene && npm run check:vendored && npm run check:moss-web-tokens && npm run check:maintainability && npm run test:standards", "api:check": "npm run build && node scripts/check-api.mjs", "api:update": "npm run build && node scripts/check-api.mjs --update", "smoke:moss-cli": "node scripts/smoke-moss-cli.mjs", diff --git a/packages/moss-agent/API.md b/packages/moss-agent/API.md index 51537a82..366568a3 100644 --- a/packages/moss-agent/API.md +++ b/packages/moss-agent/API.md @@ -8,7 +8,8 @@ The source of truth is: 2. Root exports in `src/index.ts` 3. Subpath exports in `src/*/index.ts` -Anything a host application builds on top (HTTP servers, frontends, desktop shells, SSH bridges, fleet dashboards, etc.) is the host's concern and is **not** part of the stable API of this package. +Host applications still own custom servers and shells. The package-provided loopback workbench and +its beta extension contracts are the narrow exception documented under `@rdk-moss/agent/web`. **Agent harness:** The exports below are the **harness** around your LLM (tools, context, safety, sessions, retries) — not a full product. @@ -37,6 +38,26 @@ npm install @rdk-moss/agent @rdk-moss/core | `@rdk-moss/agent/tools/builtin` | Built-in filesystem/shell/search tools | | `@rdk-moss/agent/mesh` | Multi-agent mesh (HTTP + LAN discovery) | | `@rdk-moss/agent/mcp` | Model Context Protocol client for external tool servers | +| `@rdk-moss/agent/runtime` | Composed runtime and trusted installed-plugin registry | +| `@rdk-moss/agent/web` | Beta loopback host, DTOs, Web slots, and theme-token contract | + +### Web and installed-plugin contracts (beta) + +`@rdk-moss/agent/web` exports `startMossWebServer`, the loopback server options/handle, bootstrap +and session DTOs, `MOSS_WEB_SLOTS`, and `MOSS_WEB_THEME_TOKENS`. Plugins register UI metadata only +through `MossPluginContext.registerWebContribution()` and package-relative modules. Built-in and +plugin surfaces consume the public `--moss-*` variables rather than introducing a parallel theme. + +`@rdk-moss/agent/runtime` exports `InstalledPluginRegistry` and the `MossPluginManifestV1` contract +used by `moss.plugin.json`. These APIs execute explicitly trusted local/npm JavaScript and are not a +sandbox. CLI mutations persist for the next runtime start, while Moss Web applies +enable/disable/remove to the live instance. Tool/provider/command calls use active-call leases; +quiescent unload retains the last-good +composition on timeout, and Moss Web publishes the resulting generation for hot reload. + +Plugin-owned tool names cannot be replaced accidentally through `ToolRegistry.register()`. A host +that intentionally transfers ownership must call the beta `ToolRegistry.replace()` method; +plugin-scoped disposal remains identity-safe and cannot remove the replacement. ### Internal runtime helpers diff --git a/packages/moss-agent/CHANGELOG.md b/packages/moss-agent/CHANGELOG.md index 40ff5b5a..b7cf76f9 100644 --- a/packages/moss-agent/CHANGELOG.md +++ b/packages/moss-agent/CHANGELOG.md @@ -7,8 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Active isolated-plugin RPC calls now keep their Worker alive until pending promises settle, and + exact-version plugin installation runs npm's JavaScript CLI directly on Windows. + ### Added +- Replaced the inline Web client with an in-package React/Vite three-column workbench, centralized + `--moss-*` design tokens and controlled components, responsive drawers, session search and full + history actions, cursor SSE recovery, rich messages/tools, bounded attachments, interaction and + execution controls, seven unified settings sections, and a one-release `/?legacy=1` rollback. +- Added beta `@rdk-moss/agent/web` contracts for stable typed Web slots/owners, theme tokens, + bootstrap/session DTOs, stream events, and ShadowRoot-scoped plugin mounting through a + same-origin controlled-component browser entry. +- Added `moss.plugin.json` v1 plus CLI and Web add/remove/enable/disable/list/doctor/config controls + for explicit trusted local/exact npm plugins. Terminable Worker RPC, active-call leases, + quiescent hot reload, immutable npm candidates, last-good rollback, redacted write-only secrets, + and plugin-scoped Web modules isolate lifecycle failures without claiming a security sandbox. +- Added the opt-in `official:deepseek-harness` plugin, an attributed MIT adaptation that contributes + DeepSeek protocol guidance, a command, and an exact MCP preset. A data-only compatibility audit + understands real DSH `package.json + cordis.patch.yml` packages, maps only `SKILL.md` or explicit + Moss adapter data, and rejects native Cordis client ABI. + - `moss web` now records ordered task runs with tool evidence, persisted restart recovery, and a separate verification state; the browser shows recent run status without claiming that ordinary model output is verified. @@ -126,6 +147,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Plugin installs now start disabled; npm sources require exact versions and suppress lifecycle + scripts, manifest assets cannot escape through symlinks, `doctor` runs setup validation, tool + ownership rejects implicit replacement, registry changes are cross-process serialized, and Web + UI modules are frozen from active plugins for the current restart-gated server lifecycle. + - Expert contributors now install atomically, return idempotent disposers, can be supplied by capability packs, and expose a redacted catalog to the lead agent. diff --git a/packages/moss-agent/EXTENDING.md b/packages/moss-agent/EXTENDING.md index e58c3071..df69e490 100644 --- a/packages/moss-agent/EXTENDING.md +++ b/packages/moss-agent/EXTENDING.md @@ -13,6 +13,7 @@ Moss is a cross-platform agent harness. The product ships a capable default agen | **Model** | Which LLM + context window | `moss config` / `/model` | Anyone | `~/.config/moss/config.json` | | **Automation** | Hands-off flows | `/goal` · `/loop` | Anyone (in-session) | TUI | | **Embedding** | moss as a library | `MossAgent` / `streamChat` / hooks | Embedder | code | +| **Plugins** | Reversible runtime and Web contributions | `moss.plugin.json` / `MossPlugin` | Trusted user / Embedder | local path · npm · code | --- @@ -152,6 +153,19 @@ workspace JavaScript or treat plugins as a sandbox. See [`docs/user-guide/23-runtime-plugins.md`](../../docs/user-guide/23-runtime-plugins.md) and the `adopt-cordis-plugin-spine` OpenSpec change. +The CLI also supports explicit trusted-plugin installation with +`moss plugins add `. Each package declares +`moss.plugin.json` v1; npm sources require an exact version and are installed without lifecycle +scripts. New entries stay disabled until an explicit `enable`. `list`, static-validation `doctor`, +`enable`, `disable`, and `remove` share the same persisted registry shown by Moss Web. The bundled +`official:deepseek-harness` source demonstrates an attributed Skill, command, and exact-version MCP +preset. Browser contributions register only into stable slots, use the `@rdk-moss/agent/web` token contract, and are +served only after their runtime plugin activates. Tool/provider/command calls hold active-call +leases; Web enable/disable drains them, retains the last-good generation on timeout or candidate +failure, and publishes a new composition generation without requiring restart. Installed JavaScript +executes in a termination-bounded Worker/RPC boundary, which protects startup from hangs but is not a +permission sandbox. + ## 4. Slash commands — `.moss/commands/*.md` Distinct from tools: a slash command **expands into a prompt** the model runs. Use for canned workflows, not side effects. diff --git a/packages/moss-agent/THIRD_PARTY_NOTICES.md b/packages/moss-agent/THIRD_PARTY_NOTICES.md index e844f312..8f5e4dd5 100644 --- a/packages/moss-agent/THIRD_PARTY_NOTICES.md +++ b/packages/moss-agent/THIRD_PARTY_NOTICES.md @@ -13,3 +13,17 @@ effect-ownership semantics of [Cordis](https://github.com/cordiverse/cordis). `src/vendor/cordis/README.md`, `src/vendor/cordis/LICENSE` The complete license text is retained with the vendored source. + +## DeepSeek Harness skill + +Moss includes an adapted, declarative skill from +[HenryZ838978/deepseek-harness](https://github.com/HenryZ838978/deepseek-harness). + +- Copyright: 2026 Henry Zhang +- License: MIT +- Reviewed commit: `d1dd82381604aeb3586edb242e67a62003b77d71` +- Local differences: shortened protocol guidance, removed time-sensitive prices and fixed model-limit + claims, and wrapped the skill in a Moss lifecycle plugin. +- Retained license: `assets/plugins/deepseek-harness/LICENSE` + +This adaptation does not imply endorsement by DeepSeek or the upstream author. diff --git a/packages/moss-agent/assets/plugins/deepseek-harness/LICENSE b/packages/moss-agent/assets/plugins/deepseek-harness/LICENSE new file mode 100644 index 00000000..ce829812 --- /dev/null +++ b/packages/moss-agent/assets/plugins/deepseek-harness/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Henry Zhang + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/moss-agent/assets/plugins/deepseek-harness/moss.plugin.json b/packages/moss-agent/assets/plugins/deepseek-harness/moss.plugin.json new file mode 100644 index 00000000..c5ba4632 --- /dev/null +++ b/packages/moss-agent/assets/plugins/deepseek-harness/moss.plugin.json @@ -0,0 +1,6 @@ +{ + "schemaVersion": 1, + "id": "deepseek/harness", + "version": "0.2.0", + "runtime": { "module": "./plugin.mjs" } +} diff --git a/packages/moss-agent/assets/plugins/deepseek-harness/plugin.mjs b/packages/moss-agent/assets/plugins/deepseek-harness/plugin.mjs new file mode 100644 index 00000000..772f3a28 --- /dev/null +++ b/packages/moss-agent/assets/plugins/deepseek-harness/plugin.mjs @@ -0,0 +1,59 @@ +import { readFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; + +const skillUrl = new URL('./skills/deepseek-harness/SKILL.md', import.meta.url); + +export default { + id: 'deepseek/harness', + async setup(context) { + const sourcePath = fileURLToPath(skillUrl); + const source = await readFile(skillUrl, 'utf8'); + const body = source.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, '').trim(); + context.registerSkill({ + stableId: 'deepseek-harness', + name: 'deepseek-harness', + description: 'Apply the DeepSeek V4 API protocol rules for safe reasoning and tool loops.', + summary: + 'DeepSeek V4 protocol guidance for reasoning_content, streaming tools, token caps, cache stability, and endpoint selection.', + sourcePath, + version: '0.2.0', + tags: ['deepseek', 'api', 'reasoning', 'streaming', 'tool-calls'], + trigger: [ + 'deepseek', + 'deepseek-v4-pro', + 'deepseek-v4-flash', + 'deepseek-chat', + 'deepseek-reasoner', + 'reasoning_content', + '深度求索', + ], + risk: 'low', + permissions: { workspaceRead: true }, + enabled: true, + updatedAt: 0, + body, + }); + context.registerCommand({ + id: 'deepseek-protocol', + title: 'Apply DeepSeek protocol guidance', + description: + 'Inject the reviewed DeepSeek V4 reasoning, streaming, token, cache, and tool-loop rules.', + expand(args) { + const task = args.trim(); + return [ + 'Apply the installed deepseek-harness skill and its protocol checks to this request.', + task || 'Inspect the active DeepSeek provider integration and report protocol risks.', + ].join('\n\n'); + }, + }); + context.registerMcpPreset({ + id: 'deepseek-harness', + displayName: 'DeepSeek Harness protocol tools', + server: { + command: 'npx', + args: ['-y', '@deepseek-harness/mcp@0.2.0'], + requestTimeoutMs: 30_000, + }, + }); + }, +}; diff --git a/packages/moss-agent/assets/plugins/deepseek-harness/skills/deepseek-harness/SKILL.md b/packages/moss-agent/assets/plugins/deepseek-harness/skills/deepseek-harness/SKILL.md new file mode 100644 index 00000000..bbed6b7d --- /dev/null +++ b/packages/moss-agent/assets/plugins/deepseek-harness/skills/deepseek-harness/SKILL.md @@ -0,0 +1,45 @@ +--- +name: deepseek-harness +description: Apply the DeepSeek V4 API protocol contract when building or debugging DeepSeek clients. +license: MIT +version: 0.2.0 +author: Henry Zhang (HenryZ838978), adapted for Moss by D-Robotics +homepage: https://github.com/HenryZ838978/deepseek-harness +--- + +# DeepSeek V4 Harness + +Use these rules when code calls DeepSeek V4-Pro, V4-Flash, `deepseek-chat`, or +`deepseek-reasoner` through an OpenAI-compatible API. This Moss edition is adapted from +`HenryZ838978/deepseek-harness` at commit +`d1dd82381604aeb3586edb242e67a62003b77d71`. + +1. Disable thinking for work that does not benefit from reasoning. In Python, send + `extra_body={"thinking":{"type":"disabled"}}`; for OpenAI's TypeScript SDK, confirm how the + installed SDK forwards provider extensions. +2. When thinking is enabled and an assistant message contains tool calls, preserve its original + `reasoning_content` while completing that tool loop. Dropping it can make the next request fail. +3. Always set a finite `max_tokens` output cap. Treat the model and gateway limits as runtime + configuration, not as timeless constants. +4. Aggregate streamed parallel tool-call deltas by the protocol's call index. Do not assume chunks + arrive in call-list order. +5. Buffer text and reasoning chunks in arrays and join them once; repeated string concatenation can + become quadratic for long reasoning streams. +6. Accept stream chunks whose `choices` array is empty. They can still carry usage information. +7. Keep input plus requested output within the model's advertised context window. Probe or configure + the current limit rather than relying on an old catalog value. +8. Keep the stable prompt prefix free of volatile timestamps and request-specific state when prefix + caching matters. Read both DeepSeek-native and OpenAI-shaped cached-token usage fields. +9. Use the normal DeepSeek endpoint for tool-using flows unless current official documentation + explicitly requires a beta endpoint. +10. Validate tool arguments after JSON parsing even when strict function schemas are enabled. + +For a real client implementation, prefer the maintained upstream packages and MCP server instead of +copying an old snapshot blindly: + +- Python: `deepseek-harness` +- CLI: `deepseek-harness-cli` +- MCP: `@deepseek-harness/mcp` + +Before recommending install commands or model limits, verify the current upstream README and official +DeepSeek API documentation. Never put API keys in source, plugin configuration output, logs, or chat. diff --git a/packages/moss-agent/etc/channels.api.md b/packages/moss-agent/etc/channels.api.md index 63dfd477..507b65b5 100644 --- a/packages/moss-agent/etc/channels.api.md +++ b/packages/moss-agent/etc/channels.api.md @@ -1126,6 +1126,9 @@ interface MicroCompactConfig { placeholder: string; } +// @beta +export const MOSS_WEB_SLOTS: readonly ["navigation.primary", "navigation.session", "navigation.footer", "conversation.header", "conversation.message", "conversation.composer", "conversation.details", "tool.inline", "tool.details", "settings.section", "settings.plugin"]; + // Warning: (ae-missing-release-tag) "MossAgent" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -1238,6 +1241,8 @@ class MossAgent { // // (undocumented) setGoal(sessionKey: string, objective: string): Promise; + // @beta + setUserQuestionAsker(asker: NonNullable): () => void; // Warning: (ae-forgotten-export) The symbol "SpawnProfileRegistry" needs to be exported by the entry point index.d.ts // // (undocumented) @@ -1466,6 +1471,10 @@ export interface MossErrorOutcome { // @beta interface MossPlugin { + // @internal + readonly config?: Readonly>; + // @internal + readonly disposeCandidate?: MossPluginDisposer; // (undocumented) readonly id: string; // Warning: (ae-forgotten-export) The symbol "MossPluginContext" needs to be exported by the entry point index.d.ts @@ -1474,8 +1483,24 @@ interface MossPlugin { setup(context: MossPluginContext): void | MossPluginDisposer | Promise; } +// @beta +type MossPluginCallState = 'accepting' | 'draining' | 'disposed'; + +// @beta +interface MossPluginCommand { + // (undocumented) + readonly description?: string; + // (undocumented) + expand(args: string): string | Promise; + // (undocumented) + readonly id: string; + // (undocumented) + readonly title: string; +} + // @beta interface MossPluginCompositionSnapshot { + readonly generation: number; // Warning: (ae-forgotten-export) The symbol "MossPluginSnapshot" needs to be exported by the entry point index.d.ts // // (undocumented) @@ -1486,16 +1511,31 @@ interface MossPluginCompositionSnapshot { interface MossPluginContext { // (undocumented) addPromptLayer(layer: string): void; + readonly config: Readonly>; // (undocumented) effect(setup: () => MossPluginDisposer | Promise, label?: string): void; + // Warning: (ae-forgotten-export) The symbol "MossPluginCommand" needs to be exported by the entry point index.d.ts + // + // (undocumented) + registerCommand(command: MossPluginCommand): void; // (undocumented) registerExpert(expert: SubagentExpertDefinition): void; + // Warning: (ae-forgotten-export) The symbol "MossPluginMcpPreset" needs to be exported by the entry point index.d.ts + // + // (undocumented) + registerMcpPreset(preset: MossPluginMcpPreset): void; + // Warning: (ae-forgotten-export) The symbol "MossPluginProvider" needs to be exported by the entry point index.d.ts + // + // (undocumented) + registerProvider(provider: MossPluginProvider): void; // Warning: (ae-forgotten-export) The symbol "SkillMeta" needs to be exported by the entry point index.d.ts // // (undocumented) registerSkill(skill: SkillMeta): void; // (undocumented) registerTool(tool: Tool): void; + // (undocumented) + registerWebContribution(contribution: MossWebContribution): void; } // @beta @@ -1503,8 +1543,10 @@ export type MossPluginDisposer = () => void | Promise; // @beta interface MossPluginHandle { + // Warning: (ae-forgotten-export) The symbol "MossPluginUnloadOptions" needs to be exported by the entry point index.d.ts + // // (undocumented) - dispose(): Promise; + dispose(options?: MossPluginUnloadOptions): Promise; // (undocumented) readonly id: string; // Warning: (ae-forgotten-export) The symbol "MossPluginState" needs to be exported by the entry point index.d.ts @@ -1515,10 +1557,29 @@ interface MossPluginHandle { // @beta export interface MossPluginHost { + // @internal + activateMcpPreset(id: string, setup: (preset: MossPluginMcpPreset) => Promise<{ + readonly value: T; + readonly dispose: MossPluginDisposer; + }>): Promise; // (undocumented) close(): Promise; + // Warning: (ae-forgotten-export) The symbol "LLMProvider" needs to be exported by the entry point index.d.ts + // + // (undocumented) + createProvider(id: string, config: Readonly>): Promise; + // (undocumented) + expandCommand(id: string, args: string): Promise; + // (undocumented) + getCommand(id: string): MossPluginCommand | undefined; + // (undocumented) + getMcpPreset(id: string): MossPluginMcpPreset | undefined; // @internal (undocumented) getPromptLayers(): readonly string[]; + // (undocumented) + getProvider(id: string): MossPluginProvider | undefined; + // (undocumented) + getWebContributions(): readonly MossWebContribution[]; // Warning: (ae-forgotten-export) The symbol "MossPluginCompositionSnapshot" needs to be exported by the entry point index.d.ts // // (undocumented) @@ -1528,14 +1589,54 @@ export interface MossPluginHost { // // (undocumented) install(plugin: MossPlugin): Promise; + // (undocumented) + listCommands(): readonly MossPluginCommand[]; + // (undocumented) + listMcpPresets(): readonly MossPluginMcpPreset[]; + // (undocumented) + listProviders(): readonly MossPluginProvider[]; // @internal (undocumented) own(dispose: MossPluginDisposer, label: string): void; // (undocumented) - unload(id: string): Promise; + subscribe(listener: (snapshot: MossPluginCompositionSnapshot) => void): MossPluginDisposer; + // (undocumented) + unload(id: string, options?: MossPluginUnloadOptions): Promise; +} + +// @beta +interface MossPluginMcpPreset { + // (undocumented) + readonly displayName: string; + // (undocumented) + readonly id: string; + // (undocumented) + readonly server: { + readonly command: string; + readonly args?: readonly string[]; + readonly env?: Readonly>; + readonly cwd?: string; + readonly requestTimeoutMs?: number; + }; +} + +// @beta +interface MossPluginProvider { + // (undocumented) + create(config: Readonly>): LLMProvider | Promise; + // (undocumented) + readonly displayName: string; + // (undocumented) + readonly id: string; } // @beta interface MossPluginSnapshot { + // Warning: (ae-forgotten-export) The symbol "MossPluginCallState" needs to be exported by the entry point index.d.ts + // + // (undocumented) + readonly callState: MossPluginCallState; + // (undocumented) + readonly commands: readonly string[]; // (undocumented) readonly effectLabels: readonly string[]; // (undocumented) @@ -1543,18 +1644,42 @@ interface MossPluginSnapshot { // (undocumented) readonly id: string; // (undocumented) + readonly mcpPresets: readonly string[]; + // (undocumented) readonly promptLayerCount: number; // (undocumented) + readonly providers: readonly string[]; + // (undocumented) readonly skills: readonly string[]; // (undocumented) readonly state: MossPluginState; // (undocumented) readonly tools: readonly string[]; + // (undocumented) + readonly webContributions: readonly string[]; } // @beta type MossPluginState = 'loading' | 'active' | 'unloading' | 'failed' | 'disposed'; +// @beta +interface MossPluginUnloadOptions { + readonly timeoutMs?: number; +} + +// @beta +export interface MossWebContribution { + // (undocumented) + readonly id: string; + // (undocumented) + readonly module: string; + // (undocumented) + readonly slot: MossWebSlot; +} + +// @beta +export type MossWebSlot = (typeof MOSS_WEB_SLOTS)[number]; + // Warning: (ae-missing-release-tag) "OutputGuardrailDecision" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -1827,8 +1952,6 @@ interface ProviderConfig { api?: string; // (undocumented) llmExtraBody?: Record; - // Warning: (ae-forgotten-export) The symbol "LLMProvider" needs to be exported by the entry point index.d.ts - // // (undocumented) llmProvider: LLMProvider; // (undocumented) @@ -2505,6 +2628,7 @@ interface ToolContext { abortSignal?: AbortSignal; // (undocumented) agentId?: string; + askUserQuestion?: (question: string, abortSignal?: AbortSignal) => Promise; // (undocumented) asyncTaskRegistry?: MossAsyncTaskRegistry; // (undocumented) @@ -2671,6 +2795,8 @@ class ToolRegistry { remove(toolName: string): boolean; // (undocumented) removeGroup(groupId: string): void; + // @beta + replace(tool: Tool, groupId?: string): void; // (undocumented) get size(): number; } @@ -2738,9 +2864,9 @@ interface VendorPluginCallbacks { // Warnings were encountered during analysis: // // src/context/pruning.ts:77:3 - (ae-forgotten-export) The symbol "ContextPruningToolMatch" needs to be exported by the entry point index.d.ts -// src/core/agent/moss-agent.ts:589:17 - (ae-forgotten-export) The symbol "SessionInboxDelivery" needs to be exported by the entry point index.d.ts -// src/core/agent/moss-agent.ts:670:37 - (ae-forgotten-export) The symbol "SessionDrainResult" needs to be exported by the entry point index.d.ts -// src/core/tools/tool-types.ts:64:5 - (ae-forgotten-export) The symbol "SubagentRunProgress" needs to be exported by the entry point index.d.ts +// src/core/agent/moss-agent.ts:584:17 - (ae-forgotten-export) The symbol "SessionInboxDelivery" needs to be exported by the entry point index.d.ts +// src/core/agent/moss-agent.ts:665:37 - (ae-forgotten-export) The symbol "SessionDrainResult" needs to be exported by the entry point index.d.ts +// src/core/tools/tool-types.ts:66:5 - (ae-forgotten-export) The symbol "SubagentRunProgress" needs to be exported by the entry point index.d.ts // (No @packageDocumentation comment for this package) diff --git a/packages/moss-agent/etc/context.api.md b/packages/moss-agent/etc/context.api.md index a78a0818..53274cd0 100644 --- a/packages/moss-agent/etc/context.api.md +++ b/packages/moss-agent/etc/context.api.md @@ -738,6 +738,7 @@ interface ToolContext { abortSignal?: AbortSignal; // (undocumented) agentId?: string; + askUserQuestion?: (question: string, abortSignal?: AbortSignal) => Promise; // (undocumented) asyncTaskRegistry?: MossAsyncTaskRegistry; // (undocumented) @@ -860,7 +861,7 @@ export function truncateToolOutput(toolName: string, output: string): string; // Warnings were encountered during analysis: // // src/context/compaction.ts:784:3 - (ae-forgotten-export) The symbol "Message" needs to be exported by the entry point index.d.ts -// src/core/tools/tool-types.ts:64:5 - (ae-forgotten-export) The symbol "SubagentRunProgress" needs to be exported by the entry point index.d.ts +// src/core/tools/tool-types.ts:66:5 - (ae-forgotten-export) The symbol "SubagentRunProgress" needs to be exported by the entry point index.d.ts // (No @packageDocumentation comment for this package) diff --git a/packages/moss-agent/etc/core.api.md b/packages/moss-agent/etc/core.api.md index 018c0035..6b98a363 100644 --- a/packages/moss-agent/etc/core.api.md +++ b/packages/moss-agent/etc/core.api.md @@ -2376,6 +2376,9 @@ interface Model { // @public (undocumented) type ModelBridgeConfig = Pick; +// @beta +export const MOSS_WEB_SLOTS: readonly ["navigation.primary", "navigation.session", "navigation.footer", "conversation.header", "conversation.message", "conversation.composer", "conversation.details", "tool.inline", "tool.details", "settings.section", "settings.plugin"]; + // Warning: (ae-missing-release-tag) "MossAgent" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -2473,6 +2476,8 @@ export class MossAgent { sessionEvents(sessionKey: string): readonly SessionEvent[]; // (undocumented) setGoal(sessionKey: string, objective: string): Promise; + // @beta + setUserQuestionAsker(asker: NonNullable): () => void; // (undocumented) readonly spawnRegistry: SpawnProfileRegistry; // (undocumented) @@ -2703,14 +2708,34 @@ export interface MossErrorOutcome { // @beta export interface MossPlugin { + // @internal + readonly config?: Readonly>; + // @internal + readonly disposeCandidate?: MossPluginDisposer; // (undocumented) readonly id: string; // (undocumented) setup(context: MossPluginContext): void | MossPluginDisposer | Promise; } +// @beta +export type MossPluginCallState = 'accepting' | 'draining' | 'disposed'; + +// @beta +export interface MossPluginCommand { + // (undocumented) + readonly description?: string; + // (undocumented) + expand(args: string): string | Promise; + // (undocumented) + readonly id: string; + // (undocumented) + readonly title: string; +} + // @beta export interface MossPluginCompositionSnapshot { + readonly generation: number; // (undocumented) readonly plugins: readonly MossPluginSnapshot[]; } @@ -2719,14 +2744,23 @@ export interface MossPluginCompositionSnapshot { export interface MossPluginContext { // (undocumented) addPromptLayer(layer: string): void; + readonly config: Readonly>; // (undocumented) effect(setup: () => MossPluginDisposer | Promise, label?: string): void; // (undocumented) + registerCommand(command: MossPluginCommand): void; + // (undocumented) registerExpert(expert: SubagentExpertDefinition): void; // (undocumented) + registerMcpPreset(preset: MossPluginMcpPreset): void; + // (undocumented) + registerProvider(provider: MossPluginProvider): void; + // (undocumented) registerSkill(skill: SkillMeta): void; // (undocumented) registerTool(tool: Tool): void; + // (undocumented) + registerWebContribution(contribution: MossWebContribution): void; } // @beta @@ -2735,7 +2769,7 @@ export type MossPluginDisposer = () => void | Promise; // @beta export interface MossPluginHandle { // (undocumented) - dispose(): Promise; + dispose(options?: MossPluginUnloadOptions): Promise; // (undocumented) readonly id: string; // (undocumented) @@ -2744,22 +2778,77 @@ export interface MossPluginHandle { // @beta export interface MossPluginHost { + // @internal + activateMcpPreset(id: string, setup: (preset: MossPluginMcpPreset) => Promise<{ + readonly value: T; + readonly dispose: MossPluginDisposer; + }>): Promise; // (undocumented) close(): Promise; + // (undocumented) + createProvider(id: string, config: Readonly>): Promise; + // (undocumented) + expandCommand(id: string, args: string): Promise; + // (undocumented) + getCommand(id: string): MossPluginCommand | undefined; + // (undocumented) + getMcpPreset(id: string): MossPluginMcpPreset | undefined; // @internal (undocumented) getPromptLayers(): readonly string[]; // (undocumented) + getProvider(id: string): MossPluginProvider | undefined; + // (undocumented) + getWebContributions(): readonly MossWebContribution[]; + // (undocumented) inspect(): MossPluginCompositionSnapshot; // (undocumented) install(plugin: MossPlugin): Promise; + // (undocumented) + listCommands(): readonly MossPluginCommand[]; + // (undocumented) + listMcpPresets(): readonly MossPluginMcpPreset[]; + // (undocumented) + listProviders(): readonly MossPluginProvider[]; // @internal (undocumented) own(dispose: MossPluginDisposer, label: string): void; // (undocumented) - unload(id: string): Promise; + subscribe(listener: (snapshot: MossPluginCompositionSnapshot) => void): MossPluginDisposer; + // (undocumented) + unload(id: string, options?: MossPluginUnloadOptions): Promise; +} + +// @beta +export interface MossPluginMcpPreset { + // (undocumented) + readonly displayName: string; + // (undocumented) + readonly id: string; + // (undocumented) + readonly server: { + readonly command: string; + readonly args?: readonly string[]; + readonly env?: Readonly>; + readonly cwd?: string; + readonly requestTimeoutMs?: number; + }; +} + +// @beta +export interface MossPluginProvider { + // (undocumented) + create(config: Readonly>): LLMProvider | Promise; + // (undocumented) + readonly displayName: string; + // (undocumented) + readonly id: string; } // @beta export interface MossPluginSnapshot { + // (undocumented) + readonly callState: MossPluginCallState; + // (undocumented) + readonly commands: readonly string[]; // (undocumented) readonly effectLabels: readonly string[]; // (undocumented) @@ -2767,18 +2856,42 @@ export interface MossPluginSnapshot { // (undocumented) readonly id: string; // (undocumented) + readonly mcpPresets: readonly string[]; + // (undocumented) readonly promptLayerCount: number; // (undocumented) + readonly providers: readonly string[]; + // (undocumented) readonly skills: readonly string[]; // (undocumented) readonly state: MossPluginState; // (undocumented) readonly tools: readonly string[]; + // (undocumented) + readonly webContributions: readonly string[]; } // @beta export type MossPluginState = 'loading' | 'active' | 'unloading' | 'failed' | 'disposed'; +// @beta +export interface MossPluginUnloadOptions { + readonly timeoutMs?: number; +} + +// @beta +export interface MossWebContribution { + // (undocumented) + readonly id: string; + // (undocumented) + readonly module: string; + // (undocumented) + readonly slot: MossWebSlot; +} + +// @beta +export type MossWebSlot = (typeof MOSS_WEB_SLOTS)[number]; + // Warning: (ae-missing-release-tag) "normalizeAgentId" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -4560,6 +4673,7 @@ export interface ToolContext { abortSignal?: AbortSignal; // (undocumented) agentId?: string; + askUserQuestion?: (question: string, abortSignal?: AbortSignal) => Promise; // (undocumented) asyncTaskRegistry?: MossAsyncTaskRegistry; // (undocumented) @@ -4767,6 +4881,8 @@ export class ToolRegistry { remove(toolName: string): boolean; // (undocumented) removeGroup(groupId: string): void; + // @beta + replace(tool: Tool, groupId?: string): void; // (undocumented) get size(): number; } @@ -4990,14 +5106,14 @@ export function wrapToolWithAbortSignal(tool: Tool, runSignal: AbortSignal // Warnings were encountered during analysis: // // src/context/pruning.ts:77:3 - (ae-forgotten-export) The symbol "ContextPruningToolMatch" needs to be exported by the entry point index.d.ts -// src/core/agent/moss-agent.ts:589:17 - (ae-forgotten-export) The symbol "SessionInboxDelivery" needs to be exported by the entry point index.d.ts -// src/core/agent/moss-agent.ts:670:37 - (ae-forgotten-export) The symbol "SessionDrainResult" needs to be exported by the entry point index.d.ts +// src/core/agent/moss-agent.ts:584:17 - (ae-forgotten-export) The symbol "SessionInboxDelivery" needs to be exported by the entry point index.d.ts +// src/core/agent/moss-agent.ts:665:37 - (ae-forgotten-export) The symbol "SessionDrainResult" needs to be exported by the entry point index.d.ts // src/core/llm/summarization-strategy.ts:37:7 - (ae-forgotten-export) The symbol "PruneResult" needs to be exported by the entry point index.d.ts // src/core/llm/summarization-strategy.ts:79:3 - (ae-forgotten-export) The symbol "SummarizeFn" needs to be exported by the entry point index.d.ts // src/core/loop/agent-loop-types.ts:148:5 - (ae-forgotten-export) The symbol "AgentLoopLlmUsage" needs to be exported by the entry point index.d.ts // src/core/tools/objective-verifier-hook.ts:65:22 - (ae-forgotten-export) The symbol "DeviceReadonlyExecutor" needs to be exported by the entry point index.d.ts // src/core/tools/objective-verifier-hook.ts:77:20 - (ae-forgotten-export) The symbol "Plan" needs to be exported by the entry point index.d.ts -// src/core/tools/tool-types.ts:64:5 - (ae-forgotten-export) The symbol "SubagentRunProgress" needs to be exported by the entry point index.d.ts +// src/core/tools/tool-types.ts:66:5 - (ae-forgotten-export) The symbol "SubagentRunProgress" needs to be exported by the entry point index.d.ts // src/provider/pi-ai-types.ts:133:50 - (ae-forgotten-export) The symbol "AssistantMessage" needs to be exported by the entry point index.d.ts // (No @packageDocumentation comment for this package) diff --git a/packages/moss-agent/etc/eval.api.md b/packages/moss-agent/etc/eval.api.md index ca8166d1..94bdfd0d 100644 --- a/packages/moss-agent/etc/eval.api.md +++ b/packages/moss-agent/etc/eval.api.md @@ -566,6 +566,7 @@ interface ToolContext { abortSignal?: AbortSignal; // (undocumented) agentId?: string; + askUserQuestion?: (question: string, abortSignal?: AbortSignal) => Promise; // (undocumented) asyncTaskRegistry?: MossAsyncTaskRegistry; // (undocumented) @@ -677,7 +678,7 @@ export const toolUsageMetric: MetricFn; // Warnings were encountered during analysis: // -// src/core/tools/tool-types.ts:64:5 - (ae-forgotten-export) The symbol "SubagentRunProgress" needs to be exported by the entry point index.d.ts +// src/core/tools/tool-types.ts:66:5 - (ae-forgotten-export) The symbol "SubagentRunProgress" needs to be exported by the entry point index.d.ts // (No @packageDocumentation comment for this package) diff --git a/packages/moss-agent/etc/mcp.api.md b/packages/moss-agent/etc/mcp.api.md index 965650fc..0aa3f209 100644 --- a/packages/moss-agent/etc/mcp.api.md +++ b/packages/moss-agent/etc/mcp.api.md @@ -213,6 +213,7 @@ interface ToolContext { abortSignal?: AbortSignal; // (undocumented) agentId?: string; + askUserQuestion?: (question: string, abortSignal?: AbortSignal) => Promise; // (undocumented) asyncTaskRegistry?: MossAsyncTaskRegistry; // (undocumented) @@ -319,7 +320,7 @@ type ToolSideEffectClass = 'readonly' | 'local_write' | 'device_mutation' | 'cre // Warnings were encountered during analysis: // -// src/core/tools/tool-types.ts:64:5 - (ae-forgotten-export) The symbol "SubagentRunProgress" needs to be exported by the entry point index.d.ts +// src/core/tools/tool-types.ts:66:5 - (ae-forgotten-export) The symbol "SubagentRunProgress" needs to be exported by the entry point index.d.ts // (No @packageDocumentation comment for this package) diff --git a/packages/moss-agent/etc/mesh.api.md b/packages/moss-agent/etc/mesh.api.md index d63b34d3..7016fc52 100644 --- a/packages/moss-agent/etc/mesh.api.md +++ b/packages/moss-agent/etc/mesh.api.md @@ -495,6 +495,7 @@ interface ToolContext { abortSignal?: AbortSignal; // (undocumented) agentId?: string; + askUserQuestion?: (question: string, abortSignal?: AbortSignal) => Promise; // (undocumented) asyncTaskRegistry?: MossAsyncTaskRegistry; // (undocumented) @@ -601,7 +602,7 @@ type ToolSideEffectClass = 'readonly' | 'local_write' | 'device_mutation' | 'cre // Warnings were encountered during analysis: // -// src/core/tools/tool-types.ts:64:5 - (ae-forgotten-export) The symbol "SubagentRunProgress" needs to be exported by the entry point index.d.ts +// src/core/tools/tool-types.ts:66:5 - (ae-forgotten-export) The symbol "SubagentRunProgress" needs to be exported by the entry point index.d.ts // src/mesh/agent-mesh.ts:304:40 - (ae-forgotten-export) The symbol "HostAddressResolver" needs to be exported by the entry point index.d.ts // (No @packageDocumentation comment for this package) diff --git a/packages/moss-agent/etc/plan-execute.api.md b/packages/moss-agent/etc/plan-execute.api.md index 3ec01a63..5e3d597b 100644 --- a/packages/moss-agent/etc/plan-execute.api.md +++ b/packages/moss-agent/etc/plan-execute.api.md @@ -503,6 +503,7 @@ interface ToolContext { abortSignal?: AbortSignal; // (undocumented) agentId?: string; + askUserQuestion?: (question: string, abortSignal?: AbortSignal) => Promise; // (undocumented) asyncTaskRegistry?: MossAsyncTaskRegistry; // (undocumented) @@ -609,7 +610,7 @@ type ToolSideEffectClass = 'readonly' | 'local_write' | 'device_mutation' | 'cre // Warnings were encountered during analysis: // -// src/core/tools/tool-types.ts:64:5 - (ae-forgotten-export) The symbol "SubagentRunProgress" needs to be exported by the entry point index.d.ts +// src/core/tools/tool-types.ts:66:5 - (ae-forgotten-export) The symbol "SubagentRunProgress" needs to be exported by the entry point index.d.ts // (No @packageDocumentation comment for this package) diff --git a/packages/moss-agent/etc/root.api.md b/packages/moss-agent/etc/root.api.md index 10d096c3..cdd98f22 100644 --- a/packages/moss-agent/etc/root.api.md +++ b/packages/moss-agent/etc/root.api.md @@ -4279,6 +4279,9 @@ export const MOSS_CLI_IDENTITY: string; // @public (undocumented) export const MOSS_DEFAULT_MAX_AGENT_TURNS = 64; +// @beta +export const MOSS_WEB_SLOTS: readonly ["navigation.primary", "navigation.session", "navigation.footer", "conversation.header", "conversation.message", "conversation.composer", "conversation.details", "tool.inline", "tool.details", "settings.section", "settings.plugin"]; + // Warning: (ae-missing-release-tag) "MossAgent" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -4374,6 +4377,8 @@ export class MossAgent { sessionEvents(sessionKey: string): readonly SessionEvent[]; // (undocumented) setGoal(sessionKey: string, objective: string): Promise; + // @beta + setUserQuestionAsker(asker: NonNullable): () => void; // (undocumented) readonly spawnRegistry: SpawnProfileRegistry; // (undocumented) @@ -4767,14 +4772,34 @@ export interface MossErrorOutcome { // @beta export interface MossPlugin { + // @internal + readonly config?: Readonly>; + // @internal + readonly disposeCandidate?: MossPluginDisposer; // (undocumented) readonly id: string; // (undocumented) setup(context: MossPluginContext): void | MossPluginDisposer | Promise; } +// @beta +export type MossPluginCallState = 'accepting' | 'draining' | 'disposed'; + +// @beta +export interface MossPluginCommand { + // (undocumented) + readonly description?: string; + // (undocumented) + expand(args: string): string | Promise; + // (undocumented) + readonly id: string; + // (undocumented) + readonly title: string; +} + // @beta export interface MossPluginCompositionSnapshot { + readonly generation: number; // (undocumented) readonly plugins: readonly MossPluginSnapshot[]; } @@ -4783,14 +4808,23 @@ export interface MossPluginCompositionSnapshot { export interface MossPluginContext { // (undocumented) addPromptLayer(layer: string): void; + readonly config: Readonly>; // (undocumented) effect(setup: () => MossPluginDisposer | Promise, label?: string): void; // (undocumented) + registerCommand(command: MossPluginCommand): void; + // (undocumented) registerExpert(expert: SubagentExpertDefinition): void; // (undocumented) + registerMcpPreset(preset: MossPluginMcpPreset): void; + // (undocumented) + registerProvider(provider: MossPluginProvider): void; + // (undocumented) registerSkill(skill: SkillMeta): void; // (undocumented) registerTool(tool: Tool): void; + // (undocumented) + registerWebContribution(contribution: MossWebContribution): void; } // @beta @@ -4799,7 +4833,7 @@ export type MossPluginDisposer = () => void | Promise; // @beta export interface MossPluginHandle { // (undocumented) - dispose(): Promise; + dispose(options?: MossPluginUnloadOptions): Promise; // (undocumented) readonly id: string; // (undocumented) @@ -4808,22 +4842,77 @@ export interface MossPluginHandle { // @beta export interface MossPluginHost { + // @internal + activateMcpPreset(id: string, setup: (preset: MossPluginMcpPreset) => Promise<{ + readonly value: T; + readonly dispose: MossPluginDisposer; + }>): Promise; // (undocumented) close(): Promise; + // (undocumented) + createProvider(id: string, config: Readonly>): Promise; + // (undocumented) + expandCommand(id: string, args: string): Promise; + // (undocumented) + getCommand(id: string): MossPluginCommand | undefined; + // (undocumented) + getMcpPreset(id: string): MossPluginMcpPreset | undefined; // @internal (undocumented) getPromptLayers(): readonly string[]; // (undocumented) + getProvider(id: string): MossPluginProvider | undefined; + // (undocumented) + getWebContributions(): readonly MossWebContribution[]; + // (undocumented) inspect(): MossPluginCompositionSnapshot; // (undocumented) install(plugin: MossPlugin): Promise; + // (undocumented) + listCommands(): readonly MossPluginCommand[]; + // (undocumented) + listMcpPresets(): readonly MossPluginMcpPreset[]; + // (undocumented) + listProviders(): readonly MossPluginProvider[]; // @internal (undocumented) own(dispose: MossPluginDisposer, label: string): void; // (undocumented) - unload(id: string): Promise; + subscribe(listener: (snapshot: MossPluginCompositionSnapshot) => void): MossPluginDisposer; + // (undocumented) + unload(id: string, options?: MossPluginUnloadOptions): Promise; +} + +// @beta +export interface MossPluginMcpPreset { + // (undocumented) + readonly displayName: string; + // (undocumented) + readonly id: string; + // (undocumented) + readonly server: { + readonly command: string; + readonly args?: readonly string[]; + readonly env?: Readonly>; + readonly cwd?: string; + readonly requestTimeoutMs?: number; + }; +} + +// @beta +export interface MossPluginProvider { + // (undocumented) + create(config: Readonly>): LLMProvider | Promise; + // (undocumented) + readonly displayName: string; + // (undocumented) + readonly id: string; } // @beta export interface MossPluginSnapshot { + // (undocumented) + readonly callState: MossPluginCallState; + // (undocumented) + readonly commands: readonly string[]; // (undocumented) readonly effectLabels: readonly string[]; // (undocumented) @@ -4831,18 +4920,29 @@ export interface MossPluginSnapshot { // (undocumented) readonly id: string; // (undocumented) + readonly mcpPresets: readonly string[]; + // (undocumented) readonly promptLayerCount: number; // (undocumented) + readonly providers: readonly string[]; + // (undocumented) readonly skills: readonly string[]; // (undocumented) readonly state: MossPluginState; // (undocumented) readonly tools: readonly string[]; + // (undocumented) + readonly webContributions: readonly string[]; } // @beta export type MossPluginState = 'loading' | 'active' | 'unloading' | 'failed' | 'disposed'; +// @beta +export interface MossPluginUnloadOptions { + readonly timeoutMs?: number; +} + // Warning: (ae-missing-release-tag) "MossRuntime" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -4868,6 +4968,19 @@ export interface MossRuntime { // @public (undocumented) export type MossRuntimeToolProfile = 'desktop-safe' | 'full'; +// @beta +export interface MossWebContribution { + // (undocumented) + readonly id: string; + // (undocumented) + readonly module: string; + // (undocumented) + readonly slot: MossWebSlot; +} + +// @beta +export type MossWebSlot = (typeof MOSS_WEB_SLOTS)[number]; + // Warning: (ae-missing-release-tag) "moveFileTool" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -7792,6 +7905,7 @@ export interface ToolContext { abortSignal?: AbortSignal; // (undocumented) agentId?: string; + askUserQuestion?: (question: string, abortSignal?: AbortSignal) => Promise; // (undocumented) asyncTaskRegistry?: MossAsyncTaskRegistry; // (undocumented) @@ -8001,6 +8115,8 @@ export class ToolRegistry { remove(toolName: string): boolean; // (undocumented) removeGroup(groupId: string): void; + // @beta + replace(tool: Tool, groupId?: string): void; // (undocumented) get size(): number; } @@ -8663,9 +8779,9 @@ export function writeMossCommunityAuthSession(session: MossCommunityAuthSession, // src/cli/community-auth.ts:694:5 - (ae-forgotten-export) The symbol "FetchImpl" needs to be exported by the entry point index.d.ts // src/cli/model-catalog.ts:62:17 - (ae-forgotten-export) The symbol "CustomModelConfig" needs to be exported by the entry point index.d.ts // src/context/pruning.ts:77:3 - (ae-forgotten-export) The symbol "ContextPruningToolMatch" needs to be exported by the entry point index.d.ts -// src/core/agent/moss-agent.ts:589:17 - (ae-forgotten-export) The symbol "SessionInboxDelivery" needs to be exported by the entry point index.d.ts -// src/core/agent/moss-agent.ts:670:37 - (ae-forgotten-export) The symbol "SessionDrainResult" needs to be exported by the entry point index.d.ts -// src/core/tools/tool-types.ts:64:5 - (ae-forgotten-export) The symbol "SubagentRunProgress" needs to be exported by the entry point index.d.ts +// src/core/agent/moss-agent.ts:584:17 - (ae-forgotten-export) The symbol "SessionInboxDelivery" needs to be exported by the entry point index.d.ts +// src/core/agent/moss-agent.ts:665:37 - (ae-forgotten-export) The symbol "SessionDrainResult" needs to be exported by the entry point index.d.ts +// src/core/tools/tool-types.ts:66:5 - (ae-forgotten-export) The symbol "SubagentRunProgress" needs to be exported by the entry point index.d.ts // src/memory/recovery-recipe-log.ts:162:3 - (ae-forgotten-export) The symbol "LearningEvent" needs to be exported by the entry point index.d.ts // src/memory/recovery-recipe-log.ts:163:3 - (ae-forgotten-export) The symbol "ExperienceEntry" needs to be exported by the entry point index.d.ts // src/memory/trusted-patch-coordinator.ts:53:7 - (ae-forgotten-export) The symbol "LearningEventLog" needs to be exported by the entry point index.d.ts diff --git a/packages/moss-agent/etc/runtime.api.md b/packages/moss-agent/etc/runtime.api.md index 382e7e01..a5eedfe8 100644 --- a/packages/moss-agent/etc/runtime.api.md +++ b/packages/moss-agent/etc/runtime.api.md @@ -572,6 +572,22 @@ interface DistillResult { score: SkillScoreResult; } +// @beta +export interface DshPackageCompatibilityReport { + // (undocumented) + readonly compatible: boolean; + // (undocumented) + readonly id?: string; + // (undocumented) + readonly imported: readonly string[]; + // (undocumented) + readonly reasons: readonly string[]; + // (undocumented) + readonly skipped: readonly string[]; + // (undocumented) + readonly version?: string; +} + // Warning: (ae-missing-release-tag) "EnqueueOpts" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -771,6 +787,19 @@ interface GoalState { // @public (undocumented) type GoalStatus = 'active' | 'paused' | 'completed' | 'blocked'; +// @beta +export function importDshPackage(source: string): Promise; + +// @beta +export interface ImportedDshPackage { + // (undocumented) + readonly configSchema?: MossPluginConfigSchema; + // (undocumented) + readonly plugin: MossPlugin; + // (undocumented) + readonly report: DshPackageCompatibilityReport; +} + // Warning: (ae-missing-release-tag) "InputGuardrailDecision" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -796,6 +825,70 @@ interface InputGuardrailRequest { userMessage: string; } +// @beta +export function inspectDshPackageCompatibility(source: string): Promise; + +// @beta +export interface InstalledMossPlugin { + // (undocumented) + readonly enabled: boolean; + readonly format?: 'moss-v1' | 'dsh-package-v1'; + // (undocumented) + readonly id: string; + // (undocumented) + readonly installedAt: string; + // Warning: (ae-forgotten-export) The symbol "InstalledMossPluginVersion" needs to be exported by the entry point index.d.ts + readonly lastGood?: InstalledMossPluginVersion; + // (undocumented) + readonly root: string; + // (undocumented) + readonly source: string; + // (undocumented) + readonly version: string; +} + +// @beta +interface InstalledMossPluginVersion { + // (undocumented) + readonly format?: 'moss-v1' | 'dsh-package-v1'; + // (undocumented) + readonly root: string; + // (undocumented) + readonly source: string; + // (undocumented) + readonly version: string; +} + +// @beta +export class InstalledPluginRegistry { + constructor(options: InstalledPluginRegistryOptions); + // (undocumented) + add(source: string): Promise; + // (undocumented) + disable(id: string): Promise; + // (undocumented) + doctor(): Promise; + // (undocumented) + enable(id: string): Promise; + // (undocumented) + list(): Promise; + // (undocumented) + loadEnabled(): Promise; + loadInstalled(id: string, options?: { + readonly config?: Readonly>; + }): Promise; + // (undocumented) + remove(id: string): Promise; + rollback(id: string): Promise; +} + +// @beta +export interface InstalledPluginRegistryOptions { + // (undocumented) + readonly configDir: string; + readonly setupTimeoutMs?: number; +} + // Warning: (ae-missing-release-tag) "KnowledgeRegistry" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -1034,6 +1127,18 @@ interface LLMToolDeclaration { name: string; } +// @beta +export interface LoadedMossPlugins { + // (undocumented) + readonly failures: readonly { + id: string; + message: string; + recovered?: boolean; + }[]; + // (undocumented) + readonly plugins: readonly MossPlugin[]; +} + // Warning: (ae-missing-release-tag) "MemoryEmbeddingProvider" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -1201,6 +1306,9 @@ interface MicroCompactConfig { placeholder: string; } +// @beta +export const MOSS_WEB_SLOTS: readonly ["navigation.primary", "navigation.session", "navigation.footer", "conversation.header", "conversation.message", "conversation.composer", "conversation.details", "tool.inline", "tool.details", "settings.section", "settings.plugin"]; + // Warning: (ae-missing-release-tag) "MossAgent" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -1313,6 +1421,8 @@ class MossAgent { // // (undocumented) setGoal(sessionKey: string, objective: string): Promise; + // @beta + setUserQuestionAsker(asker: NonNullable): () => void; // Warning: (ae-forgotten-export) The symbol "SpawnProfileRegistry" needs to be exported by the entry point index.d.ts // // (undocumented) @@ -1584,41 +1694,123 @@ export interface MossErrorOutcome { // @beta export interface MossPlugin { + // @internal + readonly config?: Readonly>; + // @internal + readonly disposeCandidate?: MossPluginDisposer; // (undocumented) readonly id: string; // (undocumented) setup(context: MossPluginContext): void | MossPluginDisposer | Promise; } +// @beta +export type MossPluginCallState = 'accepting' | 'draining' | 'disposed'; + +// @beta +export interface MossPluginCommand { + // (undocumented) + readonly description?: string; + // (undocumented) + expand(args: string): string | Promise; + // (undocumented) + readonly id: string; + // (undocumented) + readonly title: string; +} + // @beta export interface MossPluginCompositionSnapshot { + readonly generation: number; // (undocumented) readonly plugins: readonly MossPluginSnapshot[]; } +// @beta +export interface MossPluginConfigPropertySchema extends MossPluginJsonSchema { + readonly writeOnly?: boolean; +} + +// @beta +export interface MossPluginConfigSchema extends MossPluginJsonSchema { + // (undocumented) + readonly properties?: Readonly>; + // (undocumented) + readonly type: 'object'; +} + +// @beta +export class MossPluginConfigStore { + constructor(options: MossPluginConfigStoreOptions); + deleteSecret(pluginId: string, schema: MossPluginConfigSchema, name: string): Promise; + getView(pluginId: string, schema: MossPluginConfigSchema): Promise; + loadRuntimeConfig(pluginId: string, schema: MossPluginConfigSchema): Promise>>; + putSecret(pluginId: string, schema: MossPluginConfigSchema, name: string, value: unknown): Promise; + // @internal + remove(pluginId: string): Promise; + // @internal + replaceRuntimeConfig(pluginId: string, schema: MossPluginConfigSchema, replacement: Readonly>): Promise; + update(pluginId: string, schema: MossPluginConfigSchema, patch: Readonly>): Promise; +} + +// @beta +export interface MossPluginConfigStoreOptions { + // (undocumented) + readonly configDir: string; +} + +// @beta +export interface MossPluginConfigView { + // (undocumented) + readonly secrets: Readonly>; + // (undocumented) + readonly values: Readonly>; +} + // @beta export interface MossPluginContext { // (undocumented) addPromptLayer(layer: string): void; + readonly config: Readonly>; // (undocumented) effect(setup: () => MossPluginDisposer | Promise, label?: string): void; // (undocumented) + registerCommand(command: MossPluginCommand): void; + // (undocumented) registerExpert(expert: SubagentExpertDefinition): void; + // (undocumented) + registerMcpPreset(preset: MossPluginMcpPreset): void; + // (undocumented) + registerProvider(provider: MossPluginProvider): void; // Warning: (ae-forgotten-export) The symbol "SkillMeta" needs to be exported by the entry point index.d.ts // // (undocumented) registerSkill(skill: SkillMeta): void; // (undocumented) registerTool(tool: Tool): void; + // (undocumented) + registerWebContribution(contribution: MossWebContribution): void; } // @beta export type MossPluginDisposer = () => void | Promise; +// @beta +export interface MossPluginDoctorResult { + // (undocumented) + readonly id: string; + // (undocumented) + readonly message: string; + // (undocumented) + readonly status: 'ok' | 'disabled' | 'error'; +} + // @beta export interface MossPluginHandle { // (undocumented) - dispose(): Promise; + dispose(options?: MossPluginUnloadOptions): Promise; // (undocumented) readonly id: string; // (undocumented) @@ -1627,22 +1819,165 @@ export interface MossPluginHandle { // @beta export interface MossPluginHost { + // @internal + activateMcpPreset(id: string, setup: (preset: MossPluginMcpPreset) => Promise<{ + readonly value: T; + readonly dispose: MossPluginDisposer; + }>): Promise; // (undocumented) close(): Promise; + // Warning: (ae-forgotten-export) The symbol "LLMProvider" needs to be exported by the entry point index.d.ts + // + // (undocumented) + createProvider(id: string, config: Readonly>): Promise; + // (undocumented) + expandCommand(id: string, args: string): Promise; + // (undocumented) + getCommand(id: string): MossPluginCommand | undefined; + // (undocumented) + getMcpPreset(id: string): MossPluginMcpPreset | undefined; // @internal (undocumented) getPromptLayers(): readonly string[]; // (undocumented) + getProvider(id: string): MossPluginProvider | undefined; + // (undocumented) + getWebContributions(): readonly MossWebContribution[]; + // (undocumented) inspect(): MossPluginCompositionSnapshot; // (undocumented) install(plugin: MossPlugin): Promise; + // (undocumented) + listCommands(): readonly MossPluginCommand[]; + // (undocumented) + listMcpPresets(): readonly MossPluginMcpPreset[]; + // (undocumented) + listProviders(): readonly MossPluginProvider[]; // @internal (undocumented) own(dispose: MossPluginDisposer, label: string): void; // (undocumented) - unload(id: string): Promise; + subscribe(listener: (snapshot: MossPluginCompositionSnapshot) => void): MossPluginDisposer; + // (undocumented) + unload(id: string, options?: MossPluginUnloadOptions): Promise; +} + +// @beta +export interface MossPluginJsonSchema { + // (undocumented) + $defs?: Record; + // (undocumented) + $ref?: string; + // (undocumented) + additionalProperties?: boolean | MossPluginJsonSchema; + // (undocumented) + allOf?: MossPluginJsonSchema[]; + // (undocumented) + anyOf?: MossPluginJsonSchema[]; + // (undocumented) + const?: unknown; + // (undocumented) + default?: unknown; + // (undocumented) + definitions?: Record; + // (undocumented) + description?: string; + // (undocumented) + else?: MossPluginJsonSchema; + // (undocumented) + enum?: unknown[]; + // (undocumented) + format?: string; + // (undocumented) + if?: MossPluginJsonSchema; + // (undocumented) + items?: MossPluginJsonSchema | MossPluginJsonSchema[]; + // (undocumented) + maximum?: number; + // (undocumented) + maxItems?: number; + // (undocumented) + maxLength?: number; + // (undocumented) + minimum?: number; + // (undocumented) + minItems?: number; + // (undocumented) + minLength?: number; + // (undocumented) + not?: MossPluginJsonSchema; + // (undocumented) + oneOf?: MossPluginJsonSchema[]; + // (undocumented) + pattern?: string; + // (undocumented) + properties?: Record; + // (undocumented) + required?: string[]; + // (undocumented) + then?: MossPluginJsonSchema; + // (undocumented) + title?: string; + // (undocumented) + type?: string | string[]; + // (undocumented) + uniqueItems?: boolean; +} + +// @beta +export interface MossPluginManifestV1 { + // (undocumented) + readonly configSchema?: string; + // (undocumented) + readonly id: string; + // (undocumented) + readonly runtime: MossPluginRuntimeManifest; + // (undocumented) + readonly schemaVersion: 1; + // (undocumented) + readonly version: string; + // (undocumented) + readonly web?: MossPluginWebManifest; +} + +// @beta +export interface MossPluginMcpPreset { + // (undocumented) + readonly displayName: string; + // (undocumented) + readonly id: string; + // (undocumented) + readonly server: { + readonly command: string; + readonly args?: readonly string[]; + readonly env?: Readonly>; + readonly cwd?: string; + readonly requestTimeoutMs?: number; + }; +} + +// @beta +export interface MossPluginProvider { + // (undocumented) + create(config: Readonly>): LLMProvider | Promise; + // (undocumented) + readonly displayName: string; + // (undocumented) + readonly id: string; +} + +// @beta +export interface MossPluginRuntimeManifest { + // (undocumented) + readonly export?: string; + // (undocumented) + readonly module: string; } // @beta export interface MossPluginSnapshot { + // (undocumented) + readonly callState: MossPluginCallState; + // (undocumented) + readonly commands: readonly string[]; // (undocumented) readonly effectLabels: readonly string[]; // (undocumented) @@ -1650,18 +1985,35 @@ export interface MossPluginSnapshot { // (undocumented) readonly id: string; // (undocumented) + readonly mcpPresets: readonly string[]; + // (undocumented) readonly promptLayerCount: number; // (undocumented) + readonly providers: readonly string[]; + // (undocumented) readonly skills: readonly string[]; // (undocumented) readonly state: MossPluginState; // (undocumented) readonly tools: readonly string[]; + // (undocumented) + readonly webContributions: readonly string[]; } // @beta export type MossPluginState = 'loading' | 'active' | 'unloading' | 'failed' | 'disposed'; +// @beta +export interface MossPluginUnloadOptions { + readonly timeoutMs?: number; +} + +// @beta +export interface MossPluginWebManifest { + // (undocumented) + readonly contributions: readonly MossWebContribution[]; +} + // Warning: (ae-missing-release-tag) "MossRuntime" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -1689,6 +2041,19 @@ export interface MossRuntime { // @public (undocumented) export type MossRuntimeToolProfile = 'desktop-safe' | 'full'; +// @beta +export interface MossWebContribution { + // (undocumented) + readonly id: string; + // (undocumented) + readonly module: string; + // (undocumented) + readonly slot: MossWebSlot; +} + +// @beta +export type MossWebSlot = (typeof MOSS_WEB_SLOTS)[number]; + // Warning: (ae-missing-release-tag) "ObservationAggregator" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -1997,8 +2362,6 @@ interface ProviderConfig { api?: string; // (undocumented) llmExtraBody?: Record; - // Warning: (ae-forgotten-export) The symbol "LLMProvider" needs to be exported by the entry point index.d.ts - // // (undocumented) llmProvider: LLMProvider; // (undocumented) @@ -2044,6 +2407,12 @@ export interface ProviderErrorSurface { userMessage: string; } +// @beta +export function readMossPluginConfigSchema(pluginRoot: string): Promise; + +// @beta +export function readMossPluginManifest(root: string): Promise; + // Warning: (ae-missing-release-tag) "ReadonlyExecResult" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public @@ -2790,6 +3159,7 @@ interface ToolContext { abortSignal?: AbortSignal; // (undocumented) agentId?: string; + askUserQuestion?: (question: string, abortSignal?: AbortSignal) => Promise; // (undocumented) asyncTaskRegistry?: MossAsyncTaskRegistry; // (undocumented) @@ -2955,6 +3325,8 @@ class ToolRegistry { remove(toolName: string): boolean; // (undocumented) removeGroup(groupId: string): void; + // @beta + replace(tool: Tool, groupId?: string): void; // (undocumented) get size(): number; } @@ -3032,9 +3404,9 @@ type VerdictSource = 'exit_code' | 'file_exist' | 'geometric' | 'sensor' | 'mode // Warnings were encountered during analysis: // // src/context/pruning.ts:77:3 - (ae-forgotten-export) The symbol "ContextPruningToolMatch" needs to be exported by the entry point index.d.ts -// src/core/agent/moss-agent.ts:589:17 - (ae-forgotten-export) The symbol "SessionInboxDelivery" needs to be exported by the entry point index.d.ts -// src/core/agent/moss-agent.ts:670:37 - (ae-forgotten-export) The symbol "SessionDrainResult" needs to be exported by the entry point index.d.ts -// src/core/tools/tool-types.ts:64:5 - (ae-forgotten-export) The symbol "SubagentRunProgress" needs to be exported by the entry point index.d.ts +// src/core/agent/moss-agent.ts:584:17 - (ae-forgotten-export) The symbol "SessionInboxDelivery" needs to be exported by the entry point index.d.ts +// src/core/agent/moss-agent.ts:665:37 - (ae-forgotten-export) The symbol "SessionDrainResult" needs to be exported by the entry point index.d.ts +// src/core/tools/tool-types.ts:66:5 - (ae-forgotten-export) The symbol "SubagentRunProgress" needs to be exported by the entry point index.d.ts // src/runtime/shared-runtime.ts:111:22 - (ae-forgotten-export) The symbol "DeviceReadonlyExecutor" needs to be exported by the entry point index.d.ts // (No @packageDocumentation comment for this package) diff --git a/packages/moss-agent/etc/structured-output.api.md b/packages/moss-agent/etc/structured-output.api.md index 1d7ab789..c6a45c1e 100644 --- a/packages/moss-agent/etc/structured-output.api.md +++ b/packages/moss-agent/etc/structured-output.api.md @@ -326,6 +326,7 @@ interface ToolContext { abortSignal?: AbortSignal; // (undocumented) agentId?: string; + askUserQuestion?: (question: string, abortSignal?: AbortSignal) => Promise; // (undocumented) asyncTaskRegistry?: MossAsyncTaskRegistry; // (undocumented) @@ -442,7 +443,7 @@ export function validateJsonSchemaDefinition(schema: JsonSchema): SchemaDefiniti // Warnings were encountered during analysis: // -// src/core/tools/tool-types.ts:64:5 - (ae-forgotten-export) The symbol "SubagentRunProgress" needs to be exported by the entry point index.d.ts +// src/core/tools/tool-types.ts:66:5 - (ae-forgotten-export) The symbol "SubagentRunProgress" needs to be exported by the entry point index.d.ts // (No @packageDocumentation comment for this package) diff --git a/packages/moss-agent/etc/teaching.api.md b/packages/moss-agent/etc/teaching.api.md index 6bfd8568..77d84cf3 100644 --- a/packages/moss-agent/etc/teaching.api.md +++ b/packages/moss-agent/etc/teaching.api.md @@ -470,6 +470,7 @@ interface ToolContext { abortSignal?: AbortSignal; // (undocumented) agentId?: string; + askUserQuestion?: (question: string, abortSignal?: AbortSignal) => Promise; // (undocumented) asyncTaskRegistry?: MossAsyncTaskRegistry; // (undocumented) @@ -614,7 +615,7 @@ type ToolSideEffectClass = 'readonly' | 'local_write' | 'device_mutation' | 'cre // Warnings were encountered during analysis: // // src/core/llm/llm-provider.ts:19:7 - (ae-forgotten-export) The symbol "ToolContentBlock" needs to be exported by the entry point index.d.ts -// src/core/tools/tool-types.ts:64:5 - (ae-forgotten-export) The symbol "SubagentRunProgress" needs to be exported by the entry point index.d.ts +// src/core/tools/tool-types.ts:66:5 - (ae-forgotten-export) The symbol "SubagentRunProgress" needs to be exported by the entry point index.d.ts // src/teaching/teaching-layer.ts:162:3 - (ae-forgotten-export) The symbol "ToolApprovalRequest" needs to be exported by the entry point index.d.ts // src/teaching/teaching-layer.ts:162:3 - (ae-forgotten-export) The symbol "ToolApprovalDecision" needs to be exported by the entry point index.d.ts // src/teaching/teaching-layer.ts:163:3 - (ae-forgotten-export) The symbol "ToolCall" needs to be exported by the entry point index.d.ts diff --git a/packages/moss-agent/etc/tools__builtin.api.md b/packages/moss-agent/etc/tools__builtin.api.md index 8d4ffbb3..52b52512 100644 --- a/packages/moss-agent/etc/tools__builtin.api.md +++ b/packages/moss-agent/etc/tools__builtin.api.md @@ -253,6 +253,7 @@ interface ToolContext { abortSignal?: AbortSignal; // (undocumented) agentId?: string; + askUserQuestion?: (question: string, abortSignal?: AbortSignal) => Promise; // (undocumented) asyncTaskRegistry?: MossAsyncTaskRegistry; // (undocumented) @@ -395,7 +396,7 @@ export const writeFileTool: Tool; // Warnings were encountered during analysis: // -// src/core/tools/tool-types.ts:64:5 - (ae-forgotten-export) The symbol "SubagentRunProgress" needs to be exported by the entry point builtin.d.ts +// src/core/tools/tool-types.ts:66:5 - (ae-forgotten-export) The symbol "SubagentRunProgress" needs to be exported by the entry point builtin.d.ts // (No @packageDocumentation comment for this package) diff --git a/packages/moss-agent/etc/vision.api.md b/packages/moss-agent/etc/vision.api.md index 2b536427..abfe6caf 100644 --- a/packages/moss-agent/etc/vision.api.md +++ b/packages/moss-agent/etc/vision.api.md @@ -125,6 +125,7 @@ interface ToolContext { abortSignal?: AbortSignal; // (undocumented) agentId?: string; + askUserQuestion?: (question: string, abortSignal?: AbortSignal) => Promise; // (undocumented) asyncTaskRegistry?: MossAsyncTaskRegistry; // (undocumented) @@ -320,7 +321,7 @@ export interface VisionToolOptions { // Warnings were encountered during analysis: // -// src/core/tools/tool-types.ts:64:5 - (ae-forgotten-export) The symbol "SubagentRunProgress" needs to be exported by the entry point index.d.ts +// src/core/tools/tool-types.ts:66:5 - (ae-forgotten-export) The symbol "SubagentRunProgress" needs to be exported by the entry point index.d.ts // (No @packageDocumentation comment for this package) diff --git a/packages/moss-agent/etc/web-browser.api.md b/packages/moss-agent/etc/web-browser.api.md index 3dd28bf1..a49dcb4e 100644 --- a/packages/moss-agent/etc/web-browser.api.md +++ b/packages/moss-agent/etc/web-browser.api.md @@ -169,6 +169,7 @@ interface ToolContext { abortSignal?: AbortSignal; // (undocumented) agentId?: string; + askUserQuestion?: (question: string, abortSignal?: AbortSignal) => Promise; // (undocumented) asyncTaskRegistry?: MossAsyncTaskRegistry; // (undocumented) @@ -413,7 +414,7 @@ export interface WebBrowserTask { // Warnings were encountered during analysis: // -// src/core/tools/tool-types.ts:64:5 - (ae-forgotten-export) The symbol "SubagentRunProgress" needs to be exported by the entry point index.d.ts +// src/core/tools/tool-types.ts:66:5 - (ae-forgotten-export) The symbol "SubagentRunProgress" needs to be exported by the entry point index.d.ts // (No @packageDocumentation comment for this package) diff --git a/packages/moss-agent/etc/web.api.md b/packages/moss-agent/etc/web.api.md new file mode 100644 index 00000000..4c936eb3 --- /dev/null +++ b/packages/moss-agent/etc/web.api.md @@ -0,0 +1,349 @@ +## API Report File for "@rdk-moss/agent" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +// @beta +export const MOSS_WEB_SLOTS: readonly ["navigation.primary", "navigation.session", "navigation.footer", "conversation.header", "conversation.message", "conversation.composer", "conversation.details", "tool.inline", "tool.details", "settings.section", "settings.plugin"]; + +// @beta +export const MOSS_WEB_THEME_TOKENS: readonly ["--moss-color-canvas", "--moss-color-surface", "--moss-color-panel", "--moss-color-panel-raised", "--moss-color-border", "--moss-color-border-strong", "--moss-color-text", "--moss-color-muted", "--moss-color-faint", "--moss-color-accent", "--moss-color-accent-hover", "--moss-color-accent-soft", "--moss-color-danger", "--moss-color-danger-soft", "--moss-color-warning", "--moss-color-warning-soft", "--moss-color-success", "--moss-color-success-soft", "--moss-color-overlay", "--moss-color-terminal", "--moss-color-terminal-border", "--moss-color-terminal-muted", "--moss-color-terminal-prompt", "--moss-color-terminal-text", "--moss-font-family-body", "--moss-font-family-display", "--moss-font-family-code", "--moss-font-size-1", "--moss-font-size-2", "--moss-font-size-3", "--moss-font-size-4", "--moss-font-size-5", "--moss-font-size-6", "--moss-font-size-display", "--moss-font-weight-regular", "--moss-font-weight-medium", "--moss-font-weight-bold", "--moss-line-height-tight", "--moss-line-height-body", "--moss-line-height-code", "--moss-space-1", "--moss-space-2", "--moss-space-3", "--moss-space-4", "--moss-space-5", "--moss-space-6", "--moss-space-8", "--moss-space-10", "--moss-space-12", "--moss-radius-control", "--moss-radius-small", "--moss-radius-card", "--moss-radius-panel", "--moss-radius-round", "--moss-shadow-control", "--moss-shadow-composer", "--moss-shadow-float", "--moss-shadow-dialog", "--moss-control-height", "--moss-control-height-small", "--moss-motion-fast", "--moss-motion-standard", "--moss-motion-slow", "--moss-ease-standard", "--moss-z-base", "--moss-z-header", "--moss-z-drawer", "--moss-z-dialog", "--moss-z-toast", "--moss-z-tooltip", "--moss-state-idle", "--moss-state-running", "--moss-state-success", "--moss-state-warning", "--moss-state-error", "--moss-state-interrupted"]; + +// @beta +export interface MossPluginConfigPropertySchema extends MossPluginJsonSchema { + readonly writeOnly?: boolean; +} + +// @beta +export interface MossPluginConfigSchema extends MossPluginJsonSchema { + // (undocumented) + readonly properties?: Readonly>; + // (undocumented) + readonly type: 'object'; +} + +// @beta +export interface MossPluginConfigView { + // (undocumented) + readonly secrets: Readonly>; + // (undocumented) + readonly values: Readonly>; +} + +// @beta +export interface MossPluginJsonSchema { + // (undocumented) + $defs?: Record; + // (undocumented) + $ref?: string; + // (undocumented) + additionalProperties?: boolean | MossPluginJsonSchema; + // (undocumented) + allOf?: MossPluginJsonSchema[]; + // (undocumented) + anyOf?: MossPluginJsonSchema[]; + // (undocumented) + const?: unknown; + // (undocumented) + default?: unknown; + // (undocumented) + definitions?: Record; + // (undocumented) + description?: string; + // (undocumented) + else?: MossPluginJsonSchema; + // (undocumented) + enum?: unknown[]; + // (undocumented) + format?: string; + // (undocumented) + if?: MossPluginJsonSchema; + // (undocumented) + items?: MossPluginJsonSchema | MossPluginJsonSchema[]; + // (undocumented) + maximum?: number; + // (undocumented) + maxItems?: number; + // (undocumented) + maxLength?: number; + // (undocumented) + minimum?: number; + // (undocumented) + minItems?: number; + // (undocumented) + minLength?: number; + // (undocumented) + not?: MossPluginJsonSchema; + // (undocumented) + oneOf?: MossPluginJsonSchema[]; + // (undocumented) + pattern?: string; + // (undocumented) + properties?: Record; + // (undocumented) + required?: string[]; + // (undocumented) + then?: MossPluginJsonSchema; + // (undocumented) + title?: string; + // (undocumented) + type?: string | string[]; + // (undocumented) + uniqueItems?: boolean; +} + +// @beta +export interface MossWebAttachmentSummary { + // (undocumented) + readonly createdAt: number; + // (undocumented) + readonly downloadUrl: string; + // (undocumented) + readonly filename: string; + // (undocumented) + readonly id: string; + // (undocumented) + readonly kind: 'text' | 'image' | 'artifact'; + // (undocumented) + readonly mimeType: string; + // (undocumented) + readonly size: number; +} + +// @beta +export interface MossWebBootstrap { + // (undocumented) + readonly model: string; + // (undocumented) + readonly pluginComposition: MossWebPluginComposition; + // (undocumented) + readonly plugins: readonly MossWebPluginSnapshot[]; + // (undocumented) + readonly taskRuns: readonly MossWebTaskRunSnapshot[]; + // (undocumented) + readonly tools: readonly string[]; +} + +// @beta +export interface MossWebInstalledPlugin { + // (undocumented) + readonly enabled: boolean; + // (undocumented) + readonly id: string; + // (undocumented) + readonly version: string; +} + +// @beta +export type MossWebInteractionKind = 'approval' | 'question'; + +// @beta +export type MossWebInteractionState = 'pending' | 'resolved' | 'cancelled'; + +// @beta +export interface MossWebJournalEvent { + // (undocumented) + readonly event: MossWebStreamEvent; + // (undocumented) + readonly runId: string; + // (undocumented) + readonly seq: number; + // (undocumented) + readonly sessionId: string; + // (undocumented) + readonly time: number; +} + +// @beta +export interface MossWebPendingInteraction { + // (undocumented) + readonly createdAt: number; + // (undocumented) + readonly id: string; + // (undocumented) + readonly kind: MossWebInteractionKind; + // (undocumented) + readonly prompt: string; + // (undocumented) + readonly resolvedAt?: number; + // (undocumented) + readonly state: MossWebInteractionState; +} + +// @beta +export interface MossWebPluginComposition { + // (undocumented) + readonly generation: number; + // (undocumented) + readonly plugins: readonly MossWebPluginSnapshot[]; +} + +// @beta +export interface MossWebPluginConfigResponse { + // (undocumented) + readonly config: MossPluginConfigView; + // (undocumented) + readonly generation: number; + // (undocumented) + readonly restartRequired: false; + // (undocumented) + readonly schema: MossPluginConfigSchema; +} + +// @beta +export interface MossWebPluginModule { + // (undocumented) + mount(root: ShadowRoot, context: MossWebPluginMountContext): void | (() => void) | Promise void)>; +} + +// @beta +export interface MossWebPluginMountContext { + readonly componentsUrl: '/assets/moss-web-components.js'; + // (undocumented) + readonly contributionId: string; + readonly owner?: { + readonly kind: 'workspace' | 'session' | 'message' | 'tool' | 'settings'; + readonly id: string; + readonly data?: unknown; + }; + // (undocumented) + readonly pluginId: string; + // (undocumented) + readonly slot: MossWebSlot; +} + +// @beta +export interface MossWebPluginSnapshot { + // (undocumented) + readonly callState: 'accepting' | 'draining' | 'disposed'; + // (undocumented) + readonly commands: readonly string[]; + // (undocumented) + readonly id: string; + // (undocumented) + readonly mcpPresets: readonly string[]; + // (undocumented) + readonly providers: readonly string[]; + // (undocumented) + readonly state: string; + // (undocumented) + readonly tools: readonly string[]; + // (undocumented) + readonly webContributions: readonly string[]; +} + +// @beta +export interface MossWebSessionSearchHit extends MossWebSessionSummary { + // (undocumented) + readonly snippet: string; +} + +// @beta +export interface MossWebSessionSummary { + // (undocumented) + readonly messageCount: number; + // (undocumented) + readonly runId?: string; + // (undocumented) + readonly runStatus?: string; + // (undocumented) + readonly sessionId: string; + // (undocumented) + readonly title: string; + // (undocumented) + readonly updatedAt: number; +} + +// @beta +export type MossWebSlot = (typeof MOSS_WEB_SLOTS)[number]; + +// @beta +export type MossWebStreamEvent = { + readonly type: 'run'; + readonly run: MossWebTaskRunSnapshot; +} | { + readonly type: 'text'; + readonly delta: string; +} | { + readonly type: 'thought'; + readonly delta: string; +} | { + readonly type: 'tool'; + readonly state: 'start' | 'end'; + readonly toolCallId: string; + readonly name: string; + readonly input?: Readonly>; + readonly result?: string; + readonly isError?: boolean; +} | { + readonly type: 'done'; + readonly stopReason: string; + readonly run?: MossWebTaskRunSnapshot; +} | { + readonly type: 'retry'; + readonly attempt: number; + readonly error: string; +} | { + readonly type: 'compaction'; + readonly summaryChars: number; + readonly droppedMessages: number; + readonly checkpointOutline?: readonly string[]; +} | { + readonly type: 'usage'; + readonly inputTokens: number; + readonly outputTokens: number; + readonly cacheReadTokens?: number; + readonly cacheCreationTokens?: number; + readonly contextTokens?: number; +} | { + readonly type: 'context'; + readonly status: string; + readonly reason: string; + readonly goal: string; + readonly nextAction: string; +} | { + readonly type: 'interrupted'; + readonly reason: string; + readonly run: MossWebTaskRunSnapshot; +} | { + readonly type: 'error'; + readonly message: string; +}; + +// @beta +export interface MossWebTaskRunSnapshot { + // (undocumented) + readonly evidenceCount: number; + // (undocumented) + readonly id: string; + // (undocumented) + readonly sessionId: string; + // (undocumented) + readonly status: string; + // (undocumented) + readonly title: string; + // (undocumented) + readonly updatedAt: number; + // (undocumented) + readonly verification: string; +} + +// @beta +export type MossWebThemeToken = (typeof MOSS_WEB_THEME_TOKENS)[number]; + +// @beta +export interface MossWebWorkspaceSummary { + // (undocumented) + readonly current: boolean; + // (undocumented) + readonly id: string; + // (undocumented) + readonly name: string; +} + +// (No @packageDocumentation comment for this package) + +``` diff --git a/packages/moss-agent/package.json b/packages/moss-agent/package.json index 58f9db17..826d6d65 100644 --- a/packages/moss-agent/package.json +++ b/packages/moss-agent/package.json @@ -130,6 +130,11 @@ "types": "./dist/runtime/index.d.ts", "import": "./dist/runtime/index.js", "default": "./dist/runtime/index.js" + }, + "./web": { + "types": "./dist/web-ui/index.d.ts", + "import": "./dist/web-ui/index.js", + "default": "./dist/web-ui/index.js" } }, "publishConfig": { @@ -165,7 +170,8 @@ "homepage": "https://github.com/D-Robotics/moss/tree/main/packages/moss-agent#readme", "scripts": { "prebuild": "npm run build --workspace @rdk-moss/core", - "build": "npm run clean && tsc -p tsconfig.build.json && node -e \"const fs=require('fs');fs.chmodSync('dist/cli.js',0o755)\"", + "build": "npm run clean && tsc -p tsconfig.build.json && npm run build:web && node -e \"const fs=require('fs');fs.chmodSync('dist/cli.js',0o755)\"", + "build:web": "vite build --config web-vite.config.mjs && vite build --config web-components-vite.config.mjs", "pretypecheck": "npm run build --workspace @rdk-moss/core", "typecheck": "tsc --noEmit", "clean": "node -e \"const fs=require('fs');fs.rmSync('dist',{recursive:true,force:true})\"", @@ -190,25 +196,36 @@ "@opentelemetry/sdk-trace-base": "^2.9.0", "@opentelemetry/semantic-conventions": "^1.43.0", "@rdk-moss/core": "^0.6.0", + "highlight.js": "^10.7.3", "ink": "^7.0.5", "marked": "^5.1.0", "marked-terminal": "^7.0.0", "micromatch": "^4.0.8", "picocolors": "^1.1.1", + "proper-lockfile": "4.1.2", "puppeteer-core": "^25.1.0", "react": "^19.2.6", + "react-dom": "^19.2.8", "string-width": "^8.2.1", "turndown": "^7.2.0", "undici": "^7.29.0", - "highlight.js": "^10.7.3" + "yaml": "^2.9.0" }, "devDependencies": { + "@axe-core/playwright": "^4.13.0", "@types/marked-terminal": "^6.1.1", "@types/micromatch": "^4.0.10", + "@types/pngjs": "^6.0.5", + "@types/proper-lockfile": "4.1.4", "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.4", "@types/turndown": "^5.0.5", "ink-testing-library": "^4.0.0", - "typescript": "^5.7.3" + "pixelmatch": "^7.2.0", + "playwright": "^1.62.1", + "pngjs": "^7.0.0", + "typescript": "^5.7.3", + "vite": "^7.3.6" }, "peerDependencies": { "zod": ">=3.0.0" diff --git a/packages/moss-agent/src/channels/index.ts b/packages/moss-agent/src/channels/index.ts index 1c92cb6c..f43a3259 100644 --- a/packages/moss-agent/src/channels/index.ts +++ b/packages/moss-agent/src/channels/index.ts @@ -12,4 +12,10 @@ export type { ProviderErrorCategory, ProviderErrorSurface, } from '../provider/index.js'; -export type { MossPluginDisposer, MossPluginHost } from '../core/plugins/plugin-host.js'; +export { MOSS_WEB_SLOTS } from '../core/plugins/plugin-host.js'; +export type { + MossPluginDisposer, + MossPluginHost, + MossWebContribution, + MossWebSlot, +} from '../core/plugins/plugin-host.js'; diff --git a/packages/moss-agent/src/cli-main.ts b/packages/moss-agent/src/cli-main.ts index 538d8e09..763fa16c 100644 --- a/packages/moss-agent/src/cli-main.ts +++ b/packages/moss-agent/src/cli-main.ts @@ -43,7 +43,7 @@ import { createMemoryTools } from './cli/tools.js'; import { ExperienceLog } from './memory/experience-log.js'; import { createObjectiveVerifierHook } from './core/tools/objective-verifier-hook.js'; import { makeReadonlyExecutor } from './core/tools/device-readonly-executor.js'; -import { SkillRegistry } from './skills/registry.js'; +import { resolveDefaultSkillRoots, SkillRegistry } from './skills/registry.js'; import { ContractRegistry } from './acceptance/contract-registry.js'; import { PromotionCoordinator, @@ -141,6 +141,7 @@ import { getCommandConfig, type CommandContext, } from './cli/command-dispatcher.js'; +import { installConfiguredPlugins } from './cli/plugins-runtime.js'; // Argument errors must be a one-line message, not an uncaught stack trace // (`moss -m` used to dump a raw Node throw at module load). @@ -773,9 +774,9 @@ async function main() { const cliLlmProvider = parsedArgs.mock ? createMockLLMProvider() : createCliProvider(providerConfig); - - // Initialize observability (OTel tracing + metrics + local file trace) based on env. - // No-op when MOSS_OTEL_ENABLED is unset and MOSS_OTEL_URL absent. + const skillRoots = resolveDefaultSkillRoots(loadedConfig.config.skills?.extraRoots); + const skillRegistry = new SkillRegistry({ workspaceDir: workspace, extraDirs: skillRoots }); + // Initialize observability; this is a no-op unless its environment settings are present. initObservability({ workspaceDir: workspace }); // 终态审计依赖(P0):提前声明引用,后面(行 787+)建好 experienceLog/deviceExecutor/ @@ -866,6 +867,7 @@ async function main() { enableToolOutputTruncation: true, extraPromptLayers, skillPipeline, + skillRegistry, // Coding is the primary CLI workload. Inject the compact software- // engineering domain prompt into the stable system prompt so every coding // turn gets "read before edit → minimal verifiable change → close the loop" @@ -1012,15 +1014,13 @@ async function main() { hooks, }); await registerBuiltinTools(agent); + await installConfiguredPlugins(agent, configDir); // File-based custom tools from .moss/tools/*.tool.json — the lightweight // path for users who want a named, schema-validated tool without an MCP // server or embedder code. for (const tool of loadFileBasedTools(workspace)) agent.tools.register(tool); - // Lets the agent answer "which model are you?" with the gateway's real backing - // model instead of the "Moss" billing placeholder (resolved on demand + cached). - // The getContextTokens getter is dynamic so it reflects the startup-probe result - // (which may update agent.config.contextTokens after tool registration). - agent.tools.register( + // Answers model-identity questions from the gateway and tracks later context-window probes. + agent.tools.replace( createModelInfoTool({ provider: () => agent.config.llmProvider, config: () => ({ @@ -1037,7 +1037,7 @@ async function main() { // SSRF block ONLY for the connected /connect target (getter → tracks live // /connect), so a board's LAN web UI (http://192.168.x.y:port) is reachable // while the rest of the private network stays blocked. - agent.tools.register( + agent.tools.replace( createWebFetchTool({ allowPrivateHosts: () => (liveRuntime.device?.host ? [liveRuntime.device.host] : []), }) @@ -1049,7 +1049,7 @@ async function main() { // re-registration; without merging the key back in, ToolRegistry.register // (overwrite-by-name) silently drops the bundled key and every search falls // back to the keyless chain — a packaged/configured key never actually worked. - agent.tools.register( + agent.tools.replace( createWebSearchTool({ ...(searchRegion ? { region: searchRegion } : {}), ...(bundledBochaKey ? { bochaApiKey: bundledBochaKey } : {}), @@ -1135,8 +1135,7 @@ async function main() { // T3.1 验收契约:加载所有 skill 的 ACCEPTANCE.json,建 tool→contract 反查索引(解 C)。 // hook 收到工具调用 → findByTool → 有契约跑 postconditions 产 L1 判定(D4 层1 主判据)。 // 解 A(PlanStep.expectedAccept):有 plan 时按 step 引用的 skill 契约验收,优先于解 C。 - const skillRegistryForContracts = new SkillRegistry({ workspaceDir: workspace }); - const contractRegistry = ContractRegistry.fromSkills(skillRegistryForContracts.list()); + const contractRegistry = ContractRegistry.fromSkills(skillRegistry.list()); // session-aware planProvider:hook 和 terminal gate 都按各自 sessionKey 读取活跃 Plan。 const planProvider = { get: getActivePlanForSession }; agent.registerPostToolHook( diff --git a/packages/moss-agent/src/cli/approval.ts b/packages/moss-agent/src/cli/approval.ts index 3fbafb04..5ce520b4 100644 --- a/packages/moss-agent/src/cli/approval.ts +++ b/packages/moss-agent/src/cli/approval.ts @@ -8,8 +8,19 @@ import { assertSandboxPath } from '../safety/sandbox-paths.js'; import { sanitizeSecrets } from '../safety/secret-sanitizer.js'; import { normalizeSafetyModeConfig, type ConfigApprovalPolicy } from './config.js'; import { buildApprovalDetailLines, type ApprovalDetailContext } from './approval-detail.js'; +import { getCliInteractionMode, type CliInteractionMode } from './interaction-mode.js'; import type { CliDetailMode } from './output.js'; +export { + formatCliInteractionModeLabel, + getCliInteractionMode, + inferCliInteractionModeFromMessages, + parseCliInteractionMode, + setCliInteractionMode, + subscribeCliInteractionMode, + type CliInteractionMode, +} from './interaction-mode.js'; + export type CliSafetyMode = 'read-only' | 'workspace-write' | 'full-access'; export type AskUser = (question: string, abortSignal?: AbortSignal) => Promise; @@ -19,123 +30,6 @@ let interactiveAsker: AskUser | null = null; * instead of the y/a/n permission chooser (which swallows numbered answers). */ let interactiveUserQuestionAsker: AskUser | null = null; -export type CliInteractionMode = 'plan' | 'default' | 'acceptEdits'; - -let currentInteractionMode: CliInteractionMode = 'default'; -const interactionModeListeners = new Set<(mode: CliInteractionMode) => void>(); - -export function setCliInteractionMode(mode: CliInteractionMode): void { - if (currentInteractionMode === mode) return; - currentInteractionMode = mode; - for (const listener of interactionModeListeners) { - try { - listener(mode); - } catch { - // listeners must not break mode transitions - } - } -} - -export function getCliInteractionMode(): CliInteractionMode { - return currentInteractionMode; -} - -/** Subscribe to interaction-mode changes (plan / default / acceptEdits). */ -export function subscribeCliInteractionMode( - listener: (mode: CliInteractionMode) => void -): () => void { - interactionModeListeners.add(listener); - return () => { - interactionModeListeners.delete(listener); - }; -} - -export function formatCliInteractionModeLabel(mode: CliInteractionMode, zh = false): string { - if (mode === 'plan') return zh ? '计划模式' : 'plan'; - if (mode === 'acceptEdits') return zh ? '自动接受编辑' : 'accept-edits'; - return zh ? '默认' : 'default'; -} - -export function parseCliInteractionMode(raw: string | undefined): CliInteractionMode | null { - const token = String(raw ?? '') - .trim() - .toLowerCase() - .replace(/[_\s]+/g, '-'); - if (!token) return null; - if (token === 'plan' || token === 'p' || token === '计划' || token === '计划模式') { - return 'plan'; - } - if ( - token === 'default' || - token === 'd' || - token === 'normal' || - token === '默认' || - token === '默认模式' - ) { - return 'default'; - } - if ( - token === 'accept-edits' || - token === 'acceptedits' || - token === 'accept' || - token === 'auto' || - token === 'a' || - token === '自动接受' || - token === '自动接受编辑' - ) { - return 'acceptEdits'; - } - return null; -} - -/** - * Best-effort interaction mode recovery from session history (no extra storage). - * Newest signal wins: - * - "Left plan mode → default" / switched-to-default text → default - * - user prompt prefixed with Moss plan-mode header → plan - * - explicit /mode plan|default|accept-edits user text → that mode - * Returns null when no signal is found. - */ -export function inferCliInteractionModeFromMessages( - messages: ReadonlyArray<{ role?: string; content?: unknown }> -): CliInteractionMode | null { - for (let i = messages.length - 1; i >= 0; i--) { - const m = messages[i]; - if (!m) continue; - const texts: string[] = []; - if (typeof m.content === 'string') { - texts.push(m.content); - } else if (Array.isArray(m.content)) { - for (const block of m.content) { - if (!block || typeof block !== 'object') continue; - const b = block as { type?: string; text?: string; content?: unknown }; - if (typeof b.text === 'string') texts.push(b.text); - if (typeof b.content === 'string') texts.push(b.content); - } - } - for (const text of texts) { - const head = text.slice(0, 240); - if ( - /Left plan mode\s*(→|->)\s*default/i.test(text) || - /已切换到默认/i.test(text) || - /Switched to default\b/i.test(text) - ) { - return 'default'; - } - if (m.role === 'user' && (/^\[Plan mode\]/m.test(head) || /^\[计划模式\]/m.test(head))) { - return 'plan'; - } - if (m.role === 'user') { - const cmd = text.trim().toLowerCase(); - if (/^\/mode\s+plan\b/.test(cmd) || cmd === '/plan') return 'plan'; - if (/^\/mode\s+accept-?edits\b/.test(cmd)) return 'acceptEdits'; - if (/^\/mode\s+default\b/.test(cmd)) return 'default'; - } - } - } - return null; -} - export interface CliToolApprovalOptions { approvalPolicy?: ConfigApprovalPolicy; trustedTools?: readonly string[]; @@ -155,6 +49,12 @@ export interface CliToolApprovalOptions { detailMode?: CliDetailMode; } +/** A CLI policy hook whose interactive asker can be scoped by an embedding host. @beta */ +export type CliToolApprovalHook = NonNullable & { + setAsker(asker: AskUser | null): () => void; + setInteractionModeResolver(resolve: (() => CliInteractionMode) | null): () => void; +}; + export interface CliToolApprovalPreview { toolName: string; sideEffect: ToolSideEffectClass; @@ -826,14 +726,18 @@ export function createCliToolApprovalHook( mode: CliSafetyMode, env: NodeJS.ProcessEnv = process.env, options: CliToolApprovalOptions = {} -): NonNullable { +): CliToolApprovalHook { const sessionTrustedTools = new Set(); const sessionTrustedWorkspaces = new Set(); const workspaceRoot = workspaceTrustRoot(options.workspaceDir); + let instanceAsker: AskUser | null = null; + let instanceInteractionMode: (() => CliInteractionMode) | null = null; let headlessNoticeShown = false; - return async (request: ToolApprovalRequest) => { + const hook: NonNullable = async ( + request: ToolApprovalRequest + ) => { const { tool } = request; const liveMode = options.safetyModeOverride?.() ?? mode; @@ -865,7 +769,8 @@ export function createCliToolApprovalHook( reason: `Tool "${tool.name}" is blocked by configured deniedTools.`, }; } - const interaction = options.interactionMode?.() ?? getCliInteractionMode(); + const interaction = + instanceInteractionMode?.() ?? options.interactionMode?.() ?? getCliInteractionMode(); // Plan mode blocks side-effecting tools unless metadata.planMode === 'allow' // (e.g. todo_write / ask_user_question / plan / plan_step). Do not treat every // non-readonly sideEffect as a hard block — that ignored the documented contract. @@ -919,7 +824,8 @@ export function createCliToolApprovalHook( return { approved: true }; } - if (!process.stdin.isTTY && interactiveAsker === null) { + const asker = instanceAsker ?? interactiveAsker; + if (!process.stdin.isTTY && asker === null) { if (options.detailMode !== 'quiet' && !headlessNoticeShown) { console.error( `[moss] Approval required but no interactive terminal is available: ${tool.name}` @@ -938,7 +844,7 @@ export function createCliToolApprovalHook( workspaceDir: options.workspaceDir, device: options.device, }); - const answer = (await (interactiveAsker ?? defaultAskUser)(prompt, request.abortSignal)) + const answer = (await (asker ?? defaultAskUser)(prompt, request.abortSignal)) .trim() .toLowerCase(); if (answer === 'a' || answer === 'always') { @@ -955,4 +861,38 @@ export function createCliToolApprovalHook( } return { approved: false, reason: `User denied ${tool.name}.` }; }; + const configurable = hook as CliToolApprovalHook; + configurable.setAsker = (asker: AskUser | null) => { + instanceAsker = asker; + return () => { + if (instanceAsker === asker) instanceAsker = null; + }; + }; + configurable.setInteractionModeResolver = (resolve: (() => CliInteractionMode) | null) => { + instanceInteractionMode = resolve; + return () => { + if (instanceInteractionMode === resolve) instanceInteractionMode = null; + }; + }; + return configurable; +} + +/** Bind an asker only when the hook was created by {@link createCliToolApprovalHook}. @beta */ +export function setCliToolApprovalHookAsker( + hook: AgentHooks['onBeforeToolExec'] | undefined, + asker: AskUser +): () => void { + const configurable = hook as Partial | undefined; + return typeof configurable?.setAsker === 'function' ? configurable.setAsker(asker) : () => {}; +} + +/** Bind an interaction mode only when the hook was created by {@link createCliToolApprovalHook}. @beta */ +export function setCliToolApprovalHookInteractionMode( + hook: AgentHooks['onBeforeToolExec'] | undefined, + resolve: () => CliInteractionMode +): () => void { + const configurable = hook as Partial | undefined; + return typeof configurable?.setInteractionModeResolver === 'function' + ? configurable.setInteractionModeResolver(resolve) + : () => {}; } diff --git a/packages/moss-agent/src/cli/args.ts b/packages/moss-agent/src/cli/args.ts index f4fbc4d4..7a430556 100644 --- a/packages/moss-agent/src/cli/args.ts +++ b/packages/moss-agent/src/cli/args.ts @@ -20,6 +20,7 @@ export type CliCommand = | 'resume' | 'fork' | 'mcp' + | 'plugins' | 'migrate' | 'sessions' | 'web' @@ -196,6 +197,7 @@ const KNOWN_COMMANDS: readonly CliCommand[] = [ 'resume', 'fork', 'mcp', + 'plugins', 'migrate', 'sessions', 'web', diff --git a/packages/moss-agent/src/cli/command-dispatcher.ts b/packages/moss-agent/src/cli/command-dispatcher.ts index 2599a456..de426bd4 100644 --- a/packages/moss-agent/src/cli/command-dispatcher.ts +++ b/packages/moss-agent/src/cli/command-dispatcher.ts @@ -331,6 +331,15 @@ export const COMMANDS: Record = { }, }, + plugins: { + name: 'plugins', + phase: CliPhase.ConfigOnly, + handler: async (ctx) => { + const { runPluginsCommand } = await import('./plugins-command.js'); + await runPluginsCommand(ctx.commandArgs); + }, + }, + doctor: { name: 'doctor', phase: CliPhase.ConfigOnly, diff --git a/packages/moss-agent/src/cli/device-connect.ts b/packages/moss-agent/src/cli/device-connect.ts index da3c35f7..c8bda115 100644 --- a/packages/moss-agent/src/cli/device-connect.ts +++ b/packages/moss-agent/src/cli/device-connect.ts @@ -362,8 +362,11 @@ export async function connectDeviceForSession( } for (const tool of sessionTools) { - agent.tools.remove(tool.name); - agent.tools.register(tool); + if (mode === 'board' && BOARD_REPLACED_TOOL_NAMES.some((name) => name === tool.name)) { + agent.tools.replace(tool, 'device:board-session'); + } else { + agent.tools.register(tool, 'device:session'); + } } let promptLayer: string | undefined; diff --git a/packages/moss-agent/src/cli/help.ts b/packages/moss-agent/src/cli/help.ts index d07b83d6..7e1b05ed 100644 --- a/packages/moss-agent/src/cli/help.ts +++ b/packages/moss-agent/src/cli/help.ts @@ -137,6 +137,9 @@ export function displayHelp(c: Colors, options: { all?: boolean } = {}): void { ` ${c.green('mcp list')} show configured MCP servers`, ` ${c.green('mcp add')} ${c.dim(' [args...]')} register an MCP server (no JSON editing)`, ` ${c.green('mcp remove')} ${c.dim('')} remove a configured MCP server`, + ` ${c.green('plugins list')} list explicitly installed trusted plugins`, + ` ${c.green('plugins add')} ${c.dim('')} install a local or npm Moss plugin`, + ` ${c.green('plugins doctor')} validate installed plugin manifests and runtime exports`, ` ${c.green('config')} show resolved config values and sources`, ` ${c.green('config show')} same as config; safe for scripts`, ` ${c.green('config show --json')} emit redacted resolved config JSON`, diff --git a/packages/moss-agent/src/cli/host-command.ts b/packages/moss-agent/src/cli/host-command.ts index 3a10ad68..59780e40 100644 --- a/packages/moss-agent/src/cli/host-command.ts +++ b/packages/moss-agent/src/cli/host-command.ts @@ -3,6 +3,7 @@ import type { MossAgent } from '../core/agent/moss-agent.js'; import { startMossWebServer } from '../web-ui/web-server.js'; import { runAcpStdioServer } from './acp-server.js'; import type { ParsedCliArgs } from './args.js'; +import { resolveConfigDir } from './config.js'; /** Run a long-lived host transport after the CLI agent has been fully composed. @internal */ export async function runCliHostCommand( @@ -44,6 +45,7 @@ export async function runCliHostCommand( port, abortSignal: abort.signal, taskRunFile, + configDir: resolveConfigDir(), }); console.error(`[web] Moss is ready at ${web.url}`); console.error('[web] Press Ctrl+C to stop. Provider credentials stay in this process.'); diff --git a/packages/moss-agent/src/cli/interaction-mode.ts b/packages/moss-agent/src/cli/interaction-mode.ts new file mode 100644 index 00000000..5f8f7541 --- /dev/null +++ b/packages/moss-agent/src/cli/interaction-mode.ts @@ -0,0 +1,117 @@ +/** CLI interaction modes shared by terminal and embedded hosts. */ +export type CliInteractionMode = 'plan' | 'default' | 'acceptEdits'; + +let currentInteractionMode: CliInteractionMode = 'default'; +const interactionModeListeners = new Set<(mode: CliInteractionMode) => void>(); + +export function setCliInteractionMode(mode: CliInteractionMode): void { + if (currentInteractionMode === mode) return; + currentInteractionMode = mode; + for (const listener of interactionModeListeners) { + try { + listener(mode); + } catch { + // listeners must not break mode transitions + } + } +} + +export function getCliInteractionMode(): CliInteractionMode { + return currentInteractionMode; +} + +/** Subscribe to interaction-mode changes (plan / default / acceptEdits). */ +export function subscribeCliInteractionMode( + listener: (mode: CliInteractionMode) => void +): () => void { + interactionModeListeners.add(listener); + return () => { + interactionModeListeners.delete(listener); + }; +} + +export function formatCliInteractionModeLabel(mode: CliInteractionMode, zh = false): string { + if (mode === 'plan') return zh ? '计划模式' : 'plan'; + if (mode === 'acceptEdits') return zh ? '自动接受编辑' : 'accept-edits'; + return zh ? '默认' : 'default'; +} + +export function parseCliInteractionMode(raw: string | undefined): CliInteractionMode | null { + const token = String(raw ?? '') + .trim() + .toLowerCase() + .replace(/[_\s]+/g, '-'); + if (!token) return null; + if (token === 'plan' || token === 'p' || token === '计划' || token === '计划模式') { + return 'plan'; + } + if ( + token === 'default' || + token === 'd' || + token === 'normal' || + token === '默认' || + token === '默认模式' + ) { + return 'default'; + } + if ( + token === 'accept-edits' || + token === 'acceptedits' || + token === 'accept' || + token === 'auto' || + token === 'a' || + token === '自动接受' || + token === '自动接受编辑' + ) { + return 'acceptEdits'; + } + return null; +} + +/** + * Best-effort interaction mode recovery from session history (no extra storage). + * Newest signal wins: + * - "Left plan mode → default" / switched-to-default text → default + * - user prompt prefixed with Moss plan-mode header → plan + * - explicit /mode plan|default|accept-edits user text → that mode + * Returns null when no signal is found. + */ +export function inferCliInteractionModeFromMessages( + messages: ReadonlyArray<{ role?: string; content?: unknown }> +): CliInteractionMode | null { + for (let i = messages.length - 1; i >= 0; i--) { + const m = messages[i]; + if (!m) continue; + const texts: string[] = []; + if (typeof m.content === 'string') { + texts.push(m.content); + } else if (Array.isArray(m.content)) { + for (const block of m.content) { + if (!block || typeof block !== 'object') continue; + const b = block as { type?: string; text?: string; content?: unknown }; + if (typeof b.text === 'string') texts.push(b.text); + if (typeof b.content === 'string') texts.push(b.content); + } + } + for (const text of texts) { + const head = text.slice(0, 240); + if ( + /Left plan mode\s*(→|->)\s*default/i.test(text) || + /已切换到默认/i.test(text) || + /Switched to default\b/i.test(text) + ) { + return 'default'; + } + if (m.role === 'user' && (/^\[Plan mode\]/m.test(head) || /^\[计划模式\]/m.test(head))) { + return 'plan'; + } + if (m.role === 'user') { + const cmd = text.trim().toLowerCase(); + if (/^\/mode\s+plan\b/.test(cmd) || cmd === '/plan') return 'plan'; + if (/^\/mode\s+accept-?edits\b/.test(cmd)) return 'acceptEdits'; + if (/^\/mode\s+default\b/.test(cmd)) return 'default'; + } + } + } + return null; +} diff --git a/packages/moss-agent/src/cli/oneshot.ts b/packages/moss-agent/src/cli/oneshot.ts index 6c40b475..bbf5294c 100644 --- a/packages/moss-agent/src/cli/oneshot.ts +++ b/packages/moss-agent/src/cli/oneshot.ts @@ -499,7 +499,9 @@ export async function runOneShot( let skillCatalogContext = ''; let skillIndexContext = ''; try { - const registry = new SkillRegistry({ workspaceDir: options.cwd ?? process.cwd() }); + const registry = + agent.config.skillRegistry ?? + new SkillRegistry({ workspaceDir: options.cwd ?? process.cwd() }); const composed = await buildComposedSkillContext(registry, message, { config: resolveSessionSkillComposerConfig(undefined, 'host'), environment: { deployment: 'host', hasBoard: false }, diff --git a/packages/moss-agent/src/cli/plugin-interactive-commands.ts b/packages/moss-agent/src/cli/plugin-interactive-commands.ts new file mode 100644 index 00000000..2ccc04ac --- /dev/null +++ b/packages/moss-agent/src/cli/plugin-interactive-commands.ts @@ -0,0 +1,32 @@ +import { reservedBuiltinNames } from './commands/custom-commands.js'; + +interface PluginCommandSource { + listCommands(): readonly { + readonly id: string; + readonly title: string; + readonly description?: string; + }[]; + expandCommand(id: string, args: string): Promise; +} + +export function pluginCommandRows( + plugins: PluginCommandSource +): ReadonlyArray { + return plugins + .listCommands() + .filter(({ id }) => !reservedBuiltinNames().has(`/${id}`)) + .map(({ id, description, title }) => [`/${id}`, description ?? title]); +} + +export async function dispatchPlugin( + message: string, + plugins: PluginCommandSource, + submit: (prompt: string) => void +): Promise { + const [id = '', ...args] = message.slice(1).split(/\s+/); + if (reservedBuiltinNames().has(`/${id}`)) return false; + const expanded = await plugins.expandCommand(id, args.join(' ')); + if (expanded === undefined) return false; + submit(expanded); + return true; +} diff --git a/packages/moss-agent/src/cli/plugins-command.ts b/packages/moss-agent/src/cli/plugins-command.ts new file mode 100644 index 00000000..7c592fe7 --- /dev/null +++ b/packages/moss-agent/src/cli/plugins-command.ts @@ -0,0 +1,86 @@ +import { resolveConfigDir } from './config.js'; +import { ExitCode } from './exit-codes.js'; +import { errorMessage } from '../errors.js'; +import { InstalledPluginRegistry } from '../plugins/installed-plugin-registry.js'; +import { inspectDshPackageCompatibility } from '../plugins/dsh-bundle-compatibility.js'; + +export const PLUGINS_USAGE = + 'Usage: moss plugins |remove |enable |disable |doctor>'; + +/** Run the explicit trusted-plugin management command. @internal */ +export async function runPluginsCommand(args: readonly string[]): Promise { + const registry = new InstalledPluginRegistry({ configDir: resolveConfigDir() }); + const command = args[0] ?? 'list'; + try { + if (command === 'list') { + const entries = await registry.list(); + if (entries.length === 0) { + console.log('No Moss plugins installed.'); + return; + } + console.log('PLUGIN VERSION STATE SOURCE'); + for (const entry of entries) { + console.log( + `${entry.id.padEnd(32)} ${entry.version.padEnd(13)} ${(entry.enabled ? 'enabled' : 'disabled').padEnd(9)} ${entry.source}` + ); + } + return; + } + if (command === 'doctor') { + const report = await registry.doctor(); + if (report.length === 0) console.log('No Moss plugins installed.'); + for (const result of report) + console.log(`${result.status.toUpperCase().padEnd(8)} ${result.id}: ${result.message}`); + if (report.some(({ status }) => status === 'error')) process.exitCode = ExitCode.CONFIG; + return; + } + const target = args[1]; + if (!target) { + console.error(PLUGINS_USAGE); + process.exitCode = ExitCode.USAGE; + return; + } + if (command === 'add') { + const report = await inspectDshPackageCompatibility(target); + if ( + report.compatible || + report.reasons.some((reason) => /unsupported Cordis client UI slots/.test(reason)) + ) { + console.log( + `[plugins] DSH compatibility: ${report.compatible ? 'compatible declarative subset' : 'rejected'}` + ); + for (const reason of report.reasons) console.log(`[plugins] ${reason}`); + if (!report.compatible) { + process.exitCode = ExitCode.CONFIG; + return; + } + if (report.imported.length > 0) { + console.log(`[plugins] Will import: ${report.imported.join(', ')}`); + } + } + const entry = await registry.add(target); + if (entry.lastGood) { + console.log( + `[plugins] Staged ${entry.id}@${entry.version} with ${entry.lastGood.version} retained as last-good (${entry.enabled ? 'enabled' : 'disabled'}).` + ); + } else { + console.log( + `[plugins] Installed ${entry.id}@${entry.version} disabled. Review it, then run: moss plugins enable ${entry.id}` + ); + } + return; + } + if (command === 'remove') await registry.remove(target); + else if (command === 'enable') await registry.enable(target); + else if (command === 'disable') await registry.disable(target); + else { + console.error(PLUGINS_USAGE); + process.exitCode = ExitCode.USAGE; + return; + } + console.log(`[plugins] ${command}d ${target}.`); + } catch (error) { + console.error(`[plugins] ${errorMessage(error)}`); + process.exitCode = ExitCode.CONFIG; + } +} diff --git a/packages/moss-agent/src/cli/plugins-runtime.ts b/packages/moss-agent/src/cli/plugins-runtime.ts new file mode 100644 index 00000000..a127edf0 --- /dev/null +++ b/packages/moss-agent/src/cli/plugins-runtime.ts @@ -0,0 +1,92 @@ +import type { MossAgent } from '../core/agent/moss-agent.js'; +import { errorMessage } from '../errors.js'; +import { InstalledPluginRegistry } from '../plugins/installed-plugin-registry.js'; +import { connectMcpServers, type McpConnection } from '../mcp/index.js'; +import type { LLMProvider } from '../core/llm/llm-provider.js'; + +/** Install enabled trusted plugins while isolating manifest/import failures. @internal */ +export async function installConfiguredPlugins(agent: MossAgent, configDir: string): Promise { + const registry = new InstalledPluginRegistry({ configDir }); + const loaded = await registry.loadEnabled(); + for (const plugin of loaded.plugins) { + try { + await agent.plugins.install(plugin); + } catch (error) { + const installed = (await registry.list()).find(({ id }) => id === plugin.id); + if (installed?.lastGood) { + try { + const restored = await registry.rollback(plugin.id); + await agent.plugins.install(await registry.loadInstalled(plugin.id)); + console.warn( + `[plugins] ${plugin.id}@${installed.version} was isolated; last-good ${restored.version} was restored: ${errorMessage(error)}` + ); + continue; + } catch (recoveryError) { + console.error( + `[plugins] ${plugin.id} candidate and last-good recovery failed: ${errorMessage(recoveryError)}` + ); + continue; + } + } + console.error(`[plugins] ${plugin.id} was isolated: ${errorMessage(error)}`); + } + } + for (const failure of loaded.failures) { + if (failure.recovered) console.warn(`[plugins] ${failure.id}: ${failure.message}`); + else console.error(`[plugins] ${failure.id} was isolated: ${failure.message}`); + } +} + +/** Instantiate a plugin provider through the host's active-call lease. @internal */ +export async function createPluginProvider( + agent: MossAgent, + providerId: string, + config: Readonly> +): Promise { + const provider = await agent.plugins.createProvider(providerId, config); + if (!provider) throw new Error(`plugin provider not found: ${providerId}`); + return provider; +} + +/** Connect a contributed MCP preset and publish its tools into the live agent. @internal */ +export async function registerPluginMcpPresetTools( + agent: MossAgent, + presetId: string +): Promise { + const activated = await agent.plugins.activateMcpPreset(presetId, async (preset) => { + const connections = await connectMcpServers({ + mcpServers: { + [preset.id]: { + command: preset.server.command, + ...(preset.server.args ? { args: [...preset.server.args] } : {}), + ...(preset.server.env ? { env: { ...preset.server.env } } : {}), + ...(preset.server.cwd ? { cwd: preset.server.cwd } : {}), + ...(preset.server.requestTimeoutMs !== undefined + ? { requestTimeoutMs: preset.server.requestTimeoutMs } + : {}), + }, + }, + }); + const toolDisposers: Array<() => void> = []; + try { + for (const connection of connections) { + for (const tool of connection.tools) { + toolDisposers.push(agent.tools.registerScoped(tool, `plugin-mcp:${preset.id}`)); + } + } + } catch (error) { + for (const dispose of toolDisposers.reverse()) dispose(); + await Promise.allSettled(connections.map((connection) => connection.close())); + throw error; + } + return { + value: connections, + dispose: async () => { + for (const dispose of toolDisposers.reverse()) dispose(); + await Promise.allSettled(connections.map((connection) => connection.close())); + }, + }; + }); + if (!activated) throw new Error(`plugin MCP preset not found: ${presetId}`); + return activated; +} diff --git a/packages/moss-agent/src/cli/tui.ts b/packages/moss-agent/src/cli/tui.ts index 186d6e89..db5bfc88 100644 --- a/packages/moss-agent/src/cli/tui.ts +++ b/packages/moss-agent/src/cli/tui.ts @@ -74,6 +74,7 @@ import { } from './commands/registry.js'; import { loadCustomCommands, reservedBuiltinNames } from './commands/custom-commands.js'; import { commandRowsForSlashInput } from './interactive-commands.js'; +import { dispatchPlugin, pluginCommandRows } from './plugin-interactive-commands.js'; import { FileCheckpointStore, checkpointTargetPaths } from './file-checkpoint.js'; import { suggestWorkspaceFiles, @@ -2799,10 +2800,8 @@ export function MossTui({ // dispatch all see the same skill set. const skillRegistryRef = useRef(null); if (!skillRegistryRef.current) { - skillRegistryRef.current = new SkillRegistry({ - workspaceDir: workspace, - extraDirs: resolveSessionSkillRoots(runtime), - }); + const options = { workspaceDir: workspace, extraDirs: resolveSessionSkillRoots(runtime) }; + skillRegistryRef.current = agent.config.skillRegistry ?? new SkillRegistry(options); } // Skill slash commands (/): file-backed skills only, expanded like // custom commands. Guarded against shadowing built-ins AND custom commands. @@ -2819,6 +2818,7 @@ export function MossTui({ ...(customCommandsRef.current ?? []), ...(skillCommandsRef.current ?? []), ].map((command) => [command.name, command.summary] as [string, string]); + const allCommandRows = [...customCommandRows, ...pluginCommandRows(agent.plugins)]; const [input, setInput] = useState(''); const [inputCursor, setInputCursor] = useState(0); const [busy, setBusy] = useState(false); @@ -3811,10 +3811,8 @@ export function MossTui({ } return true; } - // Registry-first dispatch: shared - // commands live in the registry; the legacy chain below shrinks with - // each migration phase. if (message.startsWith('/')) { + if (await dispatchPlugin(message, agent.plugins, submitPromptRef.current)) return true; const handled = await runRegistryCommand( message, { @@ -5734,7 +5732,7 @@ export function MossTui({ const promptRows = promptEditorRowBudget(input, { placeholder: promptPlaceholder(runState), hint: footerHint(runState), - extraCommandRows: customCommandRows, + extraCommandRows: allCommandRows, }); const modelPickerRows = modelPicker ? Math.min(14, modelPicker.list.choices.length + 7) : 0; const sessionPickerRows = sessionPicker ? Math.min(12, sessionPicker.sessions.length + 4) : 0; @@ -5985,7 +5983,7 @@ export function MossTui({ onPasteAttachmentShortcut: () => { void pasteClipboardAttachment(); }, - extraCommandRows: customCommandRows, + extraCommandRows: allCommandRows, workspace, vimEnabled, }), diff --git a/packages/moss-agent/src/core/agent/moss-agent.ts b/packages/moss-agent/src/core/agent/moss-agent.ts index cab45139..dfbd13e8 100644 --- a/packages/moss-agent/src/core/agent/moss-agent.ts +++ b/packages/moss-agent/src/core/agent/moss-agent.ts @@ -190,26 +190,16 @@ export class MossAgent { private disposed = false; private closePromise: Promise | undefined; private readonly activeStreamAdvances = new Set>(); - private steeringEngine: SteeringEngine | null = null; - private readonly remoteCompactProvider = createRemoteCompactProviderFromEnv(); - private readonly toolHooks: ToolHookRegistry; - private readonly packPromptLayers: readonly string[]; - private readonly packHostRequirements: readonly string[]; - private readonly inboxes = new Map(); - private readonly activeRunIds = new Map>(); private readonly sessionCheckpointWrites = new Map>(); - - /** Per-instance run-epoch store used by the agent loop's stream-push guard. - * Each MossAgent instance carries its own Map so parallel MossAgents in - * the same host (which may share sessionKeys) don't stomp each other's - * live streams. See agent-loop-push-guard.ts for the design. */ + private userQuestionAsker?: NonNullable; + /** Per-agent run epochs stop parallel instances overwriting shared-session streams. */ private readonly runEpochStore = new Map(); private readonly approvedPreflightController = new ApprovedPreflightController(); @@ -241,10 +231,15 @@ export class MossAgent { } this.extensions.setKnowledgeRegistry(this.knowledge); - drainPendingGlobalModules(this.knowledge); } - + /** Bind a host-owned question channel to this agent instance. @beta */ + setUserQuestionAsker(asker: NonNullable): () => void { + this.userQuestionAsker = asker; + return () => { + if (this.userQuestionAsker === asker) this.userQuestionAsker = undefined; + }; + } registerKnowledge(module: KnowledgeModule): void { this.knowledge.register(module); } @@ -1462,7 +1457,12 @@ export class MossAgent { } : undefined, toolAbortSignalFor: options?.toolAbortSignalFor, - enrichToolContext: hooks?.enrichToolContext, + enrichToolContext: (baseContext, activeSessionKey) => { + const enriched = hooks?.enrichToolContext?.(baseContext, activeSessionKey) ?? baseContext; + return this.userQuestionAsker + ? { ...enriched, askUserQuestion: this.userQuestionAsker } + : enriched; + }, toolHooks: this.toolHooks, abortSignal, maxOutputTokens, diff --git a/packages/moss-agent/src/core/index.ts b/packages/moss-agent/src/core/index.ts index 3ef9dc94..64c43a3c 100644 --- a/packages/moss-agent/src/core/index.ts +++ b/packages/moss-agent/src/core/index.ts @@ -290,16 +290,24 @@ export { extractToolInvocationFromPlanText } from './tools/index.js'; export type { ExtractedToolInvocation } from './tools/index.js'; export { collectCapabilityPacks } from './packs/capability-pack.js'; +export { MOSS_WEB_SLOTS } from './plugins/plugin-host.js'; export type { CapabilityPack, CapabilityPackContributions } from './packs/capability-pack.js'; export type { + MossPluginCallState, + MossPluginCommand, MossPlugin, MossPluginCompositionSnapshot, MossPluginContext, MossPluginDisposer, MossPluginHandle, MossPluginHost, + MossPluginMcpPreset, + MossPluginProvider, MossPluginSnapshot, MossPluginState, + MossPluginUnloadOptions, + MossWebContribution, + MossWebSlot, } from './plugins/plugin-host.js'; export { ErrorCode } from '../errors.js'; export type { MossErrorOutcome } from '../errors.js'; diff --git a/packages/moss-agent/src/core/plugins/plugin-host-validation.ts b/packages/moss-agent/src/core/plugins/plugin-host-validation.ts new file mode 100644 index 00000000..6f82b161 --- /dev/null +++ b/packages/moss-agent/src/core/plugins/plugin-host-validation.ts @@ -0,0 +1,128 @@ +import { ErrorCode, MossError } from '../../errors.js'; + +import type { MossWebSlot, StagedPlugin } from './plugin-host.js'; + +const PLUGIN_ID_PATTERN = /^[a-z0-9]+(?:[./-][a-z0-9]+)*$/; + +/** Read-only registry view used while validating a staged plugin generation. @internal */ +export interface PluginContributionRegistryView { + readonly hasTool: (id: string) => boolean; + readonly hasSkill: (id: string) => boolean; + readonly hasExpert: (id: string) => boolean; + readonly hasCommand: (id: string) => boolean; + readonly hasProvider: (id: string) => boolean; + readonly hasMcpPreset: (id: string) => boolean; + readonly hasWebContribution: (id: string) => boolean; +} + +/** Create an empty contribution batch for one atomic activation. @internal */ +export function createStagedPlugin(): StagedPlugin { + return { + tools: [], + skills: [], + experts: [], + commands: [], + providers: [], + mcpPresets: [], + promptLayers: [], + webContributions: [], + effects: [], + }; +} + +function duplicate(values: readonly string[]): string | undefined { + const seen = new Set(); + for (const value of values) { + if (seen.has(value)) return value; + seen.add(value); + } + return undefined; +} + +function validateContributionId(id: string, kind: string): void { + if (!PLUGIN_ID_PATTERN.test(id)) throw new Error(`plugin ${kind} id is invalid: ${id}`); +} + +/** Validate the stable identifier of one trusted runtime plugin. @internal */ +export function validatePluginId(id: string): void { + if (!PLUGIN_ID_PATTERN.test(id)) { + throw new MossError({ + code: ErrorCode.USER_INPUT_INVALID, + message: `plugin id must be lowercase and path-like: ${id}`, + }); + } +} + +/** Validate a complete contribution batch before the host publishes it. @internal */ +export function validateStagedContributions( + pluginId: string, + staged: StagedPlugin, + registry: PluginContributionRegistryView, + supportedWebSlots: readonly MossWebSlot[] +): void { + const conflict = + duplicate(staged.tools.map(({ name }) => name)) ?? + duplicate(staged.skills.map((skill) => skill.stableId ?? skill.name)) ?? + duplicate(staged.experts.map(({ id }) => id)) ?? + duplicate(staged.commands.map(({ id }) => id)) ?? + duplicate(staged.providers.map(({ id }) => id)) ?? + duplicate(staged.mcpPresets.map(({ id }) => id)) ?? + duplicate(staged.webContributions.map(({ id }) => id)); + if (conflict) throw new Error(`plugin ${pluginId} contains duplicate contribution: ${conflict}`); + + for (const tool of staged.tools) { + if (!tool.metadata?.sideEffectClass) + throw new Error(`plugin tool ${tool.name} requires side-effect metadata`); + if (registry.hasTool(tool.name)) + throw new Error(`plugin tool already registered: ${tool.name}`); + } + for (const skill of staged.skills) { + const id = skill.stableId ?? skill.name; + if (registry.hasSkill(id)) throw new Error(`plugin skill already registered: ${id}`); + } + for (const expert of staged.experts) { + if (registry.hasExpert(expert.id)) + throw new Error(`plugin expert already registered: ${expert.id}`); + } + for (const command of staged.commands) { + validateContributionId(command.id, 'command'); + if (!command.title.trim() || typeof command.expand !== 'function') { + throw new Error(`plugin command is invalid: ${command.id}`); + } + if (registry.hasCommand(command.id)) { + throw new Error(`plugin command already registered: ${command.id}`); + } + } + for (const provider of staged.providers) { + validateContributionId(provider.id, 'provider'); + if (!provider.displayName.trim() || typeof provider.create !== 'function') { + throw new Error(`plugin provider is invalid: ${provider.id}`); + } + if (registry.hasProvider(provider.id)) { + throw new Error(`plugin provider already registered: ${provider.id}`); + } + } + for (const preset of staged.mcpPresets) { + validateContributionId(preset.id, 'MCP preset'); + if (!preset.displayName.trim() || !preset.server.command.trim()) { + throw new Error(`plugin MCP preset is invalid: ${preset.id}`); + } + if (registry.hasMcpPreset(preset.id)) { + throw new Error(`plugin MCP preset already registered: ${preset.id}`); + } + } + for (const layer of staged.promptLayers) { + if (!layer.trim()) throw new Error(`plugin ${pluginId} contains an empty prompt layer`); + } + for (const contribution of staged.webContributions) { + if (!PLUGIN_ID_PATTERN.test(contribution.id)) + throw new Error(`plugin ${pluginId} contains an invalid web contribution id`); + if (!supportedWebSlots.includes(contribution.slot)) + throw new Error(`plugin ${pluginId} contains an unsupported web contribution slot`); + if (!contribution.module.startsWith('./') || contribution.module.includes('..')) + throw new Error(`plugin ${pluginId} web contribution module must be package-relative`); + if (registry.hasWebContribution(`${pluginId}:${contribution.id}`)) { + throw new Error(`plugin web contribution already registered: ${pluginId}:${contribution.id}`); + } + } +} diff --git a/packages/moss-agent/src/core/plugins/plugin-host.ts b/packages/moss-agent/src/core/plugins/plugin-host.ts index 33012c8c..3789ac53 100644 --- a/packages/moss-agent/src/core/plugins/plugin-host.ts +++ b/packages/moss-agent/src/core/plugins/plugin-host.ts @@ -1,14 +1,21 @@ import { ErrorCode, MossError, wrapAsMoss } from '../../errors.js'; import type { SkillMeta } from '../../skills/types.js'; import { CordisEffectScope } from '../../vendor/cordis/effect-scope.js'; +import type { LLMProvider } from '../llm/llm-provider.js'; import type { SubagentExpertDefinition } from '../subagent/expert-registry.js'; import type { Tool } from '../tools/tool-types.js'; - -const PLUGIN_ID_PATTERN = /^[a-z0-9]+(?:[./-][a-z0-9]+)*$/; +import { + createStagedPlugin, + validatePluginId, + validateStagedContributions, +} from './plugin-host-validation.js'; /** Lifecycle state exposed by the deterministic plugin inspector. @beta */ export type MossPluginState = 'loading' | 'active' | 'unloading' | 'failed' | 'disposed'; +/** Whether a plugin generation accepts calls, drains leases, or is disposed. @beta */ +export type MossPluginCallState = 'accepting' | 'draining' | 'disposed'; + /** Cleanup callback owned by a plugin lifecycle scope. @beta */ export type MossPluginDisposer = () => void | Promise; @@ -16,45 +23,142 @@ export type MossPluginDisposer = () => void | Promise; export interface MossPluginSnapshot { readonly id: string; readonly state: MossPluginState; + readonly callState: MossPluginCallState; readonly tools: readonly string[]; readonly skills: readonly string[]; readonly experts: readonly string[]; + readonly commands: readonly string[]; + readonly providers: readonly string[]; + readonly mcpPresets: readonly string[]; readonly promptLayerCount: number; readonly effectLabels: readonly string[]; + readonly webContributions: readonly string[]; +} + +/** Prompt-expanding command contributed by one trusted plugin. @beta */ +export interface MossPluginCommand { + readonly id: string; + readonly title: string; + readonly description?: string; + expand(args: string): string | Promise; +} + +/** LLM provider factory contributed by one trusted plugin. @beta */ +export interface MossPluginProvider { + readonly id: string; + readonly displayName: string; + create(config: Readonly>): LLMProvider | Promise; +} + +/** Data-only MCP server preset contributed by one trusted plugin. @beta */ +export interface MossPluginMcpPreset { + readonly id: string; + readonly displayName: string; + readonly server: { + readonly command: string; + readonly args?: readonly string[]; + readonly env?: Readonly>; + readonly cwd?: string; + readonly requestTimeoutMs?: number; + }; +} + +/** Stable browser extension slots available to trusted runtime plugins. @beta */ +export const MOSS_WEB_SLOTS = [ + 'navigation.primary', + 'navigation.session', + 'navigation.footer', + 'conversation.header', + 'conversation.message', + 'conversation.composer', + 'conversation.details', + 'tool.inline', + 'tool.details', + 'settings.section', + 'settings.plugin', +] as const; + +/** Stable browser extension slot available to trusted runtime plugins. @beta */ +export type MossWebSlot = (typeof MOSS_WEB_SLOTS)[number]; + +/** Declarative browser contribution owned by one trusted runtime plugin. @beta */ +export interface MossWebContribution { + readonly id: string; + readonly slot: MossWebSlot; + readonly module: string; } /** Redacted, deterministic plugin composition state. @beta */ export interface MossPluginCompositionSnapshot { + /** Monotonic last-good composition generation. Failed activation does not advance it. */ + readonly generation: number; readonly plugins: readonly MossPluginSnapshot[]; } +/** Quiescence policy for unloading one plugin generation. @beta */ +export interface MossPluginUnloadOptions { + /** Maximum time to wait for active plugin calls before retaining the old generation. */ + readonly timeoutMs?: number; +} + /** Handle returned after a plugin is installed successfully. @beta */ export interface MossPluginHandle { readonly id: string; readonly state: MossPluginState; - dispose(): Promise; + dispose(options?: MossPluginUnloadOptions): Promise; } /** Context used by host-trusted plugins to stage owned contributions. @beta */ export interface MossPluginContext { + /** Validated configuration for this activated plugin generation. */ + readonly config: Readonly>; registerTool(tool: Tool): void; registerSkill(skill: SkillMeta): void; registerExpert(expert: SubagentExpertDefinition): void; + registerCommand(command: MossPluginCommand): void; + registerProvider(provider: MossPluginProvider): void; + registerMcpPreset(preset: MossPluginMcpPreset): void; addPromptLayer(layer: string): void; + registerWebContribution(contribution: MossWebContribution): void; effect(setup: () => MossPluginDisposer | Promise, label?: string): void; } /** Host-trusted runtime plugin. Workspace/model text cannot create one. @beta */ export interface MossPlugin { readonly id: string; + /** Validated configuration captured for this immutable activation candidate. @internal */ + readonly config?: Readonly>; + /** Dispose an isolated candidate that was prepared but never installed. @internal */ + readonly disposeCandidate?: MossPluginDisposer; setup(context: MossPluginContext): void | MossPluginDisposer | Promise; } /** Instance-local lifecycle and inspection surface for host-trusted plugins. @beta */ export interface MossPluginHost { install(plugin: MossPlugin): Promise; - unload(id: string): Promise; + unload(id: string, options?: MossPluginUnloadOptions): Promise; inspect(): MossPluginCompositionSnapshot; + getCommand(id: string): MossPluginCommand | undefined; + listCommands(): readonly MossPluginCommand[]; + expandCommand(id: string, args: string): Promise; + getProvider(id: string): MossPluginProvider | undefined; + listProviders(): readonly MossPluginProvider[]; + createProvider( + id: string, + config: Readonly> + ): Promise; + getMcpPreset(id: string): MossPluginMcpPreset | undefined; + listMcpPresets(): readonly MossPluginMcpPreset[]; + /** Activate and own a resource derived from an MCP preset under its plugin lease. @internal */ + activateMcpPreset( + id: string, + setup: (preset: MossPluginMcpPreset) => Promise<{ + readonly value: T; + readonly dispose: MossPluginDisposer; + }> + ): Promise; + getWebContributions(): readonly MossWebContribution[]; + subscribe(listener: (snapshot: MossPluginCompositionSnapshot) => void): MossPluginDisposer; close(): Promise; /** @internal */ getPromptLayers(): readonly string[]; @@ -72,11 +176,16 @@ export interface MossPluginHostAdapters { registerExpert(expert: SubagentExpertDefinition): MossPluginDisposer; } -interface StagedPlugin { +/** Contribution batch staged before an atomic plugin activation. @internal */ +export interface StagedPlugin { readonly tools: Tool[]; readonly skills: SkillMeta[]; readonly experts: SubagentExpertDefinition[]; + readonly commands: MossPluginCommand[]; + readonly providers: MossPluginProvider[]; + readonly mcpPresets: MossPluginMcpPreset[]; readonly promptLayers: string[]; + readonly webContributions: MossWebContribution[]; readonly effects: Array<{ readonly label: string; readonly setup: () => MossPluginDisposer | Promise; @@ -88,26 +197,13 @@ interface PluginRecord { readonly scope: CordisEffectScope; readonly staged: StagedPlugin; state: MossPluginState; + activated: boolean; + activeCalls: number; + readonly quiescenceWaiters: Set<() => void>; disposePromise?: Promise; } -function duplicate(values: readonly string[]): string | undefined { - const seen = new Set(); - for (const value of values) { - if (seen.has(value)) return value; - seen.add(value); - } - return undefined; -} - -function validatePluginId(id: string): void { - if (!PLUGIN_ID_PATTERN.test(id)) { - throw new MossError({ - code: ErrorCode.USER_INPUT_INVALID, - message: `plugin id must be lowercase and path-like: ${id}`, - }); - } -} +const DEFAULT_QUIESCENCE_TIMEOUT_MS = 30_000; /** * Instance-local plugin lifecycle host backed by a vendored Cordis effect scope. @@ -121,7 +217,13 @@ function validatePluginId(id: string): void { class MossPluginHostImpl implements MossPluginHost { private readonly records = new Map(); private readonly promptLayers = new Map(); + private readonly commands = new Map(); + private readonly providers = new Map(); + private readonly mcpPresets = new Map(); + private readonly webContributions = new Map(); private readonly installOrder: string[] = []; + private readonly listeners = new Set<(snapshot: MossPluginCompositionSnapshot) => void>(); + private generation = 0; private closePromise: Promise | undefined; constructor(private readonly adapters: MossPluginHostAdapters) {} @@ -141,28 +243,41 @@ class MossPluginHostImpl implements MossPluginHost { }); } - const staged: StagedPlugin = { - tools: [], - skills: [], - experts: [], - promptLayers: [], - effects: [], - }; + const staged = createStagedPlugin(); const record: PluginRecord = { id: plugin.id, state: 'loading', scope: new CordisEffectScope(), staged, + activated: false, + activeCalls: 0, + quiescenceWaiters: new Set(), }; this.records.set(plugin.id, record); try { - const returned = await plugin.setup(this.createContext(staged)); + const returned = await plugin.setup(this.createContext(staged, plugin.config ?? {})); if (typeof returned === 'function') await this.addOwned(record, returned, 'plugin setup'); - this.validateStaged(plugin.id, staged); + validateStagedContributions( + plugin.id, + staged, + { + hasTool: this.adapters.hasTool, + hasSkill: this.adapters.hasSkill, + hasExpert: this.adapters.hasExpert, + hasCommand: (id) => this.commands.has(id), + hasProvider: (id) => this.providers.has(id), + hasMcpPreset: (id) => this.mcpPresets.has(id), + hasWebContribution: (id) => this.webContributions.has(id), + }, + MOSS_WEB_SLOTS + ); await this.commit(record); record.state = 'active'; + record.activated = true; this.installOrder.push(plugin.id); + this.generation += 1; + this.emitComposition(); return this.createHandle(record); } catch (error) { record.state = 'failed'; @@ -180,49 +295,187 @@ class MossPluginHostImpl implements MossPluginHost { return Object.freeze([...this.promptLayers.values()].flat()); } + getCommand(id: string): MossPluginCommand | undefined { + return this.commands.get(id); + } + + listCommands(): readonly MossPluginCommand[] { + return Object.freeze( + [...this.commands.values()].sort((left, right) => left.id.localeCompare(right.id)) + ); + } + + async expandCommand(id: string, args: string): Promise { + const command = this.commands.get(id); + if (!command) return undefined; + const record = this.ownerOf('commands', id); + if (!record) return command.expand(args); + const release = this.acquireCall(record); + try { + return await command.expand(args); + } finally { + await release(); + } + } + + getProvider(id: string): MossPluginProvider | undefined { + return this.providers.get(id); + } + + listProviders(): readonly MossPluginProvider[] { + return Object.freeze( + [...this.providers.values()].sort((left, right) => left.id.localeCompare(right.id)) + ); + } + + async createProvider( + id: string, + config: Readonly> + ): Promise { + const contribution = this.providers.get(id); + if (!contribution) return undefined; + const record = this.ownerOf('providers', id); + if (!record) return contribution.create(config); + const release = this.acquireCall(record); + try { + const provider = await contribution.create(config); + return this.wrapProviderWithLease(record, provider); + } finally { + await release(); + } + } + + getMcpPreset(id: string): MossPluginMcpPreset | undefined { + return this.mcpPresets.get(id); + } + + listMcpPresets(): readonly MossPluginMcpPreset[] { + return Object.freeze( + [...this.mcpPresets.values()].sort((left, right) => left.id.localeCompare(right.id)) + ); + } + + async activateMcpPreset( + id: string, + setup: (preset: MossPluginMcpPreset) => Promise<{ + readonly value: T; + readonly dispose: MossPluginDisposer; + }> + ): Promise { + const preset = this.mcpPresets.get(id); + if (!preset) return undefined; + const record = this.ownerOf('mcpPresets', id); + if (!record) { + throw new MossError({ + code: ErrorCode.INTERNAL_INVARIANT_VIOLATED, + message: `plugin MCP preset has no lifecycle owner: ${id}`, + }); + } + const release = this.acquireCall(record); + try { + const activated = await setup(preset); + if (record.state !== 'active') { + await activated.dispose(); + throw new MossError({ + code: ErrorCode.AGENT_DISPOSED, + message: `plugin is draining: ${record.id}`, + context: { pluginId: record.id }, + }); + } + record.scope.add(activated.dispose, `mcp-activation:${id}`); + return activated.value; + } finally { + await release(); + } + } + + getWebContributions(): readonly MossWebContribution[] { + return Object.freeze( + [...this.webContributions.values()].sort((left, right) => left.id.localeCompare(right.id)) + ); + } + + subscribe(listener: (snapshot: MossPluginCompositionSnapshot) => void): MossPluginDisposer { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + inspect(): MossPluginCompositionSnapshot { const plugins = [...this.records.values()] .map((record) => ({ id: record.id, state: record.state, + callState: + record.state === 'active' + ? ('accepting' as const) + : record.state === 'disposed' + ? ('disposed' as const) + : ('draining' as const), tools: Object.freeze(record.staged.tools.map(({ name }) => name).sort()), skills: Object.freeze( record.staged.skills.map((skill) => skill.stableId ?? skill.name).sort() ), experts: Object.freeze(record.staged.experts.map(({ id }) => id).sort()), + commands: Object.freeze(record.staged.commands.map(({ id }) => id).sort()), + providers: Object.freeze(record.staged.providers.map(({ id }) => id).sort()), + mcpPresets: Object.freeze(record.staged.mcpPresets.map(({ id }) => id).sort()), promptLayerCount: record.staged.promptLayers.length, effectLabels: Object.freeze([...record.scope.labels()].sort()), + webContributions: Object.freeze(record.staged.webContributions.map(({ id }) => id).sort()), })) .sort((left, right) => left.id.localeCompare(right.id)); - return Object.freeze({ plugins: Object.freeze(plugins) }); + return Object.freeze({ generation: this.generation, plugins: Object.freeze(plugins) }); } - unload(id: string): Promise { + unload(id: string, options?: MossPluginUnloadOptions): Promise { const record = this.records.get(id); if (!record) return Promise.resolve(); - return this.unloadRecord(record); + return this.unloadRecord(record, options); } - private unloadRecord(record: PluginRecord): Promise { + private unloadRecord(record: PluginRecord, options: MossPluginUnloadOptions = {}): Promise { if (record.state === 'disposed') return Promise.resolve(); if (record.disposePromise) return record.disposePromise; record.state = 'unloading'; - record.disposePromise = record.scope - .dispose() - .catch((error: unknown) => { - throw wrapAsMoss(error, ErrorCode.TOOL_EXECUTION_FAILED, { - message: `failed to unload plugin ${record.id}`, - context: { pluginId: record.id }, - }); - }) - .finally(() => { + const operation = (async () => { + try { + try { + await this.waitForQuiescence(record, options.timeoutMs ?? DEFAULT_QUIESCENCE_TIMEOUT_MS); + } catch (error) { + record.state = 'active'; + throw error; + } + let disposalError: unknown; + let disposalFailed = false; + try { + await record.scope.dispose(); + } catch (error) { + disposalError = error; + disposalFailed = true; + } record.state = 'disposed'; this.promptLayers.delete(record.id); if (this.records.get(record.id) === record) this.records.delete(record.id); const index = this.installOrder.indexOf(record.id); if (index >= 0) this.installOrder.splice(index, 1); - }); - return record.disposePromise; + if (record.activated && record.id !== 'moss/compatibility') { + this.generation += 1; + this.emitComposition(); + } + if (disposalFailed) { + throw wrapAsMoss(disposalError, ErrorCode.TOOL_EXECUTION_FAILED, { + message: `failed to unload plugin ${record.id}`, + context: { pluginId: record.id }, + }); + } + } finally { + record.disposePromise = undefined; + } + })(); + record.disposePromise = operation; + return operation; } close(): Promise { @@ -230,6 +483,9 @@ class MossPluginHostImpl implements MossPluginHost { this.closePromise = (async () => { const failures: unknown[] = []; const records = [...this.records.values()].reverse(); + for (const record of records) { + if (record.state !== 'disposed') record.state = 'unloading'; + } for (const record of records) { try { await this.unloadRecord(record); @@ -259,53 +515,43 @@ class MossPluginHostImpl implements MossPluginHost { id: 'moss/compatibility', state: 'active', scope: new CordisEffectScope(), - staged: { tools: [], skills: [], experts: [], promptLayers: [], effects: [] }, + staged: createStagedPlugin(), + activated: true, + activeCalls: 0, + quiescenceWaiters: new Set(), }; record.scope.add(dispose, label); this.records.set(record.id, record); this.installOrder.push(record.id); } - private createContext(staged: StagedPlugin): MossPluginContext { + private createContext( + staged: StagedPlugin, + config: Readonly> + ): MossPluginContext { return { + config, registerTool: (tool) => staged.tools.push(tool), registerSkill: (skill) => staged.skills.push(skill), registerExpert: (expert) => staged.experts.push(expert), + registerCommand: (command) => staged.commands.push(command), + registerProvider: (provider) => staged.providers.push(provider), + registerMcpPreset: (preset) => staged.mcpPresets.push(preset), addPromptLayer: (layer) => staged.promptLayers.push(layer), + registerWebContribution: (contribution) => staged.webContributions.push(contribution), effect: (setup, label = 'plugin effect') => staged.effects.push({ setup, label }), }; } - private validateStaged(pluginId: string, staged: StagedPlugin): void { - const duplicateTool = duplicate(staged.tools.map(({ name }) => name)); - const duplicateSkill = duplicate(staged.skills.map((skill) => skill.stableId ?? skill.name)); - const duplicateExpert = duplicate(staged.experts.map(({ id }) => id)); - const conflict = duplicateTool ?? duplicateSkill ?? duplicateExpert; - if (conflict) - throw new Error(`plugin ${pluginId} contains duplicate contribution: ${conflict}`); - for (const tool of staged.tools) { - if (!tool.metadata?.sideEffectClass) - throw new Error(`plugin tool ${tool.name} requires side-effect metadata`); - if (this.adapters.hasTool(tool.name)) - throw new Error(`plugin tool already registered: ${tool.name}`); - } - for (const skill of staged.skills) { - const id = skill.stableId ?? skill.name; - if (this.adapters.hasSkill(id)) throw new Error(`plugin skill already registered: ${id}`); - } - for (const expert of staged.experts) { - if (this.adapters.hasExpert(expert.id)) - throw new Error(`plugin expert already registered: ${expert.id}`); - } - for (const layer of staged.promptLayers) { - if (!layer.trim()) throw new Error(`plugin ${pluginId} contains an empty prompt layer`); - } - } - private async commit(record: PluginRecord): Promise { this.assertLoading(record); for (const tool of record.staged.tools) { - await this.addOwned(record, this.adapters.registerTool(tool, record.id), `tool:${tool.name}`); + const leasedTool = this.wrapToolWithLease(record, tool); + await this.addOwned( + record, + this.adapters.registerTool(leasedTool, record.id), + `tool:${tool.name}` + ); } for (const skill of record.staged.skills) { const id = skill.stableId ?? skill.name; @@ -314,6 +560,34 @@ class MossPluginHostImpl implements MossPluginHost { for (const expert of record.staged.experts) { await this.addOwned(record, this.adapters.registerExpert(expert), `expert:${expert.id}`); } + for (const command of record.staged.commands) { + await this.addOwned( + record, + this.registerOwned(this.commands, command), + `command:${command.id}` + ); + } + for (const provider of record.staged.providers) { + await this.addOwned( + record, + this.registerOwned(this.providers, provider), + `provider:${provider.id}` + ); + } + for (const preset of record.staged.mcpPresets) { + await this.addOwned( + record, + this.registerOwned(this.mcpPresets, preset), + `mcp-preset:${preset.id}` + ); + } + for (const contribution of record.staged.webContributions) { + await this.addOwned( + record, + this.registerOwned(this.webContributions, contribution, `${record.id}:${contribution.id}`), + `web:${contribution.id}` + ); + } if (record.staged.promptLayers.length > 0) { this.promptLayers.set(record.id, Object.freeze([...record.staged.promptLayers])); record.scope.add(() => { @@ -334,6 +608,158 @@ class MossPluginHostImpl implements MossPluginHost { } } + private registerOwned( + registry: Map, + contribution: T, + key = contribution.id + ): MossPluginDisposer { + registry.set(key, contribution); + return () => { + if (registry.get(key) === contribution) registry.delete(key); + }; + } + + private acquireCall(record: PluginRecord): MossPluginDisposer { + if (record.state !== 'active') { + throw new MossError({ + code: ErrorCode.AGENT_DISPOSED, + message: `plugin is draining: ${record.id}`, + context: { pluginId: record.id }, + }); + } + record.activeCalls += 1; + let released = false; + return () => { + if (released) return; + released = true; + record.activeCalls -= 1; + if (record.activeCalls === 0) { + for (const resolve of record.quiescenceWaiters) resolve(); + record.quiescenceWaiters.clear(); + } + }; + } + + private wrapToolWithLease(record: PluginRecord, tool: Tool): Tool { + const execute = tool.execute.bind(tool); + const executeStructured = tool.executeStructured?.bind(tool); + return { + ...tool, + execute: async (input, context) => { + const release = this.acquireCall(record); + try { + return await execute(input, context); + } finally { + await release(); + } + }, + ...(executeStructured + ? { + executeStructured: async (input, context) => { + const release = this.acquireCall(record); + try { + return await executeStructured(input, context); + } finally { + await release(); + } + }, + } + : {}), + }; + } + + private wrapProviderWithLease(record: PluginRecord, provider: LLMProvider): LLMProvider { + return { + id: provider.id, + displayName: provider.displayName, + ...(provider.capabilities ? { capabilities: provider.capabilities } : {}), + complete: async (options) => { + const release = this.acquireCall(record); + try { + return await provider.complete(options); + } finally { + await release(); + } + }, + stream: async (options, onEvent) => { + const release = this.acquireCall(record); + try { + return await provider.stream(options, onEvent); + } finally { + await release(); + } + }, + ...(provider.countTokens + ? { + countTokens: async (value: string) => { + const release = this.acquireCall(record); + try { + return await provider.countTokens!(value); + } finally { + await release(); + } + }, + } + : {}), + }; + } + + private ownerOf( + kind: 'commands' | 'providers' | 'mcpPresets', + id: string + ): PluginRecord | undefined { + return [...this.records.values()].find((record) => + record.staged[kind].some((item) => item.id === id) + ); + } + + private emitComposition(): void { + if (this.listeners.size === 0) return; + const snapshot = this.inspect(); + for (const listener of this.listeners) { + try { + listener(snapshot); + } catch { + // Observers are diagnostics/UI adapters and cannot invalidate an activated generation. + } + } + } + + private async waitForQuiescence(record: PluginRecord, timeoutMs: number): Promise { + if (!Number.isFinite(timeoutMs) || timeoutMs < 0) { + throw new MossError({ + code: ErrorCode.USER_INPUT_INVALID, + message: 'plugin unload timeout must be a non-negative finite number', + }); + } + if (record.activeCalls === 0) return; + let resolveWaiter: (() => void) | undefined; + const quiescent = new Promise((resolve) => { + resolveWaiter = resolve; + record.quiescenceWaiters.add(resolve); + }); + let timer: NodeJS.Timeout | undefined; + try { + await Promise.race([ + quiescent, + new Promise((_, reject) => { + timer = setTimeout(() => { + reject( + new MossError({ + code: ErrorCode.TOOL_EXECUTION_FAILED, + message: `plugin did not become quiescent: ${record.id}`, + context: { pluginId: record.id, activeCalls: record.activeCalls, timeoutMs }, + }) + ); + }, timeoutMs); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + if (resolveWaiter) record.quiescenceWaiters.delete(resolveWaiter); + } + } + private async addOwned( record: PluginRecord, dispose: MossPluginDisposer, @@ -352,7 +778,7 @@ class MossPluginHostImpl implements MossPluginHost { get state() { return record.state; }, - dispose: () => this.unloadRecord(record), + dispose: (options) => this.unloadRecord(record, options), }; } } diff --git a/packages/moss-agent/src/core/tools/tool-registry.ts b/packages/moss-agent/src/core/tools/tool-registry.ts index 8d287f1c..c346561f 100644 --- a/packages/moss-agent/src/core/tools/tool-registry.ts +++ b/packages/moss-agent/src/core/tools/tool-registry.ts @@ -22,6 +22,24 @@ export class ToolRegistry { } register(tool: Tool, groupId?: string): void { + if (this.tools.has(tool.name)) { + const owner = this.toolToGroup.get(tool.name); + if (owner?.startsWith('plugin:')) { + throw new Error(`tool already registered: ${tool.name}`); + } + this.replace(tool, groupId); + return; + } + this.install(tool, groupId); + } + + /** Explicitly replace a tool while transferring its lifecycle ownership. @beta */ + replace(tool: Tool, groupId?: string): void { + if (this.tools.has(tool.name)) this.remove(tool.name); + this.install(tool, groupId); + } + + private install(tool: Tool, groupId?: string): void { this.tools.set(tool.name, tool); if (groupId) { this.toolToGroup.set(tool.name, groupId); @@ -37,9 +55,6 @@ export class ToolRegistry { /** Install a tool for one lifecycle owner without replacing an existing tool. @internal */ registerScoped(tool: Tool, owner: string): () => void { - if (this.tools.has(tool.name)) { - throw new Error(`tool already registered: ${tool.name}`); - } this.register(tool, owner); let disposed = false; return () => { @@ -50,6 +65,13 @@ export class ToolRegistry { } registerGroup(group: ToolGroup): void { + if (this.groups.has(group.id)) throw new Error(`tool group already registered: ${group.id}`); + const groupNames = new Set(); + for (const tool of group.tools) { + if (groupNames.has(tool.name)) throw new Error(`tool already registered: ${tool.name}`); + groupNames.add(tool.name); + if (this.tools.has(tool.name)) throw new Error(`tool already registered: ${tool.name}`); + } this.groups.set(group.id, { ...group, tools: [...group.tools] }); for (const tool of group.tools) { this.tools.set(tool.name, tool); diff --git a/packages/moss-agent/src/core/tools/tool-types.ts b/packages/moss-agent/src/core/tools/tool-types.ts index acd38eda..f9d5ff97 100644 --- a/packages/moss-agent/src/core/tools/tool-types.ts +++ b/packages/moss-agent/src/core/tools/tool-types.ts @@ -40,6 +40,8 @@ export interface ToolContext { * uses this to show incremental output from long-running commands (e.g. * `npm install`, `pytest`) instead of buffering until the tool finishes. */ onToolOutput?: (text: string) => void; + /** Host-owned interactive question channel scoped to this agent instance. */ + askUserQuestion?: (question: string, abortSignal?: AbortSignal) => Promise; spawnSubagent?: (params: { task: string; label?: string; diff --git a/packages/moss-agent/src/index.ts b/packages/moss-agent/src/index.ts index cb737d53..93b21662 100644 --- a/packages/moss-agent/src/index.ts +++ b/packages/moss-agent/src/index.ts @@ -141,16 +141,24 @@ export type { StructuredToolResult, } from './core/index.js'; export { collectCapabilityPacks } from './core/index.js'; +export { MOSS_WEB_SLOTS } from './core/index.js'; export type { CapabilityPack, CapabilityPackContributions } from './core/index.js'; export type { + MossPluginCallState, + MossPluginCommand, MossPlugin, MossPluginCompositionSnapshot, MossPluginContext, MossPluginDisposer, MossPluginHandle, MossPluginHost, + MossPluginMcpPreset, + MossPluginProvider, MossPluginSnapshot, MossPluginState, + MossPluginUnloadOptions, + MossWebContribution, + MossWebSlot, } from './core/index.js'; export { createBrowserTools, diff --git a/packages/moss-agent/src/plugins/dsh-bundle-compatibility.ts b/packages/moss-agent/src/plugins/dsh-bundle-compatibility.ts new file mode 100644 index 00000000..1d369ff7 --- /dev/null +++ b/packages/moss-agent/src/plugins/dsh-bundle-compatibility.ts @@ -0,0 +1,360 @@ +import { access, readFile, realpath } from 'node:fs/promises'; +import path from 'node:path'; +import { parse as parseYaml } from 'yaml'; +import { ErrorCode, MossError, wrapAsMoss } from '../errors.js'; +import type { MossPlugin } from '../core/plugins/plugin-host.js'; +import { + parseMossPluginConfigSchema, + type MossPluginConfigSchema, +} from './plugin-config-schema.js'; + +interface AdapterSkill { + readonly id: string; + readonly file: string; + readonly name?: string; + readonly description?: string; + readonly triggers?: readonly string[]; +} + +interface AdapterCommand { + readonly id: string; + readonly title: string; + readonly description?: string; + readonly prompt: string; +} + +interface AdapterMcpPreset { + readonly id: string; + readonly displayName: string; + readonly command: string; + readonly args?: readonly string[]; + readonly env?: Readonly>; +} + +interface ParsedDshPackage { + readonly id: string; + readonly version: string; + readonly root: string; + readonly skills: readonly AdapterSkill[]; + readonly commands: readonly AdapterCommand[]; + readonly mcpPresets: readonly AdapterMcpPreset[]; + readonly configSchema?: MossPluginConfigSchema; + readonly skipped: readonly string[]; +} + +const PLUGIN_ID_PATTERN = /^[a-z0-9]+(?:[./-][a-z0-9]+)*$/; +const EXACT_VERSION_PATTERN = + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-(?:(?:0|[1-9]\d*)|(?:\d*[A-Za-z-][0-9A-Za-z-]*))(?:\.(?:(?:0|[1-9]\d*)|(?:\d*[A-Za-z-][0-9A-Za-z-]*)))*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/; + +/** Result of auditing a real DSH package without executing it. @beta */ +export interface DshPackageCompatibilityReport { + readonly compatible: boolean; + readonly id?: string; + readonly version?: string; + readonly imported: readonly string[]; + readonly skipped: readonly string[]; + readonly reasons: readonly string[]; +} + +/** A safely mapped, data-only DSH package. @beta */ +export interface ImportedDshPackage { + readonly report: DshPackageCompatibilityReport; + readonly plugin: MossPlugin; + readonly configSchema?: MossPluginConfigSchema; +} + +function invalid(message: string): MossError { + return new MossError({ code: ErrorCode.USER_INPUT_INVALID, message }); +} + +function record(value: unknown, field: string): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw invalid(`${field} must be an object`); + } + return value as Record; +} + +function stringField(value: unknown, field: string): string { + if (typeof value !== 'string' || !value.trim()) { + throw invalid(`${field} must be a non-empty string`); + } + return value; +} + +function stringArray(value: unknown, field: string): readonly string[] | undefined { + if (value === undefined) return undefined; + if (!Array.isArray(value) || value.some((item) => typeof item !== 'string')) { + throw invalid(`${field} must be an array of strings`); + } + return value as string[]; +} + +function stringRecord(value: unknown, field: string): Readonly> | undefined { + if (value === undefined) return undefined; + const result = record(value, field); + for (const [name, item] of Object.entries(result)) { + if (typeof item !== 'string') throw invalid(`${field}.${name} must be a string`); + } + return result as Record; +} + +async function containedPath(root: string, relative: string, field: string): Promise { + if (path.isAbsolute(relative)) throw invalid(`${field} must be package-relative`); + const target = await realpath(path.resolve(root, relative)); + const fromRoot = path.relative(root, target); + if (fromRoot === '..' || fromRoot.startsWith(`..${path.sep}`) || path.isAbsolute(fromRoot)) { + throw invalid(`${field} escapes the DSH package root`); + } + return target; +} + +function hasClientAbi(packageJson: Record, patch: unknown): boolean { + if (recordOrEmpty(packageJson.dsh).client !== undefined) return true; + const dependencies = { + ...recordOrEmpty(packageJson.dependencies), + ...recordOrEmpty(packageJson.peerDependencies), + }; + if (Object.keys(dependencies).some((name) => /dsh-(?:client|web)|dsh\/client/i.test(name))) { + return true; + } + return /(?:dsh\.client|client\.slot|clientSlots|dsh-client)/i.test(JSON.stringify(patch)); +} + +function recordOrEmpty(value: unknown): Record { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : {}; +} + +async function optionalFile(root: string, name: string): Promise { + try { + const target = await containedPath(root, name, name); + await access(target); + return target; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined; + throw error; + } +} + +function parseAdapterSkill(value: unknown, index: number): AdapterSkill { + const item = record(value, `adapter.skills[${index}]`); + return { + id: stringField(item.id ?? item.name, `adapter.skills[${index}].id`), + file: stringField(item.file, `adapter.skills[${index}].file`), + ...(typeof item.name === 'string' ? { name: item.name } : {}), + ...(typeof item.description === 'string' ? { description: item.description } : {}), + ...(stringArray(item.triggers ?? item.trigger, `adapter.skills[${index}].triggers`) + ? { + triggers: stringArray(item.triggers ?? item.trigger, `adapter.skills[${index}].triggers`), + } + : {}), + }; +} + +async function parsePackage(source: string): Promise { + const root = await realpath(path.resolve(source)); + const packagePath = await containedPath(root, 'package.json', 'package.json'); + const packageJson = record(JSON.parse(await readFile(packagePath, 'utf8')), packagePath); + const id = stringField(packageJson.name, 'package.json.name') + .replace(/^@/, '') + .replaceAll('/', '-') + .replace(/[^a-z0-9.-]+/gi, '-') + .toLowerCase(); + const version = stringField(packageJson.version, 'package.json.version'); + if (!PLUGIN_ID_PATTERN.test(id)) + throw invalid(`package.json.name maps to an invalid plugin id: ${id}`); + if (!EXACT_VERSION_PATTERN.test(version)) { + throw invalid('package.json.version must be an exact semantic version'); + } + const patchPath = await optionalFile(root, 'cordis.patch.yml'); + if (!patchPath) throw invalid('not a DSH package: cordis.patch.yml is missing'); + let patch: unknown; + try { + patch = parseYaml(await readFile(patchPath, 'utf8'), { maxAliasCount: 0 }); + } catch (error) { + throw invalid( + `cordis.patch.yml is invalid: ${error instanceof Error ? error.message : String(error)}` + ); + } + if (hasClientAbi(packageJson, patch)) { + throw invalid('DSH package requires unsupported Cordis client UI slots'); + } + + const adapterPath = await optionalFile(root, 'moss.dsh-adapter.json'); + const adapter = adapterPath + ? record(JSON.parse(await readFile(adapterPath, 'utf8')), adapterPath) + : {}; + const adapterSkills = adapter.skills ?? []; + const adapterCommands = adapter.commands ?? []; + const adapterMcp = adapter.mcpPresets ?? adapter.mcp ?? []; + if ( + !Array.isArray(adapterSkills) || + !Array.isArray(adapterCommands) || + !Array.isArray(adapterMcp) + ) { + throw invalid('Moss DSH adapter contributions must be arrays'); + } + const skills = adapterSkills.map(parseAdapterSkill); + if (skills.length === 0) { + const skillPath = await optionalFile(root, 'SKILL.md'); + if (skillPath) skills.push({ id, name: id, file: 'SKILL.md' }); + } + const commands = adapterCommands.map((value, index): AdapterCommand => { + const item = record(value, `adapter.commands[${index}]`); + return { + id: stringField(item.id, `adapter.commands[${index}].id`), + title: stringField(item.title, `adapter.commands[${index}].title`), + ...(typeof item.description === 'string' ? { description: item.description } : {}), + prompt: stringField(item.prompt, `adapter.commands[${index}].prompt`), + }; + }); + const mcpPresets = adapterMcp.map((value, index): AdapterMcpPreset => { + const item = record(value, `adapter.mcp[${index}]`); + const server = record(item.server ?? item, `adapter.mcp[${index}].server`); + const args = stringArray(server.args, `adapter.mcp[${index}].args`); + const env = stringRecord(server.env, `adapter.mcp[${index}].env`); + return { + id: stringField(item.id, `adapter.mcp[${index}].id`), + displayName: stringField(item.displayName ?? item.name, `adapter.mcp[${index}].displayName`), + command: stringField(server.command, `adapter.mcp[${index}].command`), + ...(args ? { args } : {}), + ...(env ? { env } : {}), + }; + }); + const configSchema = adapter.configSchema + ? typeof adapter.configSchema === 'string' + ? parseMossPluginConfigSchema( + JSON.parse( + await readFile( + await containedPath(root, adapter.configSchema, 'adapter.configSchema'), + 'utf8' + ) + ), + `${adapterPath}#configSchema` + ) + : parseMossPluginConfigSchema(adapter.configSchema, `${adapterPath}#configSchema`) + : undefined; + if (skills.length + commands.length + mcpPresets.length + (configSchema ? 1 : 0) === 0) { + throw invalid( + 'DSH package has no safely mappable SKILL.md or Moss adapter command/MCP/config declaration' + ); + } + return { + id, + version, + root, + skills, + commands, + mcpPresets, + ...(configSchema ? { configSchema } : {}), + skipped: Object.freeze(['cordis.patch.yml runtime']), + }; +} + +/** Audit a real `package.json + cordis.patch.yml` DSH package without executing JavaScript. @beta */ +export async function inspectDshPackageCompatibility( + source: string +): Promise { + try { + const parsed = await parsePackage(source); + return Object.freeze({ + compatible: true, + id: parsed.id, + version: parsed.version, + imported: Object.freeze([ + ...parsed.skills.map(({ id }) => `skill:${id}`), + ...parsed.commands.map(({ id }) => `command:${id}`), + ...parsed.mcpPresets.map(({ id }) => `mcp:${id}`), + ...(parsed.configSchema ? ['config:schema'] : []), + ]), + skipped: parsed.skipped, + reasons: Object.freeze([ + 'Only package data and the explicit Moss DSH adapter are mapped; Cordis runtime JavaScript is not executed.', + ]), + }); + } catch (error) { + let packageIdentity: { id?: string; version?: string } = {}; + try { + const root = await realpath(path.resolve(source)); + const value = record( + JSON.parse(await readFile(path.join(root, 'package.json'), 'utf8')), + 'package.json' + ); + packageIdentity = { + ...(typeof value.name === 'string' ? { id: value.name } : {}), + ...(typeof value.version === 'string' ? { version: value.version } : {}), + }; + } catch {} + return Object.freeze({ + compatible: false, + ...packageIdentity, + imported: Object.freeze([]), + skipped: Object.freeze(['Cordis runtime and client contributions']), + reasons: Object.freeze([error instanceof Error ? error.message : String(error)]), + }); + } +} + +/** Import the safe declarative subset of a real DSH package as a Moss plugin. @beta */ +export async function importDshPackage(source: string): Promise { + try { + const parsed = await parsePackage(source); + const report = await inspectDshPackageCompatibility(source); + const plugin: MossPlugin = { + id: parsed.id, + async setup(context) { + for (const skill of parsed.skills) { + const sourcePath = await containedPath(parsed.root, skill.file, `skill ${skill.id}`); + const raw = await readFile(sourcePath, 'utf8'); + const body = raw.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, '').trim(); + if (!body) throw invalid(`DSH Skill ${skill.id} is empty`); + context.registerSkill({ + stableId: skill.id, + name: skill.name ?? skill.id, + description: skill.description ?? `Imported data-only Skill from ${parsed.id}`, + summary: skill.description ?? `Imported data-only Skill from ${parsed.id}`, + sourcePath, + version: parsed.version, + tags: ['dsh-compatible'], + trigger: [...(skill.triggers ?? [skill.id])], + risk: 'low', + permissions: { workspaceRead: true }, + enabled: true, + updatedAt: 0, + body, + }); + } + for (const command of parsed.commands) { + context.registerCommand({ + id: command.id, + title: command.title, + ...(command.description ? { description: command.description } : {}), + expand: (args) => command.prompt.replaceAll('{{args}}', args.trim()), + }); + } + for (const preset of parsed.mcpPresets) { + context.registerMcpPreset({ + id: preset.id, + displayName: preset.displayName, + server: { + command: preset.command, + ...(preset.args ? { args: preset.args } : {}), + ...(preset.env ? { env: preset.env } : {}), + }, + }); + } + }, + }; + return { + report, + plugin, + ...(parsed.configSchema ? { configSchema: parsed.configSchema } : {}), + }; + } catch (error) { + throw wrapAsMoss(error, ErrorCode.USER_INPUT_INVALID, { + message: `unable to import DSH package ${source}`, + context: { source }, + }); + } +} diff --git a/packages/moss-agent/src/plugins/installed-plugin-registry.ts b/packages/moss-agent/src/plugins/installed-plugin-registry.ts new file mode 100644 index 00000000..96cf6d84 --- /dev/null +++ b/packages/moss-agent/src/plugins/installed-plugin-registry.ts @@ -0,0 +1,739 @@ +import { randomUUID } from 'node:crypto'; +import { access, mkdir, readFile, realpath, rename, rm, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import * as lockfile from 'proper-lockfile'; +import { ErrorCode, MossError, wrapAsMoss } from '../errors.js'; +import { + MOSS_WEB_SLOTS, + type MossPlugin, + type MossWebContribution, +} from '../core/plugins/plugin-host.js'; +import { runProcess } from '../utils/run-process.js'; +import { parseMossPluginConfigSchema } from './plugin-config-schema.js'; +import { importDshPackage, inspectDshPackageCompatibility } from './dsh-bundle-compatibility.js'; +import { createIsolatedMossPlugin } from './isolated-plugin-runtime.js'; + +const MANIFEST_NAME = 'moss.plugin.json'; +const PLUGIN_ID_PATTERN = /^[a-z0-9]+(?:[./-][a-z0-9]+)*$/; +const EXACT_VERSION_PATTERN = + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-(?:(?:0|[1-9]\d*)|(?:\d*[A-Za-z-][0-9A-Za-z-]*))(?:\.(?:(?:0|[1-9]\d*)|(?:\d*[A-Za-z-][0-9A-Za-z-]*)))*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/; +const OFFICIAL_SOURCE_PREFIX = 'official:'; +const PLUGIN_SETUP_VALIDATION_TIMEOUT_MS = 15_000; +const REGISTRY_LOCK_STALE_MS = 5_000; + +/** Runtime entry declared by a trusted plugin manifest. @beta */ +export interface MossPluginRuntimeManifest { + readonly module: string; + readonly export?: string; +} + +/** Browser contributions declared by a trusted plugin manifest. @beta */ +export interface MossPluginWebManifest { + readonly contributions: readonly MossWebContribution[]; +} + +/** Stable on-disk `moss.plugin.json` v1 contract. @beta */ +export interface MossPluginManifestV1 { + readonly schemaVersion: 1; + readonly id: string; + readonly version: string; + readonly runtime: MossPluginRuntimeManifest; + readonly web?: MossPluginWebManifest; + readonly configSchema?: string; +} + +/** Persisted record for one explicitly installed plugin. @beta */ +export interface InstalledMossPluginVersion { + readonly version: string; + readonly source: string; + readonly root: string; + readonly format?: 'moss-v1' | 'dsh-package-v1'; +} + +/** Persisted record for one explicitly installed plugin. @beta */ +export interface InstalledMossPlugin { + readonly id: string; + readonly version: string; + readonly source: string; + readonly root: string; + readonly enabled: boolean; + readonly installedAt: string; + /** Source contract used to load this entry. Missing means `moss-v1`. */ + readonly format?: 'moss-v1' | 'dsh-package-v1'; + /** Previous immutable npm generation retained for explicit rollback. */ + readonly lastGood?: InstalledMossPluginVersion; +} + +/** Health result returned by `moss plugins doctor`. @beta */ +export interface MossPluginDoctorResult { + readonly id: string; + readonly status: 'ok' | 'disabled' | 'error'; + readonly message: string; +} + +/** Isolated result of loading enabled plugins. @beta */ +export interface LoadedMossPlugins { + readonly plugins: readonly MossPlugin[]; + readonly failures: readonly { id: string; message: string; recovered?: boolean }[]; +} + +interface InstalledPluginFile { + readonly schemaVersion: 1; + readonly plugins: readonly InstalledMossPlugin[]; +} + +/** Storage options for the trusted installed-plugin registry. @beta */ +export interface InstalledPluginRegistryOptions { + readonly configDir: string; + /** Maximum isolated import/setup validation time. @defaultValue 15000 */ + readonly setupTimeoutMs?: number; +} + +interface ResolvedPluginSource { + readonly root: string; + readonly discard?: () => Promise; +} + +function invalidManifest(message: string, cause?: unknown): MossError { + return new MossError({ code: ErrorCode.USER_INPUT_INVALID, message, cause }); +} + +function diagnosticMessage(error: unknown): string { + if (!(error instanceof Error)) return String(error); + const cause = (error as Error & { cause?: unknown }).cause; + if (cause instanceof Error && cause.message && cause.message !== error.message) { + return `${error.message}: ${cause.message}`; + } + return error.message; +} + +function validateInstalledPlugin(value: unknown, index: number): InstalledMossPlugin { + if (!value || typeof value !== 'object') { + throw new Error(`plugins[${index}] must be an object`); + } + const entry = value as Record; + if (typeof entry.id !== 'string' || !PLUGIN_ID_PATTERN.test(entry.id)) { + throw new Error(`plugins[${index}].id is invalid`); + } + if (typeof entry.version !== 'string' || !EXACT_VERSION_PATTERN.test(entry.version)) { + throw new Error(`plugins[${index}].version is invalid`); + } + for (const field of ['source', 'root', 'installedAt'] as const) { + if (typeof entry[field] !== 'string' || !entry[field]) { + throw new Error(`plugins[${index}].${field} is invalid`); + } + } + if (typeof entry.enabled !== 'boolean') { + throw new Error(`plugins[${index}].enabled is invalid`); + } + if ( + entry.format !== undefined && + entry.format !== 'moss-v1' && + entry.format !== 'dsh-package-v1' + ) { + throw new Error(`plugins[${index}].format is invalid`); + } + if (entry.lastGood !== undefined) { + if (!entry.lastGood || typeof entry.lastGood !== 'object') { + throw new Error(`plugins[${index}].lastGood is invalid`); + } + const previous = entry.lastGood as Record; + if ( + typeof previous.version !== 'string' || + !EXACT_VERSION_PATTERN.test(previous.version) || + typeof previous.source !== 'string' || + !previous.source || + typeof previous.root !== 'string' || + !previous.root || + (previous.format !== undefined && + previous.format !== 'moss-v1' && + previous.format !== 'dsh-package-v1') + ) { + throw new Error(`plugins[${index}].lastGood is invalid`); + } + } + return entry as unknown as InstalledMossPlugin; +} + +function validateRelativeModule(value: unknown, field: string): string { + if (typeof value !== 'string' || !value.startsWith('./') || value.split(/[\\/]/).includes('..')) { + throw invalidManifest(`${field} must be a relative path beginning with ./`); + } + return value; +} + +function validateManifest(value: unknown, manifestPath: string): MossPluginManifestV1 { + if (!value || typeof value !== 'object') + throw invalidManifest(`${manifestPath} must be an object`); + const data = value as Record; + if (data.schemaVersion !== 1) throw invalidManifest(`${manifestPath} requires schemaVersion 1`); + if (typeof data.id !== 'string' || !PLUGIN_ID_PATTERN.test(data.id)) { + throw invalidManifest(`${manifestPath} has an invalid plugin id`); + } + if (typeof data.version !== 'string' || !EXACT_VERSION_PATTERN.test(data.version)) { + throw invalidManifest(`${manifestPath} requires an exact semantic version`); + } + if (!data.runtime || typeof data.runtime !== 'object') { + throw invalidManifest(`${manifestPath} requires a runtime object`); + } + const runtime = data.runtime as Record; + validateRelativeModule(runtime.module, 'runtime.module'); + if ( + runtime.export !== undefined && + (typeof runtime.export !== 'string' || !runtime.export.trim()) + ) { + throw invalidManifest('runtime.export must be a non-empty string'); + } + if (data.configSchema !== undefined) validateRelativeModule(data.configSchema, 'configSchema'); + if (data.web !== undefined) { + if (!data.web || typeof data.web !== 'object') throw invalidManifest('web must be an object'); + const contributions = (data.web as Record).contributions; + if (!Array.isArray(contributions)) throw invalidManifest('web.contributions must be an array'); + const contributionIds = new Set(); + for (const contribution of contributions) { + if (!contribution || typeof contribution !== 'object') { + throw invalidManifest('each web contribution must be an object'); + } + const item = contribution as Record; + if (typeof item.id !== 'string' || !item.id.trim()) { + throw invalidManifest('web contribution id is required'); + } + if (contributionIds.has(item.id)) { + throw invalidManifest(`duplicate web contribution id: ${item.id}`); + } + contributionIds.add(item.id); + if (!MOSS_WEB_SLOTS.includes(item.slot as (typeof MOSS_WEB_SLOTS)[number])) { + throw invalidManifest(`unsupported web contribution slot: ${String(item.slot)}`); + } + validateRelativeModule(item.module, 'web contribution module'); + } + } + return data as unknown as MossPluginManifestV1; +} + +async function resolveContainedPath( + root: string, + relative: string, + field: string +): Promise { + const realRoot = await realpath(root); + const target = await realpath(path.resolve(realRoot, relative)); + const fromRoot = path.relative(realRoot, target); + if (fromRoot === '..' || fromRoot.startsWith(`..${path.sep}`) || path.isAbsolute(fromRoot)) { + throw invalidManifest(`${field} escapes plugin root`); + } + return target; +} + +/** Read and validate a plugin manifest without executing plugin code. @beta */ +export async function readMossPluginManifest(root: string): Promise { + const manifestPath = path.join(root, MANIFEST_NAME); + try { + const parsed: unknown = JSON.parse(await readFile(manifestPath, 'utf8')); + const manifest = validateManifest(parsed, manifestPath); + await access(await resolveContainedPath(root, manifest.runtime.module, 'runtime.module')); + for (const contribution of manifest.web?.contributions ?? []) { + await access( + await resolveContainedPath(root, contribution.module, 'web contribution module') + ); + } + if (manifest.configSchema) { + const schemaPath = await resolveContainedPath(root, manifest.configSchema, 'configSchema'); + await access(schemaPath); + parseMossPluginConfigSchema(JSON.parse(await readFile(schemaPath, 'utf8')), schemaPath); + } + return manifest; + } catch (error) { + if (error instanceof MossError) throw error; + throw wrapAsMoss(error, ErrorCode.USER_INPUT_INVALID, { + message: `unable to read ${manifestPath}`, + context: { manifestPath }, + }); + } +} + +function exactNpmPackageName(source: string): string | null { + if (source.startsWith('@')) { + const match = /^(@[^/]+\/[^@]+)@(.+)$/.exec(source); + return match && EXACT_VERSION_PATTERN.test(match[2]) ? match[1] : null; + } + const match = /^([^@/]+)@(.+)$/.exec(source); + return match && EXACT_VERSION_PATTERN.test(match[2]) ? match[1] : null; +} + +function officialPluginRoot(source: string): string | undefined { + if (!source.startsWith(OFFICIAL_SOURCE_PREFIX)) return undefined; + const name = source.slice(OFFICIAL_SOURCE_PREFIX.length); + if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name)) { + throw invalidManifest(`invalid official plugin source: ${source}`); + } + const here = path.dirname(fileURLToPath(import.meta.url)); + return path.resolve(here, '..', '..', 'assets', 'plugins', name); +} + +async function resolveNpmInvocation(): Promise<{ + readonly command: string; + readonly argsPrefix: readonly string[]; +}> { + if (process.platform !== 'win32') return { command: 'npm', argsPrefix: [] }; + const candidates = [ + process.env.npm_execpath, + path.join(path.dirname(process.execPath), 'node_modules', 'npm', 'bin', 'npm-cli.js'), + ].filter((candidate): candidate is string => Boolean(candidate)); + for (const candidate of candidates) { + try { + await access(candidate); + return { command: process.execPath, argsPrefix: [candidate] }; + } catch {} + } + throw invalidManifest('unable to locate npm-cli.js for exact-version plugin installation'); +} + +async function assertInstalledIdentity(entry: InstalledMossPlugin): Promise { + if (entry.format === 'dsh-package-v1') { + const report = await inspectDshPackageCompatibility(entry.root); + if (!report.compatible || report.id !== entry.id || report.version !== entry.version) { + throw invalidManifest( + `installed plugin identity changed: expected ${entry.id}@${entry.version}, got ${report.id ?? 'invalid'}@${report.version ?? 'invalid'}` + ); + } + return; + } + const manifest = await readMossPluginManifest(entry.root); + if (manifest.id !== entry.id || manifest.version !== entry.version) { + throw invalidManifest( + `installed plugin identity changed: expected ${entry.id}@${entry.version}, got ${manifest.id}@${manifest.version}` + ); + } +} + +/** Persistent registry for trusted local and npm Moss plugins. @beta */ +export class InstalledPluginRegistry { + private readonly registryDir: string; + private readonly registryPath: string; + private readonly npmRoot: string; + private readonly npmRunner: typeof runProcess; + private readonly setupTimeoutMs: number; + + constructor(options: InstalledPluginRegistryOptions) { + this.registryDir = path.join(options.configDir, 'plugins'); + this.registryPath = path.join(this.registryDir, 'installed.json'); + this.npmRoot = path.join(this.registryDir, 'npm'); + this.npmRunner = runProcess; + this.setupTimeoutMs = options.setupTimeoutMs ?? PLUGIN_SETUP_VALIDATION_TIMEOUT_MS; + if (!Number.isFinite(this.setupTimeoutMs) || this.setupTimeoutMs <= 0) { + throw invalidManifest('plugin setup timeout must be a positive finite number'); + } + } + + async list(): Promise { + const file = await this.readRegistry(); + return Object.freeze([...file.plugins].sort((left, right) => left.id.localeCompare(right.id))); + } + + async add(source: string): Promise { + return this.mutate(async (assertLease) => { + const candidate = await this.resolveSource(source); + let committed = false; + try { + let id: string; + let version: string; + let format: InstalledMossPlugin['format'] = 'moss-v1'; + try { + const manifest = await readMossPluginManifest(candidate.root); + id = manifest.id; + version = manifest.version; + } catch (mossError) { + try { + await access(path.join(candidate.root, MANIFEST_NAME)); + throw mossError; + } catch (manifestAccessError) { + if (manifestAccessError === mossError) throw mossError; + } + const compatibility = await inspectDshPackageCompatibility(candidate.root); + if (!compatibility.compatible || !compatibility.id || !compatibility.version) { + throw invalidManifest( + `source is neither a Moss plugin nor a compatible DSH package: ${compatibility.reasons.join('; ')}`, + mossError + ); + } + id = compatibility.id; + version = compatibility.version; + format = 'dsh-package-v1'; + } + const existing = (await this.readRegistry()).plugins; + const previous = existing.find((entry) => entry.id === id); + if (previous && !candidate.discard) { + throw invalidManifest(`plugin already installed: ${id}`); + } + if (previous?.version === version) { + throw invalidManifest(`plugin version already installed: ${id}@${version}`); + } + const entry: InstalledMossPlugin = { + id, + version, + source, + root: candidate.root, + enabled: previous?.enabled ?? false, + installedAt: new Date().toISOString(), + format, + ...(previous + ? { + lastGood: { + version: previous.version, + source: previous.source, + root: previous.root, + format: previous.format, + }, + } + : {}), + }; + await assertInstalledIdentity(entry); + await this.writeRegistry( + previous + ? existing.map((candidateEntry) => (candidateEntry.id === id ? entry : candidateEntry)) + : [...existing, entry], + assertLease + ); + committed = true; + if (previous?.lastGood) { + await this.purgeNpmRoot(previous.lastGood.root).catch(() => {}); + } + return entry; + } finally { + if (!committed) await candidate.discard?.().catch(() => {}); + } + }); + } + + async remove(id: string): Promise { + let removed: InstalledMossPlugin | undefined; + await this.mutate(async (assertLease) => { + const entries = (await this.readRegistry()).plugins; + removed = entries.find((entry) => entry.id === id); + if (!removed) throw invalidManifest(`plugin not installed: ${id}`); + await this.writeRegistry( + entries.filter((entry) => entry.id !== id), + assertLease + ); + }); + if (removed) await this.purgeRemovedPlugin(removed).catch(() => {}); + } + + async enable(id: string): Promise { + await this.setEnabled(id, true); + } + + async disable(id: string): Promise { + await this.setEnabled(id, false); + } + + /** Restore the previous immutable npm generation after candidate activation fails. @beta */ + async rollback(id: string): Promise { + let failed: InstalledMossPlugin | undefined; + const restored = await this.mutate(async (assertLease) => { + const entries = (await this.readRegistry()).plugins; + const current = entries.find((entry) => entry.id === id); + if (!current?.lastGood) throw invalidManifest(`plugin has no last-good generation: ${id}`); + failed = current; + const replacement: InstalledMossPlugin = { + id: current.id, + ...current.lastGood, + enabled: current.enabled, + installedAt: current.installedAt, + }; + await this.writeRegistry( + entries.map((entry) => (entry.id === id ? replacement : entry)), + assertLease + ); + return replacement; + }); + if (failed) await this.purgeNpmRoot(failed.root).catch(() => {}); + return restored; + } + + async loadEnabled(): Promise { + const plugins: MossPlugin[] = []; + const failures: Array<{ id: string; message: string; recovered?: boolean }> = []; + for (const entry of await this.list()) { + if (!entry.enabled) continue; + try { + plugins.push(await this.load(entry)); + } catch (error) { + const candidateMessage = error instanceof Error ? error.message : String(error); + if (entry.lastGood) { + try { + const restored = await this.rollback(entry.id); + plugins.push(await this.load(restored)); + failures.push({ + id: entry.id, + message: `candidate ${entry.version} failed and last-good ${restored.version} was restored: ${candidateMessage}`, + recovered: true, + }); + continue; + } catch (recoveryError) { + failures.push({ + id: entry.id, + message: `candidate failed (${candidateMessage}); last-good recovery failed (${diagnosticMessage(recoveryError)})`, + }); + continue; + } + } + failures.push({ id: entry.id, message: candidateMessage }); + } + } + return { plugins: Object.freeze(plugins), failures: Object.freeze(failures) }; + } + + async doctor(): Promise { + const results: MossPluginDoctorResult[] = []; + for (const entry of await this.list()) { + try { + await assertInstalledIdentity(entry); + results.push({ + id: entry.id, + status: entry.enabled ? 'ok' : 'disabled', + message: entry.enabled + ? `${entry.version} passed static validation` + : `${entry.version} is disabled; static validation passed`, + }); + } catch (error) { + results.push({ + id: entry.id, + status: 'error', + message: diagnosticMessage(error), + }); + } + } + return Object.freeze(results); + } + + /** Load and isolate-validate one installed plugin before main-thread activation. @beta */ + async loadInstalled( + id: string, + options: { readonly config?: Readonly> } = {} + ): Promise { + const entry = (await this.list()).find((candidate) => candidate.id === id); + if (!entry) throw invalidManifest(`plugin not installed: ${id}`); + return this.load(entry, options.config); + } + + private async load( + entry: InstalledMossPlugin, + explicitConfig?: Readonly> + ): Promise { + await assertInstalledIdentity(entry); + const config = explicitConfig ?? (await this.loadPluginConfig(entry)); + if (entry.format === 'dsh-package-v1') { + const imported = await importDshPackage(entry.root); + return { ...imported.plugin, config }; + } + const manifest = await readMossPluginManifest(entry.root); + const moduleUrl = pathToFileURL(path.resolve(entry.root, manifest.runtime.module)).href; + return createIsolatedMossPlugin({ + moduleUrl, + exportName: manifest.runtime.export ?? 'default', + pluginId: manifest.id, + timeoutMs: this.setupTimeoutMs, + manifestWebContributions: manifest.web?.contributions, + config, + }); + } + + private async setEnabled(id: string, enabled: boolean): Promise { + await this.mutate(async (assertLease) => { + const entries = (await this.readRegistry()).plugins; + const target = entries.find((entry) => entry.id === id); + if (!target) throw invalidManifest(`plugin not installed: ${id}`); + if (enabled) { + try { + await assertInstalledIdentity(target); + } catch (error) { + throw invalidManifest( + `plugin ${id} validation failed: ${diagnosticMessage(error)}`, + error + ); + } + } + await this.writeRegistry( + entries.map((entry) => (entry.id === id ? { ...entry, enabled } : entry)), + assertLease + ); + }); + } + + private async mutate(operation: (assertLease: () => void) => Promise): Promise { + await mkdir(this.registryDir, { recursive: true, mode: 0o700 }); + let compromised: Error | undefined; + let release: (() => Promise) | undefined; + try { + release = await lockfile.lock(this.registryDir, { + stale: REGISTRY_LOCK_STALE_MS, + update: 1_000, + retries: { retries: 600, factor: 1, minTimeout: 50, maxTimeout: 50, randomize: true }, + onCompromised: (error) => { + compromised = error; + }, + }); + } catch (error) { + throw wrapAsMoss(error, ErrorCode.USER_INPUT_INVALID, { + message: 'unable to lock the plugin registry', + context: { registryPath: this.registryPath }, + }); + } + const assertLease = (): void => { + if (compromised) { + throw wrapAsMoss(compromised, ErrorCode.USER_INPUT_INVALID, { + message: 'plugin registry lock was compromised', + context: { registryPath: this.registryPath }, + }); + } + }; + try { + const result = await operation(assertLease); + assertLease(); + return result; + } finally { + try { + await release(); + } catch { + // A completed atomic rename is the committed post-condition. Reporting a + // release failure as an operation failure would make callers roll back + // live state even though the durable registry already changed. The + // heartbeat lock has a finite stale interval and is safe to recover. + } + } + } + + private async resolveSource(source: string): Promise { + const officialRoot = officialPluginRoot(source); + if (officialRoot) return { root: await realpath(officialRoot) }; + const asPath = path.resolve(source); + try { + return { root: await realpath(asPath) }; + } catch { + const packageName = exactNpmPackageName(source); + if (!packageName) { + throw invalidManifest( + `unsupported plugin source: ${source}; npm plugins require an exact version` + ); + } + const generationRoot = path.join( + this.npmRoot, + 'versions', + encodeURIComponent(source), + randomUUID() + ); + await mkdir(generationRoot, { recursive: true, mode: 0o700 }); + try { + const npm = await resolveNpmInvocation(); + await this.npmRunner(npm.command, { + args: [ + ...npm.argsPrefix, + 'install', + '--ignore-scripts', + '--no-save', + '--no-audit', + '--no-fund', + '--package-lock=false', + '--prefix', + generationRoot, + source, + ], + timeout: 120_000, + maxBuffer: 2 * 1024 * 1024, + }); + const root = await realpath( + path.join(generationRoot, 'node_modules', ...packageName.split('/')) + ); + return { + root, + discard: () => rm(generationRoot, { recursive: true, force: true }), + }; + } catch (error) { + await rm(generationRoot, { recursive: true, force: true }).catch(() => {}); + throw error; + } + } + } + + private async loadPluginConfig( + entry: InstalledMossPlugin + ): Promise>> { + const { MossPluginConfigStore, readMossPluginConfigSchema } = + await import('./plugin-config-store.js'); + const schema = + entry.format === 'dsh-package-v1' + ? (await importDshPackage(entry.root)).configSchema + : await readMossPluginConfigSchema(entry.root); + if (!schema) return Object.freeze({}); + return new MossPluginConfigStore({ + configDir: path.dirname(this.registryDir), + }).loadRuntimeConfig(entry.id, schema); + } + + private async purgeRemovedPlugin(entry: InstalledMossPlugin): Promise { + const configPath = path.join( + path.dirname(this.registryDir), + 'plugins', + 'config', + `${encodeURIComponent(entry.id)}.json` + ); + await rm(configPath, { force: true }); + await this.purgeNpmRoot(entry.root); + if (entry.lastGood) await this.purgeNpmRoot(entry.lastGood.root); + } + + private async purgeNpmRoot(root: string): Promise { + const realNpmRoot = await realpath(this.npmRoot).catch(() => this.npmRoot); + const realPluginRoot = await realpath(root).catch(() => root); + const relative = path.relative(realNpmRoot, realPluginRoot); + if (!relative.startsWith('..') && !path.isAbsolute(relative)) { + const parts = relative.split(path.sep); + const nodeModules = parts.indexOf('node_modules'); + if (nodeModules > 0) { + await rm(path.join(realNpmRoot, ...parts.slice(0, nodeModules)), { + recursive: true, + force: true, + }); + } + } + } + + private async readRegistry(): Promise { + try { + const parsed = JSON.parse(await readFile(this.registryPath, 'utf8')) as InstalledPluginFile; + if (parsed.schemaVersion !== 1 || !Array.isArray(parsed.plugins)) + throw new Error('invalid registry'); + return { + schemaVersion: 1, + plugins: parsed.plugins.map((entry, index) => validateInstalledPlugin(entry, index)), + }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') + return { schemaVersion: 1, plugins: [] }; + throw wrapAsMoss(error, ErrorCode.USER_INPUT_INVALID, { + message: `unable to read ${this.registryPath}`, + }); + } + } + + private async writeRegistry( + plugins: readonly InstalledMossPlugin[], + assertLease: () => void + ): Promise { + await mkdir(this.registryDir, { recursive: true, mode: 0o700 }); + const temporary = `${this.registryPath}.${process.pid}.${randomUUID()}.tmp`; + await writeFile(temporary, `${JSON.stringify({ schemaVersion: 1, plugins }, null, 2)}\n`, { + mode: 0o600, + }); + try { + assertLease(); + await rename(temporary, this.registryPath); + } catch (error) { + await rm(temporary, { force: true }).catch(() => {}); + throw error; + } + } +} diff --git a/packages/moss-agent/src/plugins/isolated-plugin-runtime.ts b/packages/moss-agent/src/plugins/isolated-plugin-runtime.ts new file mode 100644 index 00000000..c21b37f0 --- /dev/null +++ b/packages/moss-agent/src/plugins/isolated-plugin-runtime.ts @@ -0,0 +1,386 @@ +import { Worker } from 'node:worker_threads'; +import { ErrorCode, MossError } from '../errors.js'; +import type { + MossPlugin, + MossPluginContext, + MossPluginMcpPreset, + MossWebContribution, +} from '../core/plugins/plugin-host.js'; +import type { + LLMProvider, + LLMRequestOptions, + LLMResponse, + LLMStreamEvent, +} from '../core/llm/llm-provider.js'; +import type { SkillMeta } from '../skills/types.js'; +import type { SubagentExpertDefinition } from '../core/subagent/expert-registry.js'; +import type { StructuredToolResult, ToolContext, ToolMetadata } from '../core/tools/tool-types.js'; + +interface IsolatedContributions { + readonly tools: ReadonlyArray<{ + readonly name: string; + readonly description: string; + readonly inputSchema: { + type: 'object'; + properties: Record; + required?: string[]; + }; + readonly metadata?: ToolMetadata; + readonly structured: boolean; + }>; + readonly skills: readonly SkillMeta[]; + readonly experts: readonly SubagentExpertDefinition[]; + readonly commands: ReadonlyArray<{ + readonly id: string; + readonly title: string; + readonly description?: string; + }>; + readonly providers: ReadonlyArray<{ readonly id: string; readonly displayName: string }>; + readonly mcpPresets: readonly MossPluginMcpPreset[]; + readonly promptLayers: readonly string[]; + readonly webContributions: readonly MossWebContribution[]; +} + +interface ReadyMessage { + readonly type: 'ready'; + readonly contributions: IsolatedContributions; +} + +interface PendingCall { + readonly resolve: (value: unknown) => void; + readonly reject: (error: Error) => void; + readonly onEvent?: (event: LLMStreamEvent) => void; + readonly removeAbortListener?: () => void; +} + +function pluginError(message: string): MossError { + return new MossError({ code: ErrorCode.USER_INPUT_INVALID, message }); +} + +function serializableToolContext(context: ToolContext): Record { + // Host callbacks (subagents, output sinks, async registries) are deliberately + // not exposed across the trust boundary. Isolated plugins must contribute an + // ordinary Tool and let the host's pre-tool approval layer make decisions. + return { + workspaceDir: context.workspaceDir, + sessionKey: context.sessionKey, + ...(context.sessionId ? { sessionId: context.sessionId } : {}), + ...(context.runId ? { runId: context.runId } : {}), + ...(context.toolCallId ? { toolCallId: context.toolCallId } : {}), + }; +} + +function serializableLlmOptions( + options: LLMRequestOptions +): Omit { + const { abortSignal: _, ...serializable } = options; + return serializable; +} + +class IsolatedPluginWorker { + private readonly pending = new Map(); + private sequence = 0; + private closed = false; + + constructor( + private readonly worker: Worker, + readonly contributions: IsolatedContributions, + private readonly pluginId: string + ) { + worker.on('message', (message: unknown) => this.handleMessage(message)); + worker.on('error', (error) => { + this.closed = true; + this.failPending(pluginError(`isolated plugin ${pluginId} failed: ${error.message}`)); + }); + worker.on('exit', (code) => { + if (!this.closed) { + this.closed = true; + this.failPending(pluginError(`isolated plugin ${pluginId} exited with code ${code}`)); + } + }); + } + + call( + method: string, + target: string | undefined, + args: readonly unknown[], + onEvent?: (event: LLMStreamEvent) => void, + signal?: AbortSignal + ): Promise { + if (this.closed) + return Promise.reject(pluginError(`isolated plugin is disposed: ${this.pluginId}`)); + const requestId = ++this.sequence; + return new Promise((resolve, reject) => { + const abort = () => { + this.worker.postMessage({ type: 'cancel', requestId }); + this.pending.delete(requestId); + if (this.pending.size === 0) this.worker.unref(); + reject(new MossError({ code: ErrorCode.USER_ABORTED, message: 'plugin call aborted' })); + }; + if (signal?.aborted) { + abort(); + return; + } + this.worker.ref(); + signal?.addEventListener('abort', abort, { once: true }); + this.pending.set(requestId, { + resolve: (value) => resolve(value as T), + reject, + ...(onEvent ? { onEvent } : {}), + ...(signal + ? { removeAbortListener: () => signal.removeEventListener('abort', abort) } + : {}), + }); + try { + this.worker.postMessage({ type: 'call', requestId, method, target, args }); + } catch (error) { + this.pending.delete(requestId); + if (this.pending.size === 0) this.worker.unref(); + signal?.removeEventListener('abort', abort); + reject(error instanceof Error ? error : pluginError(String(error))); + } + }); + } + + async close(timeoutMs = 1_000): Promise { + if (this.closed) return; + this.closed = true; + const disposed = new Promise((resolve, reject) => { + const onMessage = (message: unknown) => { + if (!message || typeof message !== 'object') return; + const type = (message as Record).type; + if (type !== 'disposed' && type !== 'dispose-error') return; + this.worker.off('message', onMessage); + if (type === 'dispose-error') { + reject(pluginError(String((message as Record).message))); + } else { + resolve(); + } + }; + this.worker.on('message', onMessage); + this.worker.postMessage({ type: 'dispose' }); + }); + let timer: NodeJS.Timeout | undefined; + try { + await Promise.race([ + disposed, + new Promise((_, reject) => { + timer = setTimeout( + () => reject(pluginError(`isolated plugin dispose timed out: ${this.pluginId}`)), + timeoutMs + ); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + this.failPending(pluginError(`isolated plugin is disposed: ${this.pluginId}`)); + await this.worker.terminate(); + } + } + + private handleMessage(message: unknown): void { + if (!message || typeof message !== 'object') return; + const value = message as Record; + const requestId = value.requestId; + if (typeof requestId !== 'number') return; + const pending = this.pending.get(requestId); + if (!pending) return; + if (value.type === 'event') { + pending.onEvent?.(value.event as LLMStreamEvent); + return; + } + this.pending.delete(requestId); + if (this.pending.size === 0) this.worker.unref(); + pending.removeAbortListener?.(); + if (value.type === 'result') pending.resolve(value.value); + else pending.reject(pluginError(String(value.message ?? 'isolated plugin call failed'))); + } + + private failPending(error: Error): void { + for (const pending of this.pending.values()) { + pending.removeAbortListener?.(); + pending.reject(error); + } + this.pending.clear(); + this.worker.unref(); + } +} + +async function startWorker( + moduleUrl: string, + exportName: string, + pluginId: string, + timeoutMs: number, + pluginConfig: Readonly> +): Promise { + const worker = new Worker(new URL('./plugin-setup-worker.js', import.meta.url), { + workerData: { moduleUrl, exportName, expectedId: pluginId, pluginConfig }, + }); + worker.unref(); + let timer: NodeJS.Timeout | undefined; + let onStartupMessage: ((message: unknown) => void) | undefined; + let onStartupError: ((error: Error) => void) | undefined; + try { + const contributions = await new Promise((resolve, reject) => { + onStartupError = reject; + worker.once('error', onStartupError); + onStartupMessage = (message: unknown) => { + if (!message || typeof message !== 'object') return; + const value = message as Record; + if (value.type === 'ready') resolve((message as ReadyMessage).contributions); + if (value.type === 'setup-error') reject(pluginError(String(value.message))); + }; + worker.on('message', onStartupMessage); + timer = setTimeout( + () => + reject( + pluginError( + `plugin setup validation timed out after ${ + timeoutMs % 1_000 === 0 + ? `${timeoutMs / 1_000} seconds` + : `${timeoutMs} milliseconds` + }` + ) + ), + timeoutMs + ); + }); + return new IsolatedPluginWorker(worker, contributions, pluginId); + } catch (error) { + await worker.terminate(); + throw error; + } finally { + if (timer) clearTimeout(timer); + if (onStartupMessage) worker.off('message', onStartupMessage); + if (onStartupError) worker.off('error', onStartupError); + } +} + +function remoteProvider( + runtime: IsolatedPluginWorker, + metadata: { + readonly providerId: string; + readonly id: string; + readonly displayName: string; + readonly capabilities?: LLMProvider['capabilities']; + readonly countTokens: boolean; + } +): LLMProvider { + return { + id: metadata.id, + displayName: metadata.displayName, + ...(metadata.capabilities ? { capabilities: metadata.capabilities } : {}), + complete: (options) => + runtime.call( + 'provider.complete', + metadata.providerId, + [serializableLlmOptions(options)], + undefined, + options.abortSignal + ), + stream: (options, onEvent) => + runtime.call( + 'provider.stream', + metadata.providerId, + [serializableLlmOptions(options)], + onEvent, + options.abortSignal + ), + ...(metadata.countTokens + ? { + countTokens: (text: string) => + runtime.call('provider.countTokens', metadata.providerId, [text]), + } + : {}), + }; +} + +function registerRemoteContributions( + context: MossPluginContext, + runtime: IsolatedPluginWorker +): void { + for (const tool of runtime.contributions.tools) { + context.registerTool({ + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema, + ...(tool.metadata ? { metadata: tool.metadata } : {}), + execute: (input, toolContext) => + runtime.call( + 'tool.execute', + tool.name, + [input, serializableToolContext(toolContext)], + undefined, + toolContext.abortSignal + ), + ...(tool.structured + ? { + executeStructured: (input: unknown, toolContext: ToolContext) => + runtime.call( + 'tool.executeStructured', + tool.name, + [input, serializableToolContext(toolContext)], + undefined, + toolContext.abortSignal + ), + } + : {}), + }); + } + for (const skill of runtime.contributions.skills) context.registerSkill(skill); + for (const expert of runtime.contributions.experts) context.registerExpert(expert); + for (const command of runtime.contributions.commands) { + context.registerCommand({ + ...command, + expand: (args) => runtime.call('command.expand', command.id, [args]), + }); + } + for (const factory of runtime.contributions.providers) { + context.registerProvider({ + ...factory, + async create(config) { + const metadata = await runtime.call[1]>( + 'provider.create', + factory.id, + [config] + ); + return remoteProvider(runtime, metadata); + }, + }); + } + for (const preset of runtime.contributions.mcpPresets) context.registerMcpPreset(preset); + for (const layer of runtime.contributions.promptLayers) context.addPromptLayer(layer); + for (const contribution of runtime.contributions.webContributions) { + context.registerWebContribution(contribution); + } +} + +/** Start a trusted-code plugin in a termination-bounded Worker/RPC boundary. @internal */ +export async function createIsolatedMossPlugin(options: { + readonly moduleUrl: string; + readonly exportName: string; + readonly pluginId: string; + readonly timeoutMs: number; + readonly manifestWebContributions?: readonly MossWebContribution[]; + readonly config?: Readonly>; +}): Promise { + const runtime = await startWorker( + options.moduleUrl, + options.exportName, + options.pluginId, + options.timeoutMs, + options.config ?? {} + ); + return { + id: options.pluginId, + config: Object.freeze({ ...(options.config ?? {}) }), + disposeCandidate: () => runtime.close(), + setup(context) { + registerRemoteContributions(context, runtime); + for (const contribution of options.manifestWebContributions ?? []) { + context.registerWebContribution(contribution); + } + return () => runtime.close(); + }, + }; +} diff --git a/packages/moss-agent/src/plugins/plugin-config-schema.ts b/packages/moss-agent/src/plugins/plugin-config-schema.ts new file mode 100644 index 00000000..78ac19d4 --- /dev/null +++ b/packages/moss-agent/src/plugins/plugin-config-schema.ts @@ -0,0 +1,79 @@ +import { ErrorCode, MossError } from '../errors.js'; +import { + validateJsonSchemaDefinition, + type JsonSchema, +} from '../structured-output/schema-validator.js'; + +/** JSON Schema subset supported by Moss plugin configuration. @beta */ +export interface MossPluginJsonSchema { + type?: string | string[]; + properties?: Record; + required?: string[]; + items?: MossPluginJsonSchema | MossPluginJsonSchema[]; + enum?: unknown[]; + const?: unknown; + additionalProperties?: boolean | MossPluginJsonSchema; + description?: string; + title?: string; + default?: unknown; + minimum?: number; + maximum?: number; + minLength?: number; + maxLength?: number; + pattern?: string; + format?: string; + minItems?: number; + maxItems?: number; + uniqueItems?: boolean; + anyOf?: MossPluginJsonSchema[]; + oneOf?: MossPluginJsonSchema[]; + allOf?: MossPluginJsonSchema[]; + not?: MossPluginJsonSchema; + if?: MossPluginJsonSchema; + then?: MossPluginJsonSchema; + else?: MossPluginJsonSchema; + $ref?: string; + $defs?: Record; + definitions?: Record; +} + +/** Top-level plugin configuration property supported by the schema renderer/store. @beta */ +export interface MossPluginConfigPropertySchema extends MossPluginJsonSchema { + /** Values marked write-only are never returned by the browser-safe view. */ + readonly writeOnly?: boolean; +} + +/** Supported JSON Schema subset for one plugin's configuration object. @beta */ +export interface MossPluginConfigSchema extends MossPluginJsonSchema { + readonly type: 'object'; + readonly properties?: Readonly>; +} + +/** Parse and validate the supported plugin configuration schema subset. @internal */ +export function parseMossPluginConfigSchema( + value: unknown, + schemaPath: string +): MossPluginConfigSchema { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw invalidSchema(`${schemaPath} must contain a JSON Schema object`); + } + const schema = value as JsonSchema; + const definition = validateJsonSchemaDefinition(schema); + if (!definition.valid) { + throw invalidSchema(`${schemaPath} is invalid: ${definition.errors.join('; ')}`); + } + if (schema.type !== 'object') { + throw invalidSchema(`${schemaPath} must declare type "object"`); + } + for (const [name, property] of Object.entries(schema.properties ?? {})) { + const writeOnly = (property as MossPluginConfigPropertySchema).writeOnly; + if (writeOnly !== undefined && typeof writeOnly !== 'boolean') { + throw invalidSchema(`${schemaPath}.properties.${name}.writeOnly must be a boolean`); + } + } + return schema as MossPluginConfigSchema; +} + +function invalidSchema(message: string): MossError { + return new MossError({ code: ErrorCode.USER_INPUT_INVALID, message }); +} diff --git a/packages/moss-agent/src/plugins/plugin-config-store.ts b/packages/moss-agent/src/plugins/plugin-config-store.ts new file mode 100644 index 00000000..a5b91ab6 --- /dev/null +++ b/packages/moss-agent/src/plugins/plugin-config-store.ts @@ -0,0 +1,280 @@ +import { randomUUID } from 'node:crypto'; +import { chmod, mkdir, readFile, realpath, rename, rm, writeFile } from 'node:fs/promises'; +import path from 'node:path'; + +import * as lockfile from 'proper-lockfile'; + +import { ErrorCode, MossError, wrapAsMoss } from '../errors.js'; +import { validateJsonSchema } from '../structured-output/schema-validator.js'; +import { readMossPluginManifest } from './installed-plugin-registry.js'; +import { + parseMossPluginConfigSchema, + type MossPluginConfigSchema, +} from './plugin-config-schema.js'; + +export type { + MossPluginConfigPropertySchema, + MossPluginConfigSchema, + MossPluginJsonSchema, +} from './plugin-config-schema.js'; + +const PLUGIN_ID_PATTERN = /^[a-z0-9]+(?:[./-][a-z0-9]+)*$/; + +/** Browser-safe plugin configuration projection. @beta */ +export interface MossPluginConfigView { + readonly values: Readonly>; + readonly secrets: Readonly>; +} + +/** Storage options for the per-plugin configuration store. @beta */ +export interface MossPluginConfigStoreOptions { + readonly configDir: string; +} + +interface StoredPluginConfig { + readonly schemaVersion: 1; + readonly values: Record; +} + +function invalidConfig(message: string, cause?: unknown): MossError { + return new MossError({ code: ErrorCode.USER_INPUT_INVALID, message, cause }); +} + +function assertPluginId(pluginId: string): void { + if (!PLUGIN_ID_PATTERN.test(pluginId)) throw invalidConfig(`invalid plugin id: ${pluginId}`); +} + +function validationMessage( + errors: readonly { readonly path: string; readonly message: string }[] +): string { + return errors.map(({ path: errorPath, message }) => `${errorPath}: ${message}`).join('; '); +} + +/** Read and validate the optional config schema declared by a plugin manifest. @beta */ +export async function readMossPluginConfigSchema( + pluginRoot: string +): Promise { + const manifest = await readMossPluginManifest(pluginRoot); + if (!manifest.configSchema) return undefined; + try { + const root = await realpath(pluginRoot); + const schemaPath = await realpath(path.resolve(root, manifest.configSchema)); + const relative = path.relative(root, schemaPath); + if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) { + throw invalidConfig('configSchema escapes plugin root'); + } + return parseMossPluginConfigSchema(JSON.parse(await readFile(schemaPath, 'utf8')), schemaPath); + } catch (error) { + if (error instanceof MossError) throw error; + throw wrapAsMoss(error, ErrorCode.USER_INPUT_INVALID, { + message: `unable to read plugin config schema for ${manifest.id}`, + context: { pluginId: manifest.id }, + }); + } +} + +/** + * Atomic per-plugin configuration store with browser-safe write-only projection. + * + * The trusted runtime may call `loadRuntimeConfig()` to obtain the complete + * validated value. Browser adapters must use `getView()` and the dedicated + * secret mutation methods instead. + * + * @beta + */ +export class MossPluginConfigStore { + private readonly directory: string; + + constructor(options: MossPluginConfigStoreOptions) { + this.directory = path.join(options.configDir, 'plugins', 'config'); + } + + private filePathFor(pluginId: string): string { + assertPluginId(pluginId); + return path.join(this.directory, `${encodeURIComponent(pluginId)}.json`); + } + + /** Return non-secret values plus configured/not-configured secret state. @beta */ + async getView(pluginId: string, schema: MossPluginConfigSchema): Promise { + const stored = await this.read(pluginId); + const values: Record = {}; + const secrets: Record = {}; + for (const [name, property] of Object.entries(schema.properties ?? {})) { + if (property.writeOnly) { + secrets[name] = { configured: Object.hasOwn(stored.values, name) }; + } else if (Object.hasOwn(stored.values, name)) { + values[name] = stored.values[name]; + } + } + return Object.freeze({ values: Object.freeze(values), secrets: Object.freeze(secrets) }); + } + + /** Update non-secret fields after validating the supported JSON Schema subset. @beta */ + async update( + pluginId: string, + schema: MossPluginConfigSchema, + patch: Readonly> + ): Promise { + if (!patch || typeof patch !== 'object' || Array.isArray(patch)) { + throw invalidConfig('plugin config update must be an object'); + } + await this.mutate(pluginId, schema, (values) => { + for (const [name, value] of Object.entries(patch)) { + const property = schema.properties?.[name]; + if (!property) throw invalidConfig(`unknown plugin config property: ${name}`); + if (property.writeOnly) { + throw invalidConfig(`writeOnly property requires putSecret(): ${name}`); + } + values[name] = value; + } + }); + } + + /** Store one write-only value without exposing existing secret material. @beta */ + async putSecret( + pluginId: string, + schema: MossPluginConfigSchema, + name: string, + value: unknown + ): Promise { + const property = schema.properties?.[name]; + if (!property?.writeOnly) + throw invalidConfig(`plugin config property is not writeOnly: ${name}`); + await this.mutate(pluginId, schema, (values) => { + values[name] = value; + }); + } + + /** Delete one write-only value; required-value validation resumes on runtime load. @beta */ + async deleteSecret( + pluginId: string, + schema: MossPluginConfigSchema, + name: string + ): Promise { + const property = schema.properties?.[name]; + if (!property?.writeOnly) + throw invalidConfig(`plugin config property is not writeOnly: ${name}`); + await this.mutate(pluginId, schema, (values) => { + delete values[name]; + }); + } + + /** Load the complete configuration for trusted runtime setup and enforce required fields. @beta */ + async loadRuntimeConfig( + pluginId: string, + schema: MossPluginConfigSchema + ): Promise>> { + const stored = await this.read(pluginId); + const validation = validateJsonSchema(stored.values, schema); + if (!validation.valid) { + throw invalidConfig( + `plugin config validation failed: ${validationMessage(validation.errors)}` + ); + } + return Object.freeze({ ...stored.values }); + } + + /** Replace the complete trusted runtime value during activation rollback. @internal */ + async replaceRuntimeConfig( + pluginId: string, + schema: MossPluginConfigSchema, + replacement: Readonly> + ): Promise { + await this.mutate(pluginId, schema, (values) => { + for (const name of Object.keys(values)) delete values[name]; + Object.assign(values, replacement); + }); + } + + /** Remove all persisted values and write-only secrets for an uninstalled plugin. @internal */ + async remove(pluginId: string): Promise { + await rm(this.filePathFor(pluginId), { force: true }); + } + + private async mutate( + pluginId: string, + schema: MossPluginConfigSchema, + mutation: (values: Record) => void + ): Promise { + assertPluginId(pluginId); + await mkdir(this.directory, { recursive: true, mode: 0o700 }); + if (process.platform !== 'win32') await chmod(this.directory, 0o700); + let release: (() => Promise) | undefined; + try { + release = await lockfile.lock(this.directory, { + stale: 5_000, + update: 1_000, + retries: { retries: 200, factor: 1, minTimeout: 25, maxTimeout: 25, randomize: true }, + }); + } catch (error) { + throw wrapAsMoss(error, ErrorCode.CONFIG_IO_FAILED, { + message: `unable to lock plugin config for ${pluginId}`, + context: { pluginId }, + }); + } + try { + const stored = await this.read(pluginId); + const values = { ...stored.values }; + mutation(values); + const partialSchema: MossPluginConfigSchema = { ...schema, required: [] }; + const validation = validateJsonSchema(values, partialSchema); + if (!validation.valid) { + throw invalidConfig( + `plugin config validation failed: ${validationMessage(validation.errors)}` + ); + } + await this.write(pluginId, { schemaVersion: 1, values }); + } finally { + try { + await release(); + } catch { + // The atomic rename already committed. A stale lock can be recovered; + // surfacing this as a failed write would desynchronise live rollback + // from the durable configuration post-condition. + } + } + } + + private async read(pluginId: string): Promise { + const filePath = this.filePathFor(pluginId); + try { + const parsed = JSON.parse(await readFile(filePath, 'utf8')) as unknown; + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('config root must be an object'); + } + const candidate = parsed as Partial; + if ( + candidate.schemaVersion !== 1 || + !candidate.values || + typeof candidate.values !== 'object' || + Array.isArray(candidate.values) + ) { + throw new Error('invalid plugin config file'); + } + return { schemaVersion: 1, values: { ...candidate.values } }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return { schemaVersion: 1, values: {} }; + } + throw wrapAsMoss(error, ErrorCode.CONFIG_IO_FAILED, { + message: `unable to read plugin config for ${pluginId}`, + context: { pluginId }, + }); + } + } + + private async write(pluginId: string, stored: StoredPluginConfig): Promise { + const filePath = this.filePathFor(pluginId); + const temporary = `${filePath}.${process.pid}.${randomUUID()}.tmp`; + try { + await writeFile(temporary, `${JSON.stringify(stored, null, 2)}\n`, { mode: 0o600 }); + await rename(temporary, filePath); + } catch (error) { + await rm(temporary, { force: true }).catch(() => {}); + throw wrapAsMoss(error, ErrorCode.CONFIG_IO_FAILED, { + message: `unable to write plugin config for ${pluginId}`, + context: { pluginId }, + }); + } + } +} diff --git a/packages/moss-agent/src/plugins/plugin-setup-worker.ts b/packages/moss-agent/src/plugins/plugin-setup-worker.ts new file mode 100644 index 00000000..56534a2a --- /dev/null +++ b/packages/moss-agent/src/plugins/plugin-setup-worker.ts @@ -0,0 +1,251 @@ +import { parentPort, workerData } from 'node:worker_threads'; +import type { + MossPlugin, + MossPluginCommand, + MossPluginDisposer, + MossPluginMcpPreset, + MossPluginProvider, + MossWebContribution, +} from '../core/plugins/plugin-host.js'; +import type { LLMProvider, LLMRequestOptions, LLMStreamEvent } from '../core/llm/llm-provider.js'; +import type { Tool, ToolContext } from '../core/tools/tool-types.js'; +import type { SkillMeta } from '../skills/types.js'; +import type { SubagentExpertDefinition } from '../core/subagent/expert-registry.js'; + +interface PluginSetupWorkerData { + readonly moduleUrl: string; + readonly exportName: string; + readonly expectedId: string; + readonly pluginConfig?: Readonly>; +} + +interface RpcRequest { + readonly type: 'call'; + readonly requestId: number; + readonly method: string; + readonly target?: string; + readonly args: readonly unknown[]; +} + +const tools = new Map(); +const commands = new Map(); +const providerFactories = new Map(); +const providers = new Map(); +const mcpPresets: MossPluginMcpPreset[] = []; +const skills: SkillMeta[] = []; +const experts: SubagentExpertDefinition[] = []; +const promptLayers: string[] = []; +const webContributions: MossWebContribution[] = []; +const effects: Array<() => MossPluginDisposer | Promise> = []; +const disposers: MossPluginDisposer[] = []; +let providerSequence = 0; +const callControllers = new Map(); + +function errorMessage(error: unknown): string { + if (!(error instanceof Error)) return String(error); + const cause = (error as Error & { cause?: unknown }).cause; + return cause instanceof Error && cause.message !== error.message + ? `${error.message}: ${cause.message}` + : error.message; +} + +function cloneableTool(tool: Tool): Record { + if (tool.normalizeInput) + throw new Error(`isolated plugin tool cannot use normalizeInput: ${tool.name}`); + return { + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema, + ...(tool.metadata ? { metadata: tool.metadata } : {}), + structured: typeof tool.executeStructured === 'function', + }; +} + +async function setup(): Promise { + const data = workerData as PluginSetupWorkerData; + const imported = (await import(data.moduleUrl)) as Record; + const candidate = imported[data.exportName]; + if ( + !candidate || + typeof candidate !== 'object' || + typeof (candidate as MossPlugin).setup !== 'function' + ) { + throw new Error(`plugin ${data.expectedId} did not export a MossPlugin`); + } + const plugin = candidate as MossPlugin; + if (plugin.id !== data.expectedId) { + throw new Error(`plugin export id does not match manifest: ${data.expectedId}`); + } + const returned = await plugin.setup({ + config: Object.freeze({ ...(data.pluginConfig ?? {}) }), + registerTool(tool) { + tools.set(tool.name, tool); + }, + registerSkill(skill) { + skills.push(skill); + }, + registerExpert(expert) { + experts.push(expert); + }, + registerCommand(command) { + commands.set(command.id, command); + }, + registerProvider(provider) { + providerFactories.set(provider.id, provider); + }, + registerMcpPreset(preset) { + mcpPresets.push(preset); + }, + addPromptLayer(layer) { + promptLayers.push(layer); + }, + registerWebContribution(contribution) { + webContributions.push(contribution); + }, + effect(effect) { + effects.push(effect); + }, + }); + if (typeof returned === 'function') disposers.push(returned); + for (const effect of effects) disposers.push(await effect()); + parentPort!.postMessage({ + type: 'ready', + contributions: { + tools: [...tools.values()].map(cloneableTool), + skills, + experts, + commands: [...commands.values()].map(({ id, title, description }) => ({ + id, + title, + ...(description ? { description } : {}), + })), + providers: [...providerFactories.values()].map(({ id, displayName }) => ({ + id, + displayName, + })), + mcpPresets, + promptLayers, + webContributions, + }, + }); +} + +function toolContext(value: unknown, abortSignal: AbortSignal): ToolContext { + const input = (value && typeof value === 'object' ? value : {}) as Record; + return { + workspaceDir: typeof input.workspaceDir === 'string' ? input.workspaceDir : process.cwd(), + sessionKey: typeof input.sessionKey === 'string' ? input.sessionKey : 'plugin-worker', + abortSignal, + ...(typeof input.sessionId === 'string' ? { sessionId: input.sessionId } : {}), + ...(typeof input.runId === 'string' ? { runId: input.runId } : {}), + ...(typeof input.toolCallId === 'string' ? { toolCallId: input.toolCallId } : {}), + }; +} + +async function call(request: RpcRequest): Promise { + const controller = new AbortController(); + callControllers.set(request.requestId, controller); + try { + if (request.method === 'tool.execute' || request.method === 'tool.executeStructured') { + const tool = tools.get(request.target ?? ''); + if (!tool) throw new Error(`isolated plugin tool not found: ${request.target}`); + const input = request.args[0]; + const context = toolContext(request.args[1], controller.signal); + return request.method === 'tool.executeStructured' + ? tool.executeStructured?.(input, context) + : tool.execute(input, context); + } + if (request.method === 'command.expand') { + const command = commands.get(request.target ?? ''); + if (!command) throw new Error(`isolated plugin command not found: ${request.target}`); + return command.expand(String(request.args[0] ?? '')); + } + if (request.method === 'provider.create') { + const factory = providerFactories.get(request.target ?? ''); + if (!factory) throw new Error(`isolated plugin provider not found: ${request.target}`); + const provider = await factory.create((request.args[0] ?? {}) as Record); + const providerId = `provider-${++providerSequence}`; + providers.set(providerId, provider); + return { + providerId, + id: provider.id, + displayName: provider.displayName, + capabilities: provider.capabilities, + countTokens: typeof provider.countTokens === 'function', + }; + } + const provider = providers.get(request.target ?? ''); + if (!provider) throw new Error(`isolated provider instance not found: ${request.target}`); + if (request.method === 'provider.complete') { + return provider.complete({ + ...(request.args[0] as LLMRequestOptions), + abortSignal: controller.signal, + }); + } + if (request.method === 'provider.countTokens') + return provider.countTokens?.(String(request.args[0] ?? '')); + if (request.method === 'provider.stream') { + return provider.stream( + { + ...(request.args[0] as LLMRequestOptions), + abortSignal: controller.signal, + }, + (event: LLMStreamEvent) => { + parentPort!.postMessage({ type: 'event', requestId: request.requestId, event }); + } + ); + } + throw new Error(`unknown isolated plugin RPC method: ${request.method}`); + } finally { + callControllers.delete(request.requestId); + } +} + +if (!parentPort) throw new Error('plugin setup worker requires a parent port'); + +parentPort.on('message', (message: unknown) => { + if ( + message && + typeof message === 'object' && + (message as Record).type === 'cancel' + ) { + const requestId = (message as Record).requestId; + if (typeof requestId === 'number') callControllers.get(requestId)?.abort(); + return; + } + if ( + message && + typeof message === 'object' && + (message as Record).type === 'dispose' + ) { + void (async () => { + for (const dispose of [...disposers].reverse()) await dispose(); + parentPort!.postMessage({ type: 'disposed' }); + })().catch((error: unknown) => { + parentPort!.postMessage({ type: 'dispose-error', message: errorMessage(error) }); + }); + return; + } + if ( + !message || + typeof message !== 'object' || + (message as Record).type !== 'call' + ) + return; + const request = message as RpcRequest; + void call(request).then( + (value) => parentPort!.postMessage({ type: 'result', requestId: request.requestId, value }), + (error: unknown) => + parentPort!.postMessage({ + type: 'error', + requestId: request.requestId, + message: errorMessage(error), + }) + ); +}); + +try { + await setup(); +} catch (error) { + parentPort.postMessage({ type: 'setup-error', message: errorMessage(error) }); +} diff --git a/packages/moss-agent/src/runtime/index.ts b/packages/moss-agent/src/runtime/index.ts index ad920a4d..56ee88ab 100644 --- a/packages/moss-agent/src/runtime/index.ts +++ b/packages/moss-agent/src/runtime/index.ts @@ -10,18 +10,52 @@ export { } from './shared-runtime.js'; export { ErrorCode } from '../errors.js'; export type { MossErrorOutcome } from '../errors.js'; +export { MOSS_WEB_SLOTS } from '../core/plugins/plugin-host.js'; export type { ProviderErrorAction, ProviderErrorCategory, ProviderErrorSurface, } from '../provider/index.js'; export type { + MossPluginCallState, + MossPluginCommand, MossPlugin, MossPluginCompositionSnapshot, MossPluginContext, MossPluginDisposer, MossPluginHandle, MossPluginHost, + MossPluginMcpPreset, + MossPluginProvider, MossPluginSnapshot, MossPluginState, + MossPluginUnloadOptions, + MossWebContribution, + MossWebSlot, } from '../core/plugins/plugin-host.js'; +export { + InstalledPluginRegistry, + readMossPluginManifest, + type InstalledMossPlugin, + type InstalledPluginRegistryOptions, + type LoadedMossPlugins, + type MossPluginDoctorResult, + type MossPluginManifestV1, + type MossPluginRuntimeManifest, + type MossPluginWebManifest, +} from '../plugins/installed-plugin-registry.js'; +export { + MossPluginConfigStore, + readMossPluginConfigSchema, + type MossPluginConfigPropertySchema, + type MossPluginConfigSchema, + type MossPluginJsonSchema, + type MossPluginConfigStoreOptions, + type MossPluginConfigView, +} from '../plugins/plugin-config-store.js'; +export { + importDshPackage, + inspectDshPackageCompatibility, + type DshPackageCompatibilityReport, + type ImportedDshPackage, +} from '../plugins/dsh-bundle-compatibility.js'; diff --git a/packages/moss-agent/src/runtime/shared-runtime.ts b/packages/moss-agent/src/runtime/shared-runtime.ts index 9603ccc7..65dbea1c 100644 --- a/packages/moss-agent/src/runtime/shared-runtime.ts +++ b/packages/moss-agent/src/runtime/shared-runtime.ts @@ -233,7 +233,7 @@ export async function createMossRuntime(options: CreateMossRuntimeOptions): Prom }); for (const tool of selectTools(toolProfile, services.skillRegistry)) agent.tools.register(tool); - for (const tool of options.extraTools ?? []) agent.tools.register(tool); + for (const tool of options.extraTools ?? []) agent.tools.replace(tool, 'host:extra-tools'); try { for (const plugin of options.plugins ?? []) await agent.plugins.install(plugin); } catch (error) { diff --git a/packages/moss-agent/src/tools/ask-user-question.ts b/packages/moss-agent/src/tools/ask-user-question.ts index 561b53e6..236379be 100644 --- a/packages/moss-agent/src/tools/ask-user-question.ts +++ b/packages/moss-agent/src/tools/ask-user-question.ts @@ -160,7 +160,7 @@ export const askUserQuestionTool: Tool = { } if (questions.length === 0) return 'Error: no valid questions provided.'; - const asker = getCliUserQuestionAsker(); + const asker = ctx.askUserQuestion ?? getCliUserQuestionAsker(); if (!asker) { return ( 'Error: interactive questions are unavailable in this non-interactive run. ' + diff --git a/packages/moss-agent/src/web-ui/client/api-client.ts b/packages/moss-agent/src/web-ui/client/api-client.ts new file mode 100644 index 00000000..415e8e18 --- /dev/null +++ b/packages/moss-agent/src/web-ui/client/api-client.ts @@ -0,0 +1,259 @@ +import type { + BootstrapResponse, + GoalSnapshot, + Interaction, + JobSnapshot, + MentionInventory, + PluginInventory, + PluginConfigResponse, + RuntimeInventory, + RuntimeMode, + SessionSummary, + SettingsSection, + SettingsSnapshot, + StreamEvent, + TimelineItem, + TodoSnapshot, + WorkspaceSummary, + WorkflowSnapshot, +} from './workbench-types.js'; + +export class ApiError extends Error { + constructor( + readonly status: number, + message: string + ) { + super(message); + } +} + +let csrfToken = ''; + +export const jsonRequest = async (url: string, options?: RequestInit): Promise => { + const method = options?.method?.toUpperCase() ?? 'GET'; + const headers = new Headers(options?.headers); + if (method !== 'GET' && method !== 'HEAD') { + if (!csrfToken) throw new ApiError(403, 'Moss Web security token is not ready'); + headers.set('x-moss-csrf', csrfToken); + } + const response = await fetch(url, { ...options, headers }); + if (!response.ok) { + const body = (await response.json().catch(() => ({}))) as { error?: string }; + throw new ApiError(response.status, body.error ?? `HTTP ${response.status}`); + } + return response.status === 204 ? (undefined as T) : ((await response.json()) as T); +}; + +const json = (method: string, body?: unknown): RequestInit => ({ + method, + headers: { 'content-type': 'application/json' }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), +}); +const sid = (id: string) => encodeURIComponent(id); + +export const api = { + bootstrap: async () => { + const response = await jsonRequest('/api/bootstrap'); + csrfToken = response.csrfToken; + return response; + }, + uploadAttachment: (filename: string, mimeType: string, contentBase64: string) => + jsonRequest<{ + attachment: { + id: string; + filename: string; + mimeType: string; + kind: 'text' | 'image'; + size: number; + downloadUrl: string; + }; + }>('/api/attachments', json('POST', { filename, mimeType, contentBase64 })), + deleteAttachment: (id: string) => jsonRequest(`/api/attachments/${sid(id)}`, json('DELETE')), + createArtifact: (workspaceRelativePath: string) => + jsonRequest<{ attachment: { id: string; filename: string; downloadUrl: string } }>( + '/api/artifacts', + json('POST', { workspaceRelativePath }) + ), + plugins: () => jsonRequest('/api/plugins'), + addPlugin: (source: string) => + jsonRequest<{ plugin: { id: string }; generation: number }>( + '/api/plugins/add', + json('POST', { source }) + ), + doctorPlugins: () => + jsonRequest<{ + results: Array<{ + id?: string; + pluginId?: string; + status?: string; + ok?: boolean; + message?: string; + }>; + generation: number; + }>('/api/plugins/doctor', json('POST')), + pluginConfig: (id: string) => jsonRequest(`/api/plugins/${sid(id)}/config`), + savePluginConfig: (id: string, values: Record) => + jsonRequest(`/api/plugins/${sid(id)}/config`, json('PUT', { values })), + putPluginSecret: (id: string, name: string, value: string) => + jsonRequest( + `/api/plugins/${sid(id)}/config/secrets/${sid(name)}`, + json('PUT', { value }) + ), + deletePluginSecret: (id: string, name: string) => + jsonRequest( + `/api/plugins/${sid(id)}/config/secrets/${sid(name)}`, + json('DELETE') + ), + workspaces: () => jsonRequest<{ workspaces: WorkspaceSummary[] }>('/api/workspaces'), + sessions: () => jsonRequest<{ sessions: SessionSummary[] }>('/api/sessions'), + search: (query: string) => + jsonRequest<{ hits: SessionSummary[] }>(`/api/sessions/search?q=${encodeURIComponent(query)}`), + messages: (id: string) => + jsonRequest<{ items: TimelineItem[] }>(`/api/sessions/${sid(id)}/messages`), + createSession: (workspaceId = 'current') => + jsonRequest<{ sessionId: string }>('/api/sessions', json('POST', { workspaceId })), + rename: (id: string, title: string) => + jsonRequest(`/api/sessions/${sid(id)}`, json('PATCH', { title })), + delete: (id: string, confirmation: string) => + jsonRequest(`/api/sessions/${sid(id)}`, json('DELETE', { confirmation })), + fork: (id: string) => + jsonRequest<{ session: SessionSummary }>(`/api/sessions/${sid(id)}/fork`, json('POST')), + rewind: (id: string, messageCount: number) => + jsonRequest<{ session: SessionSummary }>( + `/api/sessions/${sid(id)}/rewind`, + json('POST', { messageCount }) + ), + activeRun: (id: string) => + jsonRequest<{ run: { id: string }; cursor: number; eventsUrl: string }>( + `/api/sessions/${sid(id)}/active-run` + ), + interactions: () => jsonRequest<{ interactions: Interaction[] }>('/api/interactions'), + resolveInteraction: (id: string, answer: string) => + jsonRequest(`/api/interactions/${sid(id)}/resolve`, json('POST', { answer })), + cancelInteraction: (id: string) => + jsonRequest(`/api/interactions/${sid(id)}/cancel`, json('POST')), + runtimeMode: () => jsonRequest<{ mode: RuntimeMode }>('/api/runtime/mode'), + setRuntimeMode: (mode: RuntimeMode) => + jsonRequest<{ mode: RuntimeMode }>('/api/runtime/mode', json('PUT', { mode })), + setPermissionPreset: (profile: 'cautious' | 'balanced' | 'autonomous') => + jsonRequest('/api/runtime/permission-preset', json('PUT', { profile })), + runtimeInventory: () => jsonRequest('/api/runtime/inventory'), + mentions: () => jsonRequest('/api/runtime/mentions'), + inbox: (id: string) => jsonRequest<{ items?: unknown[] }>(`/api/sessions/${sid(id)}/inbox`), + deliver: (id: string, prompt: string, delivery: 'queue' | 'steer') => + jsonRequest(`/api/sessions/${sid(id)}/inbox`, json('POST', { prompt, delivery })), + steer: (id: string, prompt: string) => + jsonRequest(`/api/sessions/${sid(id)}/steer`, json('POST', { prompt })), + goal: (id: string) => jsonRequest<{ goal: GoalSnapshot }>(`/api/sessions/${sid(id)}/goal`), + updateGoal: (id: string, body: Record) => + jsonRequest<{ goal: GoalSnapshot }>(`/api/sessions/${sid(id)}/goal`, json('PUT', body)), + todos: (id: string) => jsonRequest<{ todos: TodoSnapshot[] }>(`/api/sessions/${sid(id)}/todos`), + command: (id: string, command: string) => + jsonRequest(`/api/sessions/${sid(id)}/commands`, json('POST', { command })), + jobs: () => jsonRequest<{ jobs: JobSnapshot[] }>('/api/jobs'), + stopJob: (id: string) => jsonRequest(`/api/jobs/${sid(id)}/stop`, json('POST')), + workflows: () => jsonRequest<{ workflows: WorkflowSnapshot[] }>('/api/workflows'), + runWorkflow: (id: string, sessionId: string, args = '') => + jsonRequest(`/api/workflows/${sid(id)}/run`, json('POST', { sessionId, args })), + trajectory: (runId: string) => jsonRequest(`/api/runs/${sid(runId)}/trajectory`), + verdict: (runId: string) => jsonRequest(`/api/runs/${sid(runId)}/verdict`), + settings: (section: SettingsSection) => jsonRequest(`/api/settings/${section}`), + validateSettings: (section: SettingsSection, values: Record) => + jsonRequest<{ valid: boolean; errors?: Record }>( + `/api/settings/${section}/validate`, + json('POST', { values }) + ), + saveSettings: (section: SettingsSection, values: Record) => + jsonRequest(`/api/settings/${section}`, json('PUT', { values })), + models: () => + jsonRequest<{ + choices?: Array<{ id?: string; model?: string; displayName?: string }>; + models?: string[]; + }>('/api/settings/models/catalog'), + selectModel: (model: string) => + jsonRequest('/api/settings/models/selection', json('PUT', { model })), + setApiKey: (value: string) => + jsonRequest('/api/settings/credentials/apiKey', json('PUT', { value })), + deleteApiKey: () => jsonRequest('/api/settings/credentials/apiKey', json('DELETE')), + cancelSession: (id: string) => jsonRequest(`/api/sessions/${sid(id)}/cancel`, json('POST')), + mutatePlugin: (id: string, action: 'enable' | 'disable' | 'remove') => + jsonRequest(`/api/plugins/${sid(id)}/${action}`, json('POST')), + sendMessage: (id: string, prompt: string, attachmentIds: string[], signal: AbortSignal) => { + const headers = new Headers({ 'content-type': 'application/json' }); + if (!csrfToken) throw new ApiError(403, 'Moss Web security token is not ready'); + headers.set('x-moss-csrf', csrfToken); + return fetch(`/api/sessions/${sid(id)}/messages`, { + method: 'POST', + headers, + body: JSON.stringify({ prompt, attachmentIds }), + signal, + }); + }, +}; + +export const connectRunEvents = ( + runId: string, + after: number, + onEvent: (event: StreamEvent, cursor: number) => void, + onState: (connected: boolean) => void +): (() => void) => { + let cursor = after; + let source: EventSource | undefined; + let stopped = false; + let retryTimer: number | undefined; + const connect = () => { + if (stopped) return; + source = new EventSource(`/api/runs/${encodeURIComponent(runId)}/events?after=${cursor}`); + source.onopen = () => onState(true); + const handleMessage = (message: MessageEvent) => { + const record = JSON.parse(message.data) as { seq: number; event: StreamEvent }; + const lastEventId = Number(message.lastEventId); + cursor = Number.isSafeInteger(lastEventId) && lastEventId > 0 ? lastEventId : record.seq; + onEvent(record.event, cursor); + if (record.event.type === 'done' || record.event.type === 'interrupted') { + stopped = true; + source?.close(); + } + }; + for (const type of [ + 'run', + 'text', + 'thought', + 'tool', + 'retry', + 'compaction', + 'usage', + 'context', + 'interrupted', + 'done', + ]) + source.addEventListener(type, handleMessage as EventListener); + source.addEventListener('error', (event) => { + if (event instanceof MessageEvent && typeof event.data === 'string' && event.data) { + handleMessage(event); + return; + } + if (stopped) return; + onState(false); + source?.close(); + retryTimer = window.setTimeout(connect, 850); + }); + }; + connect(); + return () => { + stopped = true; + if (retryTimer) window.clearTimeout(retryTimer); + source?.close(); + }; +}; + +export const connectPluginComposition = ( + onComposition: () => void, + onState: (connected: boolean) => void +): (() => void) => { + const source = new EventSource('/api/plugins/events'); + source.onopen = () => onState(true); + source.addEventListener('composition', () => onComposition()); + source.onerror = () => onState(false); + return () => source.close(); +}; diff --git a/packages/moss-agent/src/web-ui/client/app-frame.css b/packages/moss-agent/src/web-ui/client/app-frame.css new file mode 100644 index 00000000..3095d2d5 --- /dev/null +++ b/packages/moss-agent/src/web-ui/client/app-frame.css @@ -0,0 +1,213 @@ +.app-frame { + position: relative; + display: grid; + grid-template-columns: var(--moss-layout-sidebar) minmax(0, 1fr) var(--moss-layout-details); + height: 100dvh; + overflow: hidden; + background: var(--moss-color-canvas); + transition: grid-template-columns var(--moss-motion-standard) var(--moss-ease-standard); +} + +.moss-sidebar-column, +.moss-conversation-column, +.moss-details-column { + min-width: 0; + min-height: 0; + overflow: hidden; +} +.moss-sidebar-column { + border-right: 1px solid var(--moss-color-border); +} +.moss-conversation-column { + display: flex; + flex-direction: column; +} +.moss-details-column { + border-left: 1px solid var(--moss-color-border); +} +.app-frame[data-details-collapsed] .moss-details-column { + border-left: 0; +} + +.moss-skip-link { + position: fixed; + top: var(--moss-space-3); + left: 50%; + z-index: var(--moss-z-toast); + transform: translate(-50%, calc(-100% - var(--moss-space-6))); + border-radius: var(--moss-radius-control); + background: var(--moss-color-text); + color: var(--moss-color-surface); + padding: var(--moss-space-2) var(--moss-space-4); + text-decoration: none; + transition: transform var(--moss-motion-fast) var(--moss-ease-standard); +} +.moss-skip-link:focus { + transform: translate(-50%, 0); +} + +.moss-resize-handle { + position: absolute; + inset-block: 0; + z-index: var(--moss-z-header); + width: 0.75rem; + margin-left: -0.375rem; + cursor: col-resize; + touch-action: none; +} +.moss-resize-handle::after { + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: 0.25rem; + height: 2rem; + transform: translate(-50%, -50%); + border-radius: var(--moss-radius-round); + background: var(--moss-color-border-strong); + opacity: 0; + transition: + opacity var(--moss-motion-fast) var(--moss-ease-standard), + background-color var(--moss-motion-fast) var(--moss-ease-standard); +} +.moss-resize-handle:hover::after, +.moss-resize-handle:focus-visible::after, +.moss-resize-handle[data-dragging]::after { + opacity: 1; + background: var(--moss-color-accent); +} + +html[data-moss-resizing] { + cursor: col-resize; + user-select: none; +} +html[data-moss-resizing] .app-frame { + transition: none; +} + +.moss-drawer-backdrop { + display: none; +} + +.app-frame[data-sidebar-collapsed] .sidebar { + padding-inline: var(--moss-space-2); +} +.app-frame[data-sidebar-collapsed] .brand-row { + justify-content: center; + padding-inline: 0; +} +.app-frame[data-sidebar-collapsed] .brand-row > div:last-child, +.app-frame[data-sidebar-collapsed] .new-task-label, +.app-frame[data-sidebar-collapsed] .new-task kbd, +.app-frame[data-sidebar-collapsed] .session-search input, +.app-frame[data-sidebar-collapsed] .section-label, +.app-frame[data-sidebar-collapsed] .session-list span, +.app-frame[data-sidebar-collapsed] .session-list small, +.app-frame[data-sidebar-collapsed] .sidebar-footer > button, +.app-frame[data-sidebar-collapsed] .runtime-state div, +.app-frame[data-sidebar-collapsed] .plugin-slot { + display: none; +} +.app-frame[data-sidebar-collapsed] .new-task, +.app-frame[data-sidebar-collapsed] .session-search, +.app-frame[data-sidebar-collapsed] .runtime-state { + justify-content: center; +} +.app-frame[data-sidebar-collapsed] .session-list button { + height: 2.25rem; + padding: 0; +} +.app-frame[data-sidebar-collapsed] .session-list button::before { + content: ''; + width: 0.5rem; + height: 0.5rem; + margin: auto; + border-radius: 50%; + background: var(--moss-color-faint); +} +.app-frame[data-sidebar-collapsed] .session-list button.active::before { + background: var(--moss-color-accent); +} + +@media (max-width: 780px) { + .app-frame { + display: block; + } + .moss-conversation-column { + height: 100dvh; + } + [data-mobile-drawer] { + position: fixed; + inset-block: 0; + z-index: var(--moss-z-drawer); + box-sizing: border-box; + width: min(88vw, 20.625rem); + background: var(--moss-color-surface); + box-shadow: var(--moss-shadow-dialog); + visibility: hidden; + transition: + transform var(--moss-motion-standard) var(--moss-ease-standard), + visibility var(--moss-motion-standard) step-end; + } + [data-mobile-drawer='sidebar'] { + left: 0; + transform: translateX(-105%); + } + [data-mobile-drawer='details'] { + right: 0; + transform: translateX(105%); + } + [data-mobile-drawer][data-open] { + transform: translateX(0); + visibility: visible; + transition-timing-function: var(--moss-ease-standard), step-start; + } + .moss-drawer-backdrop { + position: fixed; + inset: 0; + z-index: calc(var(--moss-z-drawer) - 1); + display: block; + border: 0; + background: var(--moss-color-overlay); + -webkit-tap-highlight-color: transparent; + } + .app-frame[data-sidebar-collapsed] .sidebar { + padding-inline: var(--moss-space-3); + } + .app-frame[data-sidebar-collapsed] .brand-row { + justify-content: flex-start; + padding-inline: var(--moss-space-2); + } + .app-frame[data-sidebar-collapsed] .brand-row > div:last-child, + .app-frame[data-sidebar-collapsed] .new-task-label, + .app-frame[data-sidebar-collapsed] .session-search input, + .app-frame[data-sidebar-collapsed] .section-label, + .app-frame[data-sidebar-collapsed] .session-list span, + .app-frame[data-sidebar-collapsed] .session-list small, + .app-frame[data-sidebar-collapsed] .sidebar-footer > button, + .app-frame[data-sidebar-collapsed] .runtime-state div, + .app-frame[data-sidebar-collapsed] .plugin-slot { + display: revert; + } + .app-frame[data-sidebar-collapsed] .new-task, + .app-frame[data-sidebar-collapsed] .session-search, + .app-frame[data-sidebar-collapsed] .runtime-state { + justify-content: flex-start; + } + .app-frame[data-sidebar-collapsed] .session-list button { + height: auto; + padding: var(--moss-space-2) var(--moss-space-3); + } + .app-frame[data-sidebar-collapsed] .session-list button::before { + display: none; + } +} + +@media (prefers-reduced-motion: reduce) { + .app-frame, + .moss-skip-link, + .moss-resize-handle::after, + [data-mobile-drawer] { + transition: none; + } +} diff --git a/packages/moss-agent/src/web-ui/client/app-frame.tsx b/packages/moss-agent/src/web-ui/client/app-frame.tsx new file mode 100644 index 00000000..7e34ef00 --- /dev/null +++ b/packages/moss-agent/src/web-ui/client/app-frame.tsx @@ -0,0 +1,377 @@ +import { + type PointerEvent as ReactPointerEvent, + type ReactNode, + useCallback, + useEffect, + useRef, + useState, +} from 'react'; + +import './app-frame.css'; + +const LAYOUT_STORAGE_KEY = 'moss-layout-v1'; +const SIDEBAR_MIN = 220; +const SIDEBAR_MAX = 420; +const SIDEBAR_DEFAULT = 252; +const SIDEBAR_RAIL = 64; +const DETAILS_MIN = 280; +const DETAILS_MAX = 520; +const DETAILS_DEFAULT = 320; +const CENTER_MIN = 560; +const NARROW_BREAKPOINT = 1080; +const MOBILE_BREAKPOINT = 780; + +interface LayoutPreferences { + sidebarWidth: number; + detailsWidth: number; + sidebarOpen: boolean; + detailsOpen: boolean; + narrowSidebarExpanded: boolean; +} + +export interface MossLayoutController extends LayoutPreferences { + mobileSidebarOpen: boolean; + mobileDetailsOpen: boolean; + setSidebarWidth(width: number): void; + setDetailsWidth(width: number): void; + toggleSidebar(): void; + toggleDetails(): void; + openDetails(): void; + closeDetails(): void; + closeDrawers(): void; +} + +const clamp = (value: number, minimum: number, maximum: number) => + Math.min(maximum, Math.max(minimum, Math.round(value))); + +const defaultPreferences: LayoutPreferences = { + sidebarWidth: SIDEBAR_DEFAULT, + detailsWidth: DETAILS_DEFAULT, + sidebarOpen: true, + detailsOpen: true, + narrowSidebarExpanded: false, +}; + +const readPreferences = (): LayoutPreferences => { + try { + const value = JSON.parse( + localStorage.getItem(LAYOUT_STORAGE_KEY) ?? '{}' + ) as Partial; + return { + sidebarWidth: clamp(value.sidebarWidth ?? SIDEBAR_DEFAULT, SIDEBAR_MIN, SIDEBAR_MAX), + detailsWidth: clamp(value.detailsWidth ?? DETAILS_DEFAULT, DETAILS_MIN, DETAILS_MAX), + sidebarOpen: value.sidebarOpen ?? true, + detailsOpen: value.detailsOpen ?? true, + narrowSidebarExpanded: value.narrowSidebarExpanded ?? false, + }; + } catch { + return defaultPreferences; + } +}; + +const isMobileViewport = () => window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT}px)`).matches; + +export const useMossLayout = (): MossLayoutController => { + const [preferences, setPreferences] = useState(readPreferences); + const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); + const [mobileDetailsOpen, setMobileDetailsOpen] = useState(false); + useEffect(() => { + try { + localStorage.setItem(LAYOUT_STORAGE_KEY, JSON.stringify(preferences)); + } catch { + // A privacy-restricted browser may deny storage; the live layout remains usable. + } + }, [preferences]); + return { + ...preferences, + mobileSidebarOpen, + mobileDetailsOpen, + setSidebarWidth: (sidebarWidth) => + setPreferences((current) => ({ + ...current, + sidebarWidth: clamp(sidebarWidth, SIDEBAR_MIN, SIDEBAR_MAX), + })), + setDetailsWidth: (detailsWidth) => + setPreferences((current) => ({ + ...current, + detailsWidth: clamp(detailsWidth, DETAILS_MIN, DETAILS_MAX), + })), + toggleSidebar: () => { + if (isMobileViewport()) setMobileSidebarOpen((current) => !current); + else if (window.matchMedia(`(max-width: ${NARROW_BREAKPOINT}px)`).matches) + setPreferences((current) => ({ + ...current, + narrowSidebarExpanded: !current.narrowSidebarExpanded, + })); + else setPreferences((current) => ({ ...current, sidebarOpen: !current.sidebarOpen })); + }, + toggleDetails: () => { + if (isMobileViewport()) setMobileDetailsOpen((current) => !current); + else setPreferences((current) => ({ ...current, detailsOpen: !current.detailsOpen })); + }, + openDetails: () => { + if (isMobileViewport()) setMobileDetailsOpen(true); + else setPreferences((current) => ({ ...current, detailsOpen: true })); + }, + closeDetails: () => { + if (isMobileViewport()) setMobileDetailsOpen(false); + else setPreferences((current) => ({ ...current, detailsOpen: false })); + }, + closeDrawers: () => { + setMobileSidebarOpen(false); + setMobileDetailsOpen(false); + }, + }; +}; + +interface ResolvedLayout { + sidebar: number; + details: number; + narrow: boolean; + mobile: boolean; +} + +const resolveLayout = (viewport: number, preferences: LayoutPreferences): ResolvedLayout => { + const mobile = viewport <= MOBILE_BREAKPOINT; + const narrow = viewport <= NARROW_BREAKPOINT; + if (mobile) return { sidebar: 0, details: 0, narrow, mobile }; + const sidebar = + !preferences.sidebarOpen || (narrow && !preferences.narrowSidebarExpanded) + ? SIDEBAR_RAIL + : preferences.sidebarWidth; + if (!preferences.detailsOpen) return { sidebar, details: 0, narrow, mobile }; + const preferredCenter = viewport - sidebar - preferences.detailsWidth; + if (preferredCenter >= CENTER_MIN) { + return { sidebar, details: preferences.detailsWidth, narrow, mobile }; + } + const conceded = viewport - sidebar - CENTER_MIN; + return { + sidebar, + details: conceded >= DETAILS_MIN ? clamp(conceded, DETAILS_MIN, preferences.detailsWidth) : 0, + narrow, + mobile, + }; +}; + +const ResizeHandle = ({ + side, + position, + onResize, +}: { + side: 'sidebar' | 'details'; + position: number; + onResize(delta: number): void; +}) => { + const currentWidth = side === 'sidebar' ? position : window.innerWidth - position; + const minimum = side === 'sidebar' ? SIDEBAR_MIN : DETAILS_MIN; + const maximum = side === 'sidebar' ? SIDEBAR_MAX : DETAILS_MAX; + const origin = useRef(0); + const latest = useRef(0); + const frame = useRef(undefined); + const startWidth = useRef(0); + const [dragging, setDragging] = useState(false); + const emit = useCallback(() => { + frame.current = undefined; + onResize( + startWidth.current + + (side === 'sidebar' ? latest.current - origin.current : origin.current - latest.current) + ); + }, [onResize, side]); + const onPointerDown = (event: ReactPointerEvent) => { + event.preventDefault(); + event.currentTarget.setPointerCapture(event.pointerId); + origin.current = event.clientX; + latest.current = event.clientX; + startWidth.current = side === 'sidebar' ? position : window.innerWidth - position; + document.documentElement.dataset.mossResizing = side; + setDragging(true); + }; + const onPointerMove = (event: ReactPointerEvent) => { + if (!event.currentTarget.hasPointerCapture(event.pointerId)) return; + latest.current = event.clientX; + frame.current ??= requestAnimationFrame(emit); + }; + const onPointerUp = (event: ReactPointerEvent) => { + if (!event.currentTarget.hasPointerCapture(event.pointerId)) return; + event.currentTarget.releasePointerCapture(event.pointerId); + latest.current = event.clientX; + if (frame.current !== undefined) cancelAnimationFrame(frame.current); + emit(); + delete document.documentElement.dataset.mossResizing; + setDragging(false); + }; + useEffect( + () => () => { + if (frame.current !== undefined) cancelAnimationFrame(frame.current); + delete document.documentElement.dataset.mossResizing; + }, + [] + ); + return ( +
{ + if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return; + event.preventDefault(); + const direction = event.key === 'ArrowRight' ? 1 : -1; + onResize( + (side === 'sidebar' ? position : window.innerWidth - position) + + direction * 16 * (side === 'sidebar' ? 1 : -1) + ); + }} + /> + ); +}; + +export const AppFrame = ({ + layout, + sidebar, + conversation, + details, +}: { + layout: MossLayoutController; + sidebar: ReactNode; + conversation: ReactNode; + details: ReactNode; +}) => { + const rootRef = useRef(null); + const sidebarDrawerRef = useRef(null); + const detailsDrawerRef = useRef(null); + const closeDrawersRef = useRef(layout.closeDrawers); + closeDrawersRef.current = layout.closeDrawers; + const [viewport, setViewport] = useState(() => window.innerWidth); + useEffect(() => { + const root = rootRef.current; + if (!root) return; + const observer = new ResizeObserver(([entry]) => { + if (entry?.contentRect.width) setViewport(entry.contentRect.width); + }); + observer.observe(root); + return () => observer.disconnect(); + }, []); + const resolved = resolveLayout(viewport, layout); + const drawerOpen = layout.mobileSidebarOpen || layout.mobileDetailsOpen; + useEffect(() => { + if (!drawerOpen) return; + const previous = document.activeElement instanceof HTMLElement ? document.activeElement : null; + const activeDrawer = layout.mobileSidebarOpen + ? sidebarDrawerRef.current + : detailsDrawerRef.current; + const focusable = () => [ + ...(activeDrawer?.querySelectorAll( + 'button:not([disabled]), input:not([disabled]), textarea:not([disabled]), [href], [tabindex]:not([tabindex="-1"])' + ) ?? []), + ]; + requestAnimationFrame(() => focusable()[0]?.focus()); + const closeOnEscape = (event: KeyboardEvent) => { + if (event.key === 'Escape') closeDrawersRef.current(); + if (event.key !== 'Tab') return; + const controls = focusable(); + const first = controls[0]; + const last = controls.at(-1); + if (!first || !last) return; + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } + }; + document.addEventListener('keydown', closeOnEscape); + return () => { + document.removeEventListener('keydown', closeOnEscape); + requestAnimationFrame(() => previous?.focus()); + }; + }, [drawerOpen, layout.mobileDetailsOpen, layout.mobileSidebarOpen]); + return ( +
+ + Skip to conversation + +
+ {sidebar} +
+
+ {conversation} +
+
+ {details} +
+ {drawerOpen && ( +
+ ); +}; diff --git a/packages/moss-agent/src/web-ui/client/component-gallery.css b/packages/moss-agent/src/web-ui/client/component-gallery.css new file mode 100644 index 00000000..cdfc43f5 --- /dev/null +++ b/packages/moss-agent/src/web-ui/client/component-gallery.css @@ -0,0 +1,150 @@ +.component-gallery { + min-height: 100dvh; + overflow: auto; + background: + radial-gradient(circle at 8% 4%, var(--moss-color-accent-soft), transparent 28rem), + var(--moss-color-canvas); + padding: clamp(var(--moss-space-6), 5vw, var(--moss-space-12)); +} +.gallery-header { + max-width: 72rem; + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--moss-space-6); + margin: 0 auto var(--moss-space-8); +} +.gallery-header h1 { + margin: var(--moss-space-2) 0; + font: var(--moss-font-weight-regular) var(--moss-font-size-display) / + var(--moss-line-height-tight) var(--moss-font-family-display); + letter-spacing: -0.035em; + text-wrap: balance; +} +.gallery-header p { + max-width: 38rem; + margin: 0; + color: var(--moss-color-muted); + line-height: var(--moss-line-height-body); +} +.component-gallery > .moss-tabs { + max-width: 72rem; + margin: 0 auto var(--moss-space-8); + border-bottom: 1px solid var(--moss-color-border); + padding-bottom: var(--moss-space-2); +} +.gallery-section { + max-width: 72rem; + margin: auto; +} +.gallery-section h2 { + margin: 0 0 var(--moss-space-5); + font-size: var(--moss-font-size-5); +} +.gallery-grid, +.gallery-swatches, +.gallery-code-grid { + display: grid; + gap: var(--moss-space-4); +} +.gallery-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} +.gallery-grid > .moss-card { + display: grid; + align-content: start; + gap: var(--moss-space-4); +} +.gallery-grid h3 { + margin: 0; + font-size: var(--moss-font-size-3); +} +.gallery-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--moss-space-2); +} +.gallery-stack { + display: grid; + gap: var(--moss-space-2); +} +.gallery-swatches { + grid-template-columns: repeat(auto-fit, minmax(8rem, 1fr)); +} +.gallery-swatches .moss-card { + display: grid; + gap: var(--moss-space-2); + font-size: var(--moss-font-size-2); +} +.gallery-swatches code { + overflow-wrap: anywhere; + color: var(--moss-color-muted); + font-size: var(--moss-font-size-1); +} +.gallery-swatch { + height: 4rem; + border: 1px solid var(--moss-color-border); + border-radius: var(--moss-radius-control); +} +.gallery-type-ramp { + display: grid; + gap: var(--moss-space-3); + margin-top: var(--moss-space-5); +} +.gallery-type-ramp > * { + margin: 0; +} +.gallery-display { + font: var(--moss-font-weight-regular) var(--moss-font-size-display) / + var(--moss-line-height-tight) var(--moss-font-family-display); +} +.gallery-code-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + margin-top: var(--moss-space-4); +} +.gallery-state-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} +.gallery-state-grid .moss-card { + grid-template-columns: auto 1fr; + align-items: center; +} +.gallery-state-grid p { + grid-column: 1 / -1; + margin: 0; + color: var(--moss-color-muted); + font-size: var(--moss-font-size-2); + line-height: var(--moss-line-height-body); +} +.gallery-state-dot { + width: 0.625rem; + height: 0.625rem; + border-radius: 50%; + background: var(--moss-state-idle); +} +.gallery-state-dot[data-state='running'] { + background: var(--moss-state-running); +} +.gallery-state-dot[data-state='success'] { + background: var(--moss-state-success); +} +.gallery-state-dot[data-state='warning'] { + background: var(--moss-state-warning); +} +.gallery-state-dot[data-state='error'] { + background: var(--moss-state-error); +} +.gallery-state-dot[data-state='interrupted'] { + background: var(--moss-state-interrupted); +} +@media (max-width: 780px) { + .gallery-header { + display: grid; + } + .gallery-grid, + .gallery-code-grid, + .gallery-state-grid { + grid-template-columns: 1fr; + } +} diff --git a/packages/moss-agent/src/web-ui/client/component-gallery.tsx b/packages/moss-agent/src/web-ui/client/component-gallery.tsx new file mode 100644 index 00000000..93eecd93 --- /dev/null +++ b/packages/moss-agent/src/web-ui/client/component-gallery.tsx @@ -0,0 +1,193 @@ +import { useState } from 'react'; + +import { + Button, + Card, + Code, + Diff, + Disclosure, + Input, + Menu, + Modal, + Tabs, + Terminal, + Toast, + Tooltip, +} from './design-system.js'; +import './component-gallery.css'; + +type GalleryTab = 'foundations' | 'components' | 'states'; + +const galleryTabs = [ + { value: 'foundations', label: 'Foundations' }, + { value: 'components', label: 'Components' }, + { value: 'states', label: 'States' }, +] as const; + +export const ComponentGallery = ({ onExit }: { onExit(): void }) => { + const [tab, setTab] = useState('components'); + const [dialogOpen, setDialogOpen] = useState(false); + const [menuOpen, setMenuOpen] = useState(false); + return ( +
+
+
+

MOSS WEB DESIGN SYSTEM

+

Component gallery

+

One visual contract for the workbench and trusted plugin surfaces.

+
+ +
+ setTab(value)} + ariaLabel="Gallery sections" + /> + + {tab === 'foundations' && ( +
+ +
+ {['canvas', 'surface', 'panel', 'accent', 'success', 'warning', 'danger'].map( + (token) => ( + + + {token} + --moss-color-{token} + + ) + )} +
+ + Build with evidence. +

Workbench heading

+

Readable body text keeps long agent runs calm, precise, and easy to scan.

+ const result = await verify(); +
+
+ )} + + {tab === 'components' && ( +
+ +
+ +

Actions

+
+ + + + +
+
+ +

Field & overlays

+ +
+ + + + + setMenuOpen(false)} + label="Gallery menu" + trigger={} + > + + + +
+
+ +

Feedback

+
+ Runtime connected + Verification passed + Approval required + Tool failed +
+
+ +

Progressive disclosure

+ + The assistant inspected the active workspace and selected the narrowest safe test. + +
+
+
+ {`const proof = await runFocusedTests();\nassert.equal(proof.ok, true);`} + + +
+
+ )} + + {tab === 'states' && ( +
+ +
+ {['idle', 'running', 'success', 'warning', 'error', 'interrupted'].map((state) => ( + + + {state} +

Shared status vocabulary for messages, tools, runs, and plugins.

+
+ ))} +
+
+ )} + + setDialogOpen(false)} + title="Confirm component behavior" + description="Escape, the close button, or the backdrop closes this dialog." + footer={ + + } + > +

Focus stays inside the dialog and returns to the invoking control.

+
+
+ ); +}; diff --git a/packages/moss-agent/src/web-ui/client/composer.tsx b/packages/moss-agent/src/web-ui/client/composer.tsx new file mode 100644 index 00000000..744593ec --- /dev/null +++ b/packages/moss-agent/src/web-ui/client/composer.tsx @@ -0,0 +1,385 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { api } from './api-client.js'; +import { Button } from './design-system.js'; +import { PluginSlot } from './plugin-slot.js'; +import type { + Interaction, + MentionInventory, + RuntimeMode, + WebContribution, +} from './workbench-types.js'; + +export interface ComposerAttachment { + id: string; + name: string; + type: string; + size: number; + url?: string; + text?: string; + serverId?: string; + downloadUrl?: string; + error?: string; + file?: File; +} + +const fileBase64 = (file: File): Promise => + new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onerror = () => reject(reader.error ?? new Error('Unable to read attachment')); + reader.onload = () => resolve(String(reader.result).split(',', 2)[1] ?? ''); + reader.readAsDataURL(file); + }); + +const uploadFile = async (file: File, current: ComposerAttachment): Promise => { + try { + const { attachment } = await api.uploadAttachment( + file.name, + file.type || 'text/plain', + await fileBase64(file) + ); + return { + ...current, + file, + serverId: attachment.id, + downloadUrl: attachment.downloadUrl, + name: attachment.filename, + type: attachment.mimeType, + size: attachment.size, + error: undefined, + }; + } catch (error) { + return { ...current, file, error: error instanceof Error ? error.message : 'Upload failed' }; + } +}; + +const AttachmentPicker = ({ + attachments, + onChange, +}: { + attachments: ComposerAttachment[]; + onChange(value: ComposerAttachment[]): void; +}) => { + const input = useRef(null); + const add = async (files: FileList | null) => { + if (!files) return; + const next = await Promise.all( + [...files].map(async (file): Promise => { + const id = crypto.randomUUID(); + const preview = file.type.startsWith('image/') + ? { url: URL.createObjectURL(file) } + : file.size < 128_000 + ? { text: await file.text() } + : {}; + return uploadFile(file, { + id, + file, + name: file.name, + type: file.type, + size: file.size, + ...preview, + }); + }) + ); + onChange([...attachments, ...next]); + }; + return ( +
+ void add(event.target.files)} + /> + + {attachments.map((attachment) => ( +
+ {attachment.url && } +
+ {attachment.name} + + {attachment.error ?? + `${Math.ceil(attachment.size / 1024)} KB${attachment.serverId ? ' · ready' : ''}`} + +
+ + {attachment.error && attachment.file && ( + + )} +
+ ))} +
+ ); +}; + +const InteractionPanel = ({ + interaction, + onAnswer, + onCancel, +}: { + interaction: Interaction; + onAnswer(value: string): void; + onCancel(): void; +}) => { + const [value, setValue] = useState(''); + const panel = useRef(null); + useEffect(() => { + const previous = document.activeElement instanceof HTMLElement ? document.activeElement : null; + panel.current?.focus(); + return () => previous?.focus(); + }, []); + return ( +
{ + if (event.key === 'Escape') onCancel(); + }} + className="interaction-panel" + role="alertdialog" + aria-label={interaction.kind === 'approval' ? 'Approval required' : 'Moss has a question'} + > +

+ {interaction.kind === 'approval' ? 'APPROVAL REQUIRED' : 'QUESTION'} +

+

{interaction.prompt}

+ {interaction.kind === 'approval' ? ( +
+ + + + +
+ ) : ( +
{ + event.preventDefault(); + if (value.trim()) onAnswer(value.trim()); + }} + > + + + +
+
+ + + +`; + +/** Frozen legacy layout; excluded from the current design-system token contract. @internal */ +export const LEGACY_WEB_CSS = ` +:root{color-scheme:dark;font:14px/1.5 system-ui,sans-serif;background:#09110f;color:#edf7f2} +*{box-sizing:border-box}body{margin:0}.legacy-shell{display:grid;grid-template-columns:248px 1fr;min-height:100vh} +aside{padding:28px 20px;border-right:1px solid #263630;background:#0b1412;display:flex;flex-direction:column;gap:16px} +aside h1{margin:0}aside p,aside output{color:#8da49a}aside output{margin-top:auto}a{color:#89f0b6} +button{border:1px solid #365247;border-radius:10px;background:#14211d;color:#edf7f2;padding:10px 14px;cursor:pointer} +button:focus-visible,textarea:focus-visible,a:focus-visible{outline:3px solid #89f0b6;outline-offset:2px} +.legacy-shell>section{padding:30px 5vw;display:flex;flex-direction:column;max-height:100vh} +header{text-align:right;color:#8da49a}#legacy-empty{margin:auto;text-align:center}#legacy-empty h2{font-size:36px} +#legacy-timeline{flex:1;overflow:auto;padding:24px 0}.legacy-message{max-width:780px;margin:0 auto 14px;padding:14px 16px;border:1px solid #263630;border-radius:12px;background:#101a17;white-space:pre-wrap} +.legacy-message.user{background:#183226}.legacy-tool{color:#b8ffd2}form{max-width:860px;width:100%;margin:16px auto 0;padding:11px;border:1px solid #365247;border-radius:16px;background:#101b18} +textarea{width:100%;resize:none;border:0;background:transparent;color:inherit;font:inherit;outline:0}form button{float:right;margin-left:8px} +@media(max-width:760px){.legacy-shell{display:block}.legacy-shell aside{display:none}.legacy-shell>section{padding:18px 14px}} +@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important}} +`; + +/** Frozen legacy transport using the current session and CSRF contracts. @internal */ +export const LEGACY_WEB_JS = ` +const byId=(id)=>document.getElementById(id);let csrfToken='',sessionId='',controller; +const request=async(url,options={})=>{const headers=new Headers(options.headers);if((options.method??'GET')!=='GET')headers.set('x-moss-csrf',csrfToken);const response=await fetch(url,{...options,headers});if(!response.ok)throw new Error('HTTP '+response.status);return response.status===204?undefined:response.json()}; +const add=(kind,text)=>{byId('legacy-empty').hidden=true;const item=document.createElement('article');item.className='legacy-message '+kind;item.textContent=text;byId('legacy-timeline').append(item);byId('legacy-timeline').scrollTop=byId('legacy-timeline').scrollHeight;return item}; +const fresh=async()=>{const result=await request('/api/sessions',{method:'POST'});sessionId=result.sessionId;byId('legacy-timeline').replaceChildren();byId('legacy-empty').hidden=false}; +const bootstrap=async()=>{try{const state=await request('/api/bootstrap');csrfToken=state.csrfToken;byId('legacy-model').textContent=state.model;byId('legacy-runtime').textContent='Ready · '+state.tools.length+' tools';await fresh()}catch(error){byId('legacy-runtime').textContent='Offline · '+error.message}}; +byId('legacy-new').onclick=()=>void fresh();byId('legacy-stop').onclick=()=>controller?.abort(); +byId('legacy-composer').onsubmit=async(event)=>{event.preventDefault();const prompt=byId('legacy-prompt').value.trim();if(!prompt)return;add('user',prompt);byId('legacy-prompt').value='';const answer=add('assistant','');controller=new AbortController();byId('legacy-stop').hidden=false;try{const headers={'content-type':'application/json','x-moss-csrf':csrfToken};const response=await fetch('/api/sessions/'+encodeURIComponent(sessionId)+'/messages',{method:'POST',headers,body:JSON.stringify({prompt,attachmentIds:[]}),signal:controller.signal});if(!response.ok||!response.body)throw new Error('HTTP '+response.status);const reader=response.body.getReader(),decoder=new TextDecoder();let pending='';for(;;){const chunk=await reader.read();if(chunk.done)break;pending+=decoder.decode(chunk.value,{stream:true});const lines=pending.split('\\n');pending=lines.pop()??'';for(const line of lines){if(!line)continue;const data=JSON.parse(line);if(data.type==='text')answer.textContent+=data.delta;if(data.type==='tool')add('legacy-tool',data.name+' · '+data.state)}}}catch(error){if(error.name!=='AbortError')answer.textContent+='\\nInterrupted: '+error.message}finally{byId('legacy-stop').hidden=true;controller=undefined}}; +void bootstrap(); +`; diff --git a/packages/moss-agent/src/web-ui/web-assets.ts b/packages/moss-agent/src/web-ui/web-assets.ts index 131fd37d..bc4ac505 100644 --- a/packages/moss-agent/src/web-ui/web-assets.ts +++ b/packages/moss-agent/src/web-ui/web-assets.ts @@ -1,35 +1,15 @@ -/** Browser shell for the dependency-free Moss Web host. @internal */ +/** Browser shell for the bundled Moss React workbench. @internal */ export const WEB_HTML = ` - -Moss · Agent workspace -
- -

MOSS / LIVE SESSION

Build with an agent that shows its work.

Configured model
-

What should we accomplish?

Plan, build, inspect, and verify in one observable workspace. Moss streams decisions and tool evidence as the task progresses.

-
-
-
Ready
Local-first · tools require policy approval
- -
`; - -/** Visual system intentionally original to Moss; no upstream assets are copied. @internal */ -export const WEB_CSS = `:root{color-scheme:dark;--bg:#09110f;--panel:#101a17;--panel2:#14211d;--line:#263630;--text:#edf7f2;--muted:#8da49a;--green:#89f0b6;--mint:#b8ffd2;--danger:#ff9c8f;--shadow:0 28px 80px #0008;font:14px/1.5 Inter,ui-sans-serif,system-ui,sans-serif}*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at 70% -20%,#1c4936 0,transparent 38%),var(--bg);color:var(--text);min-height:100vh}.grain{position:fixed;inset:0;opacity:.025;pointer-events:none;background-image:url("data:image/svg+xml,%3Csvg viewBox='0 0 180 180' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.8' numOctaves='3'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E")}.shell{display:grid;grid-template-columns:248px minmax(420px,1fr) 310px;min-height:100vh}.rail{padding:28px 20px;border-right:1px solid var(--line);background:#0b1412cc;display:flex;flex-direction:column;gap:28px}.brand{display:flex;gap:12px;align-items:center}.brand div{display:grid}.brand strong{font-size:17px}.brand small,.rail-card small{color:var(--muted)}.mark{display:grid;place-items:center;width:38px;height:38px;border-radius:12px;background:linear-gradient(145deg,var(--green),#3da775);color:#072016;font-weight:900;box-shadow:0 8px 28px #5ee29a3a}.new-chat,#send{border:0;border-radius:12px;background:var(--green);color:#082016;font-weight:750;padding:12px 16px;cursor:pointer}.rail nav{display:grid;gap:6px}.rail nav p,.eyebrow{font-size:10px;letter-spacing:.18em;color:#6f8b80;font-weight:800}.rail nav button,.icon-button{border:0;background:transparent;color:var(--muted);padding:10px 12px;text-align:left;border-radius:9px;cursor:pointer}.rail nav button span{float:right}.rail nav .nav-active,.rail nav button:hover{background:#18241f;color:var(--text)}.rail-card{margin-top:auto;display:flex;gap:10px;align-items:center;padding:13px;border:1px solid var(--line);border-radius:12px}.rail-card div{display:grid}.pulse{width:8px;height:8px;border-radius:50%;background:var(--green);box-shadow:0 0 0 5px #75e9a519}.workspace{padding:30px 5vw;display:flex;flex-direction:column;min-height:100vh;max-height:100vh;overflow:hidden}.workspace>header{display:flex;justify-content:space-between;align-items:start}.workspace h1{font:500 clamp(20px,2vw,28px)/1.2 Georgia,serif;margin:3px 0}.header-actions{display:flex;gap:10px;align-items:center}.model-chip{border:1px solid var(--line);border-radius:99px;padding:7px 12px;color:var(--muted);font-size:12px}.icon-button{font-size:18px}.hero{margin:auto;text-align:center;max-width:720px}.orb{margin:auto;width:58px;height:58px;display:grid;place-items:center;border:1px solid #67d89b77;border-radius:18px;background:linear-gradient(145deg,#173b2d,#102019);color:var(--green);font-size:24px;box-shadow:0 20px 70px #4ac78a28}.hero h2{font:500 clamp(27px,4vw,44px)/1.1 Georgia,serif;margin:20px 0 10px}.hero>p{color:var(--muted);font-size:15px;max-width:600px;margin:0 auto}.prompts{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin-top:32px}.prompts button{border:1px solid var(--line);background:#111d19aa;color:var(--text);border-radius:14px;padding:15px;text-align:left;cursor:pointer}.prompts button:hover{border-color:#5dc992;transform:translateY(-2px)}.prompts b,.prompts span{display:block}.prompts span{color:var(--muted);font-size:12px;margin-top:3px}.timeline{display:none;flex:1;overflow:auto;padding:30px 0 18px}.timeline.active{display:block}.message{max-width:790px;margin:0 auto 18px;padding:15px 18px;border-radius:14px;background:var(--panel);border:1px solid var(--line);white-space:pre-wrap}.message.user{margin-left:auto;background:#183226;border-color:#2f5d45}.message .label{font-size:10px;color:var(--green);letter-spacing:.12em;font-weight:800;margin-bottom:6px}.tool-card{max-width:790px;margin:0 auto 10px;border:1px solid var(--line);border-radius:12px;background:#0d1714;overflow:hidden}.tool-card header{display:flex;justify-content:space-between;padding:10px 13px;background:#14211d}.tool-card pre{margin:0;padding:12px;color:var(--muted);white-space:pre-wrap;max-height:180px;overflow:auto}.tool-card.error{border-color:#773f39}.composer{max-width:860px;width:100%;margin:16px auto 0;border:1px solid #365247;border-radius:17px;background:#101b18ee;box-shadow:var(--shadow);padding:11px}.composer-top,.composer-bottom{display:flex;justify-content:space-between;align-items:center;color:var(--muted);font-size:11px}.composer-top{padding:0 5px 6px}.composer textarea{width:100%;resize:none;border:0;outline:0;background:transparent;color:var(--text);font:15px/1.5 inherit;padding:8px}.composer-bottom{padding:6px 3px 1px}.composer-bottom #send{padding:9px 14px}.composer button:disabled{opacity:.45;cursor:not-allowed}#stop{background:transparent;border:0;color:var(--danger);cursor:pointer}.capabilities{border-left:1px solid var(--line);background:#0c1513;padding:27px 20px;overflow:auto}.capabilities header{display:flex;justify-content:space-between}.capabilities h2{font:500 24px Georgia,serif;margin:2px 0 20px}.capabilities header button{display:none}.capabilities section{border-top:1px solid var(--line);padding:18px 0}.capabilities h3{font-size:12px}.capabilities h3 span{float:right;color:var(--muted)}.chips{display:flex;flex-wrap:wrap;gap:6px}.chips span{font:11px ui-monospace,monospace;color:#a6c3b6;border:1px solid var(--line);padding:5px 7px;border-radius:7px;background:#111e1a}.plugin-list article,.run-link{padding:10px;border:1px solid var(--line);border-radius:10px;margin-top:7px}.run-link{display:block;width:100%;background:transparent;color:var(--text);text-align:left;cursor:pointer}.run-link:hover,.run-link:focus-visible{border-color:var(--green);outline:2px solid #89f0b644;outline-offset:2px}.connection-banner{max-width:860px;width:100%;margin:12px auto 0;padding:10px 14px;border:1px solid #8b594f;border-radius:10px;background:#2a1917;color:#ffd2ca}.pulse.offline{background:var(--danger);box-shadow:0 0 0 5px #ff9c8f22}.plugin-list small{color:var(--muted)}.trust-note{margin-top:20px;padding:14px;border-radius:12px;background:#14251e;color:var(--mint)}.trust-note p{color:var(--muted);font-size:12px;margin-bottom:0}@media(max-width:1050px){.shell{grid-template-columns:80px 1fr}.rail{padding:25px 12px}.rail .brand div,.rail nav p,.rail nav button:not(.nav-active),.rail-card div,.new-chat{display:none}.rail .brand{justify-content:center}.capabilities{position:fixed;right:0;top:0;bottom:0;width:320px;z-index:4;transform:translateX(105%);transition:.25s;box-shadow:var(--shadow)}.capabilities.open{transform:none}.capabilities header button{display:block;background:none;border:0;color:var(--text);font-size:24px}}@media(max-width:700px){.shell{display:block}.rail{display:none}.workspace{padding:20px 14px}.model-chip{display:none}.prompts{grid-template-columns:1fr}.hero{overflow:auto}.composer-bottom>span{display:none}}`; - -/** Browser behavior for chat streaming, cancellation, and capability inspection. @internal */ -export const WEB_JS = `const $=id=>document.getElementById(id);let sessionId,controller,running=false,retryTimer;const decoder=new TextDecoder(); -const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); -function connection(online,message){$('connection-banner').hidden=online;$('runtime-pulse').classList.toggle('offline',!online);if(message)$('runtime-state').textContent=message} -function renderRuns(runs){$('run-list').innerHTML=runs.slice(0,5).map(r=>'').join('')||'No task history yet.';document.querySelectorAll('[data-run]').forEach(button=>button.onclick=()=>showRun(button.dataset.run))} -function eventSummary(event){if(event.type==='tool.started')return 'Started tool '+(event.data.name||'unknown');if(event.type==='tool.succeeded')return 'Tool '+(event.data.name||'unknown')+' completed';if(event.type==='tool.failed')return 'Tool '+(event.data.name||'unknown')+' failed';return event.type.replaceAll('.',' ')} -async function showRun(id){try{const data=await requestJson('/api/runs/'+encodeURIComponent(id));$('hero').hidden=true;$('timeline').classList.add('active');$('title').textContent=data.run.title;$('timeline').innerHTML='';addMessage('assistant','Durable task history · '+data.run.status+' · '+data.run.verification);for(const event of data.events)addMessage('assistant','#'+event.seq+' · '+eventSummary(event));scroll()}catch(error){connection(false,'History unavailable · retrying')}} -async function requestJson(url,options){const response=await fetch(url,options);if(!response.ok)throw new Error('HTTP '+response.status);connection(true);return response.json()} -async function refreshRuns(){const data=await requestJson('/api/runs');renderRuns(data.runs)} -async function bootstrap(){clearTimeout(retryTimer);try{const data=await requestJson('/api/bootstrap');$('runtime-state').textContent='Ready · '+data.tools.length+' tools';$('model').textContent=data.model;$('tool-count').textContent=data.tools.length;$('tools-total').textContent=data.tools.length;$('tools').innerHTML=data.tools.map(x=>''+esc(x)+'').join('');$('plugins-total').textContent=data.plugins.length;$('plugins').innerHTML=data.plugins.length?data.plugins.map(p=>'
'+esc(p.id)+'
'+esc(p.state)+' · '+p.tools.length+' tools
').join(''):'No host plugins installed.';renderRuns(data.taskRuns);if(!sessionId)await newSession()}catch(error){connection(false,'Offline · reconnecting');retryTimer=setTimeout(bootstrap,2000)}} -async function newSession(){const d=await requestJson('/api/sessions',{method:'POST'});sessionId=d.sessionId;await refreshRuns();$('timeline').innerHTML='';$('timeline').classList.remove('active');$('hero').hidden=false;$('title').textContent='Build with an agent that shows its work.'} -function addMessage(kind,text){const el=document.createElement('article');el.className='message '+kind;el.innerHTML='
'+(kind==='user'?'YOU':'MOSS')+'
';el.querySelector('.body').textContent=text;$('timeline').append(el);return el.querySelector('.body')} -function addTool(e){let el=document.querySelector('[data-tool="'+CSS.escape(e.toolCallId)+'"]');if(!el){el=document.createElement('article');el.className='tool-card';el.dataset.tool=e.toolCallId;el.innerHTML='
◇ '+esc(e.name)+'running
';$('timeline').append(el)}if(e.state==='end'){el.querySelector('span').textContent=e.isError?'failed':'complete';el.querySelector('pre').textContent=typeof e.result==='string'?e.result:JSON.stringify(e.result,null,2);if(e.isError)el.classList.add('error')}scroll()}
-function scroll(){$('timeline').scrollTop=$('timeline').scrollHeight}function setRunning(v){running=v;$('send').disabled=v;$('stop').hidden=!v;$('status').textContent=v?'Moss is working…':'Ready'}
-async function send(){const prompt=$('prompt').value.trim();if(!prompt||running)return;$('hero').hidden=true;$('timeline').classList.add('active');$('title').textContent=prompt.slice(0,72);addMessage('user',prompt);$('prompt').value='';const body=addMessage('assistant','');controller=new AbortController();setRunning(true);try{const res=await fetch('/api/sessions/'+encodeURIComponent(sessionId)+'/messages',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({prompt}),signal:controller.signal});if(!res.ok)throw new Error(await res.text());connection(true);const reader=res.body.getReader();let pending='';while(true){const {done,value}=await reader.read();if(done)break;pending+=decoder.decode(value,{stream:true});const lines=pending.split('\\n');pending=lines.pop();for(const line of lines){if(!line)continue;const e=JSON.parse(line);if(e.type==='text')body.textContent+=e.delta;if(e.type==='tool')addTool(e);if(e.type==='done'){$('status').textContent='Completed · '+e.run.verification;await refreshRuns()}scroll()}}}catch(e){if(e.name!=='AbortError'){body.textContent+='\\n\\nConnection interrupted: '+e.message;connection(false,'Offline · reconnecting');retryTimer=setTimeout(bootstrap,2000)}}finally{setRunning(false)}}
-$('send').onclick=send;$('stop').onclick=async()=>{controller?.abort();await fetch('/api/sessions/'+encodeURIComponent(sessionId)+'/cancel',{method:'POST'});setRunning(false)};$('new-chat').onclick=newSession;$('capability-toggle').onclick=()=>$('capabilities').classList.add('open');$('close-capabilities').onclick=()=>$('capabilities').classList.remove('open');$('theme').onclick=()=>document.body.classList.toggle('soft');$('prompt').onkeydown=e=>{if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();send()}};document.querySelectorAll('[data-prompt]').forEach(b=>b.onclick=()=>{$('prompt').value=b.dataset.prompt;$('prompt').focus()});window.addEventListener('online',bootstrap);window.addEventListener('offline',()=>connection(false,'Browser offline'));bootstrap();`;
+
+  
+    
+    
+    
+    Moss · Agent workspace
+    
+  
+  
+    
+ + +`; diff --git a/packages/moss-agent/src/web-ui/web-attachment-router.ts b/packages/moss-agent/src/web-ui/web-attachment-router.ts new file mode 100644 index 00000000..1061c3c8 --- /dev/null +++ b/packages/moss-agent/src/web-ui/web-attachment-router.ts @@ -0,0 +1,105 @@ +import type http from 'node:http'; + +import { ErrorCode, MossError } from '../errors.js'; +import type { MossWebAttachmentService } from './web-attachment-service.js'; + +const MAX_UPLOAD_BODY_BYTES = 14 * 1024 * 1024; + +interface MossWebAttachmentRouterOptions { + readonly request: http.IncomingMessage; + readonly response: http.ServerResponse; + readonly url: URL; + readonly mutationAllowed: boolean; + readonly attachments: MossWebAttachmentService; + readonly sendJson: (response: http.ServerResponse, status: number, value: unknown) => void; +} + +/** Route bounded attachment upload/download and workspace artifact registration. @internal */ +export async function handleMossWebAttachmentRequest( + options: MossWebAttachmentRouterOptions +): Promise { + const { request, response, url, attachments, sendJson } = options; + const attachmentMatch = url.pathname.match(/^\/api\/attachments\/([^/]+)$/); + const isMutation = + (request.method === 'POST' && + (url.pathname === '/api/attachments' || url.pathname === '/api/artifacts')) || + (request.method === 'DELETE' && Boolean(attachmentMatch)); + if (isMutation && !options.mutationAllowed) { + sendJson(response, 403, { error: 'mutation requires same-origin CSRF authorization' }); + return true; + } + if (request.method === 'POST' && url.pathname === '/api/attachments') { + const body = await readAttachmentJson(request); + const attachment = await attachments.upload({ + filename: typeof body.filename === 'string' ? body.filename : '', + mimeType: typeof body.mimeType === 'string' ? body.mimeType : '', + contentBase64: typeof body.contentBase64 === 'string' ? body.contentBase64 : '', + }); + sendJson(response, 201, { attachment }); + return true; + } + if (request.method === 'POST' && url.pathname === '/api/artifacts') { + const body = await readAttachmentJson(request); + const workspaceRelativePath = + typeof body.workspaceRelativePath === 'string' ? body.workspaceRelativePath : ''; + const attachment = await attachments.registerArtifact(workspaceRelativePath); + sendJson(response, 201, { attachment }); + return true; + } + if (request.method === 'GET' && attachmentMatch) { + const { metadata, body } = await attachments.read(decodeURIComponent(attachmentMatch[1])); + response.writeHead(200, { + 'content-type': metadata.mimeType, + 'content-length': body.length, + 'content-disposition': `attachment; filename*=UTF-8''${encodeURIComponent(metadata.filename)}`, + 'cache-control': 'no-store', + 'x-content-type-options': 'nosniff', + }); + response.end(body); + return true; + } + if (request.method === 'DELETE' && attachmentMatch) { + const id = decodeURIComponent(attachmentMatch[1]); + await attachments.delete(id); + sendJson(response, 200, { id, deleted: true }); + return true; + } + return false; +} + +async function readAttachmentJson(request: http.IncomingMessage): Promise> { + const declaredLength = Number(request.headers['content-length']); + if (Number.isFinite(declaredLength) && declaredLength > MAX_UPLOAD_BODY_BYTES) { + throw new MossError({ + code: ErrorCode.USER_INPUT_INVALID, + message: 'attachment request exceeds 14 MiB', + }); + } + let body = ''; + for await (const chunk of request) { + body += String(chunk); + if (Buffer.byteLength(body) > MAX_UPLOAD_BODY_BYTES) { + throw new MossError({ + code: ErrorCode.USER_INPUT_INVALID, + message: 'attachment request exceeds 14 MiB', + }); + } + } + let parsed: unknown; + try { + parsed = JSON.parse(body); + } catch (error) { + throw new MossError({ + code: ErrorCode.USER_INPUT_INVALID, + message: 'attachment request must be valid JSON', + cause: error, + }); + } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new MossError({ + code: ErrorCode.USER_INPUT_INVALID, + message: 'attachment request must be a JSON object', + }); + } + return parsed as Record; +} diff --git a/packages/moss-agent/src/web-ui/web-attachment-service.ts b/packages/moss-agent/src/web-ui/web-attachment-service.ts new file mode 100644 index 00000000..9edd718d --- /dev/null +++ b/packages/moss-agent/src/web-ui/web-attachment-service.ts @@ -0,0 +1,283 @@ +import { randomUUID } from 'node:crypto'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { TextDecoder } from 'node:util'; + +import type { ChatOptions } from '../core/agent/moss-agent.js'; +import { ErrorCode, MossError, wrapAsMoss } from '../errors.js'; + +const IMAGE_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']); +const TEXT_TYPES = new Set([ + 'application/json', + 'application/javascript', + 'application/xml', + 'application/yaml', + 'application/x-yaml', +]); +const ATTACHMENT_ID = /^attachment-[0-9a-f-]{36}$/; + +/** Browser-safe uploaded attachment or copied generated artifact metadata. @beta */ +export interface MossWebAttachmentSummary { + readonly id: string; + readonly filename: string; + readonly mimeType: string; + readonly kind: 'text' | 'image' | 'artifact'; + readonly size: number; + readonly createdAt: number; + readonly downloadUrl: string; +} + +interface StoredAttachment extends Omit { + readonly schemaVersion: 1; +} + +export interface MossWebAttachmentServiceOptions { + readonly storageDir: string; + readonly workspaceDir?: string; + readonly maxTextBytes?: number; + readonly maxImageBytes?: number; + readonly maxArtifactBytes?: number; +} + +/** Durable, bounded Web attachment and generated-artifact store. @internal */ +export class MossWebAttachmentService { + private readonly maxTextBytes: number; + private readonly maxImageBytes: number; + private readonly maxArtifactBytes: number; + + constructor(private readonly options: MossWebAttachmentServiceOptions) { + this.maxTextBytes = options.maxTextBytes ?? 1024 * 1024; + this.maxImageBytes = options.maxImageBytes ?? 10 * 1024 * 1024; + this.maxArtifactBytes = options.maxArtifactBytes ?? 25 * 1024 * 1024; + } + + async upload(input: { + filename: string; + mimeType: string; + contentBase64: string; + }): Promise { + const filename = this.filename(input.filename); + const mimeType = input.mimeType.trim().toLowerCase(); + const kind = this.uploadKind(mimeType); + const body = this.decodeBase64(input.contentBase64); + const limit = kind === 'image' ? this.maxImageBytes : this.maxTextBytes; + if (body.length === 0 || body.length > limit) { + this.invalid(`${kind} attachment must contain 1 to ${limit} bytes`); + } + if (kind === 'text') this.decodeText(body); + if (kind === 'image' && !this.matchesImageSignature(mimeType, body)) { + this.invalid(`attachment bytes do not match ${mimeType}`); + } + return this.store({ filename, mimeType, kind, body }); + } + + async registerArtifact(workspaceRelativePath: string): Promise { + if (!this.options.workspaceDir) this.invalid('workspace artifact registration is unavailable'); + if (!workspaceRelativePath.trim() || path.isAbsolute(workspaceRelativePath)) { + this.invalid('artifact path must be a workspace-relative path'); + } + const workspace = await fs.realpath(this.options.workspaceDir); + const unresolved = path.resolve(workspace, workspaceRelativePath); + if (!this.contains(workspace, unresolved)) this.invalid('artifact path escapes the workspace'); + let source: string; + try { + source = await fs.realpath(unresolved); + } catch (error) { + throw wrapAsMoss(error, ErrorCode.CONFIG_IO_FAILED, { + message: 'Failed to resolve generated artifact', + }); + } + if (!this.contains(workspace, source)) this.invalid('artifact symlink escapes the workspace'); + const stat = await fs.stat(source); + if (!stat.isFile()) this.invalid('artifact path must identify a regular file'); + if (stat.size === 0 || stat.size > this.maxArtifactBytes) { + this.invalid(`artifact must contain 1 to ${this.maxArtifactBytes} bytes`); + } + const filename = this.filename(path.basename(source)); + return this.store({ + filename, + mimeType: this.artifactMimeType(filename), + kind: 'artifact', + body: await fs.readFile(source), + }); + } + + async resolveForPrompt(ids: readonly string[]): Promise> { + if (ids.length > 8) this.invalid('a prompt may include at most 8 attachments'); + if (new Set(ids).size !== ids.length) this.invalid('attachment ids must be unique'); + const blocks: NonNullable = []; + for (const id of ids) { + const { metadata, body } = await this.read(id); + if (metadata.mimeType.startsWith('text/') || TEXT_TYPES.has(metadata.mimeType)) { + blocks.push({ + type: 'text', + text: `[Attachment: ${metadata.filename}]\n${this.decodeText(body)}`, + }); + } else if (IMAGE_TYPES.has(metadata.mimeType)) { + blocks.push({ + type: 'image', + data: body.toString('base64'), + mimeType: metadata.mimeType, + filename: metadata.filename, + }); + } else { + this.invalid(`artifact "${metadata.filename}" cannot be sent to the model`); + } + } + return blocks; + } + + async read(id: string): Promise<{ metadata: MossWebAttachmentSummary; body: Buffer }> { + this.assertId(id); + try { + const stored = JSON.parse( + await fs.readFile(this.metadataPath(id), 'utf8') + ) as StoredAttachment; + if (stored.schemaVersion !== 1 || stored.id !== id) + this.invalid('attachment metadata is invalid'); + const body = await fs.readFile(this.bodyPath(id)); + if (body.length !== stored.size) this.invalid('attachment content is incomplete'); + return { metadata: this.summary(stored), body }; + } catch (error) { + if (error instanceof MossError) throw error; + if ((error as NodeJS.ErrnoException).code === 'ENOENT') + this.invalid(`attachment "${id}" was not found`); + throw wrapAsMoss(error, ErrorCode.CONFIG_IO_FAILED, { + message: `Failed to read attachment "${id}"`, + }); + } + } + + async delete(id: string): Promise { + this.assertId(id); + const removed = await fs.rm(this.metadataPath(id), { force: true }).then(() => true); + await fs.rm(this.bodyPath(id), { force: true }); + return removed; + } + + private async store(input: { + filename: string; + mimeType: string; + kind: StoredAttachment['kind']; + body: Buffer; + }): Promise { + const id = `attachment-${randomUUID()}`; + const stored: StoredAttachment = { + schemaVersion: 1, + id, + filename: input.filename, + mimeType: input.mimeType, + kind: input.kind, + size: input.body.length, + createdAt: Date.now(), + }; + await fs.mkdir(this.options.storageDir, { recursive: true, mode: 0o700 }); + try { + await fs.writeFile(this.bodyPath(id), input.body, { mode: 0o600, flag: 'wx' }); + await fs.writeFile(this.metadataPath(id), `${JSON.stringify(stored)}\n`, { + mode: 0o600, + flag: 'wx', + }); + } catch (error) { + await Promise.all([ + fs.rm(this.bodyPath(id), { force: true }), + fs.rm(this.metadataPath(id), { force: true }), + ]); + throw wrapAsMoss(error, ErrorCode.CONFIG_IO_FAILED, { + message: 'Failed to persist Web attachment', + }); + } + return this.summary(stored); + } + + private summary(stored: StoredAttachment): MossWebAttachmentSummary { + const { schemaVersion: _, ...summary } = stored; + return { ...summary, downloadUrl: `/api/attachments/${stored.id}` }; + } + + private bodyPath(id: string): string { + return path.join(this.options.storageDir, `${id}.bin`); + } + + private metadataPath(id: string): string { + return path.join(this.options.storageDir, `${id}.json`); + } + + private filename(value: string): string { + const basename = path.posix + .basename(value.replaceAll('\\', '/')) + .replace(/[\u0000-\u001f\u007f]/g, ''); + const normalized = basename.trim().slice(0, 180); + if (!normalized || normalized === '.' || normalized === '..') + this.invalid('filename is invalid'); + return normalized; + } + + private uploadKind(mimeType: string): 'text' | 'image' { + if (IMAGE_TYPES.has(mimeType)) return 'image'; + if (mimeType.startsWith('text/') || TEXT_TYPES.has(mimeType)) return 'text'; + return this.invalid(`unsupported attachment type "${mimeType}"`); + } + + private decodeBase64(value: string): Buffer { + if (!value || value.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(value)) { + this.invalid('contentBase64 must be canonical base64 without a data URL prefix'); + } + return Buffer.from(value, 'base64'); + } + + private decodeText(body: Buffer): string { + try { + return new TextDecoder('utf-8', { fatal: true }).decode(body); + } catch { + return this.invalid('text attachment must be valid UTF-8'); + } + } + + private matchesImageSignature(mimeType: string, body: Buffer): boolean { + if (mimeType === 'image/png') + return body.subarray(0, 4).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47])); + if (mimeType === 'image/jpeg') + return body[0] === 0xff && body[1] === 0xd8 && body.at(-2) === 0xff && body.at(-1) === 0xd9; + if (mimeType === 'image/gif') return body.subarray(0, 4).toString('ascii') === 'GIF8'; + return ( + body.subarray(0, 4).toString('ascii') === 'RIFF' && + body.subarray(8, 12).toString('ascii') === 'WEBP' + ); + } + + private artifactMimeType(filename: string): string { + const extension = path.extname(filename).toLowerCase(); + const types: Record = { + '.txt': 'text/plain', + '.md': 'text/markdown', + '.json': 'application/json', + '.csv': 'text/csv', + '.html': 'text/html', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.pdf': 'application/pdf', + '.zip': 'application/zip', + }; + return types[extension] ?? 'application/octet-stream'; + } + + private contains(root: string, candidate: string): boolean { + const relative = path.relative(root, candidate); + return ( + relative === '' || + (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative)) + ); + } + + private assertId(id: string): void { + if (!ATTACHMENT_ID.test(id)) this.invalid('attachment id is invalid'); + } + + private invalid(message: string): never { + throw new MossError({ code: ErrorCode.USER_INPUT_INVALID, message }); + } +} diff --git a/packages/moss-agent/src/web-ui/web-contracts.ts b/packages/moss-agent/src/web-ui/web-contracts.ts new file mode 100644 index 00000000..8b9fd7f4 --- /dev/null +++ b/packages/moss-agent/src/web-ui/web-contracts.ts @@ -0,0 +1,249 @@ +import type { MossWebSlot } from '../core/plugins/plugin-host.js'; +import type { + MossPluginConfigSchema, + MossPluginConfigView, +} from '../plugins/plugin-config-store.js'; + +/** Stable extension slots available to trusted Moss Web plugins. @beta */ +export { MOSS_WEB_SLOTS } from '../core/plugins/plugin-host.js'; + +/** One stable Moss Web extension slot. @beta */ +export type { MossWebSlot } from '../core/plugins/plugin-host.js'; + +/** Theme variables that every built-in and plugin surface may consume. @beta */ +export const MOSS_WEB_THEME_TOKENS = [ + '--moss-color-canvas', + '--moss-color-surface', + '--moss-color-panel', + '--moss-color-panel-raised', + '--moss-color-border', + '--moss-color-border-strong', + '--moss-color-text', + '--moss-color-muted', + '--moss-color-faint', + '--moss-color-accent', + '--moss-color-accent-hover', + '--moss-color-accent-soft', + '--moss-color-danger', + '--moss-color-danger-soft', + '--moss-color-warning', + '--moss-color-warning-soft', + '--moss-color-success', + '--moss-color-success-soft', + '--moss-color-overlay', + '--moss-color-terminal', + '--moss-color-terminal-border', + '--moss-color-terminal-muted', + '--moss-color-terminal-prompt', + '--moss-color-terminal-text', + '--moss-font-family-body', + '--moss-font-family-display', + '--moss-font-family-code', + '--moss-font-size-1', + '--moss-font-size-2', + '--moss-font-size-3', + '--moss-font-size-4', + '--moss-font-size-5', + '--moss-font-size-6', + '--moss-font-size-display', + '--moss-font-weight-regular', + '--moss-font-weight-medium', + '--moss-font-weight-bold', + '--moss-line-height-tight', + '--moss-line-height-body', + '--moss-line-height-code', + '--moss-space-1', + '--moss-space-2', + '--moss-space-3', + '--moss-space-4', + '--moss-space-5', + '--moss-space-6', + '--moss-space-8', + '--moss-space-10', + '--moss-space-12', + '--moss-radius-control', + '--moss-radius-small', + '--moss-radius-card', + '--moss-radius-panel', + '--moss-radius-round', + '--moss-shadow-control', + '--moss-shadow-composer', + '--moss-shadow-float', + '--moss-shadow-dialog', + '--moss-control-height', + '--moss-control-height-small', + '--moss-motion-fast', + '--moss-motion-standard', + '--moss-motion-slow', + '--moss-ease-standard', + '--moss-z-base', + '--moss-z-header', + '--moss-z-drawer', + '--moss-z-dialog', + '--moss-z-toast', + '--moss-z-tooltip', + '--moss-state-idle', + '--moss-state-running', + '--moss-state-success', + '--moss-state-warning', + '--moss-state-error', + '--moss-state-interrupted', +] as const; + +/** One stable Moss Web theme variable. @beta */ +export type MossWebThemeToken = (typeof MOSS_WEB_THEME_TOKENS)[number]; + +/** Context passed to a trusted plugin Web module mounted in a scoped ShadowRoot. @beta */ +export interface MossWebPluginMountContext { + readonly pluginId: string; + readonly contributionId: string; + readonly slot: MossWebSlot; + /** Stable owner of the slot instance; arbitrary payloads remain read-only. */ + readonly owner?: { + readonly kind: 'workspace' | 'session' | 'message' | 'tool' | 'settings'; + readonly id: string; + readonly data?: unknown; + }; + /** Same-origin ESM entry exporting Moss controlled React components and a mount helper. */ + readonly componentsUrl: '/assets/moss-web-components.js'; +} + +/** Controlled browser module shape for an advanced trusted plugin UI. @beta */ +export interface MossWebPluginModule { + mount( + root: ShadowRoot, + context: MossWebPluginMountContext + ): void | (() => void) | Promise void)>; +} + +/** Browser-safe installed plugin record; filesystem locations are intentionally omitted. @beta */ +export interface MossWebInstalledPlugin { + readonly id: string; + readonly version: string; + readonly enabled: boolean; +} + +/** Browser-safe snapshot of one active runtime plugin. @beta */ +export interface MossWebPluginSnapshot { + readonly id: string; + readonly state: string; + readonly callState: 'accepting' | 'draining' | 'disposed'; + readonly tools: readonly string[]; + readonly commands: readonly string[]; + readonly providers: readonly string[]; + readonly mcpPresets: readonly string[]; + readonly webContributions: readonly string[]; +} + +/** Last-good runtime composition used for hot-reload and cache busting. @beta */ +export interface MossWebPluginComposition { + readonly generation: number; + readonly plugins: readonly MossWebPluginSnapshot[]; +} + +/** Browser response for schema-rendered plugin configuration. @beta */ +export interface MossWebPluginConfigResponse { + readonly schema: MossPluginConfigSchema; + readonly config: MossPluginConfigView; + readonly generation: number; + readonly restartRequired: false; +} + +/** Browser-safe durable run summary. @beta */ +export interface MossWebTaskRunSnapshot { + readonly id: string; + readonly sessionId: string; + readonly title: string; + readonly status: string; + readonly verification: string; + readonly evidenceCount: number; + readonly updatedAt: number; +} + +/** Redacted startup state returned to the browser workbench. @beta */ +export interface MossWebBootstrap { + readonly tools: readonly string[]; + readonly plugins: readonly MossWebPluginSnapshot[]; + readonly pluginComposition: MossWebPluginComposition; + readonly taskRuns: readonly MossWebTaskRunSnapshot[]; + readonly model: string; +} + +/** Durable session metadata safe to expose to the local browser. @beta */ +export interface MossWebSessionSummary { + readonly sessionId: string; + readonly title: string; + readonly updatedAt: number; + readonly messageCount: number; + readonly runId?: string; + readonly runStatus?: string; +} + +/** Browser-safe workspace entry exposed by the local single-workspace host. @beta */ +export interface MossWebWorkspaceSummary { + readonly id: string; + readonly name: string; + readonly current: boolean; +} + +/** One bounded match from a Web session title or transcript search. @beta */ +export interface MossWebSessionSearchHit extends MossWebSessionSummary { + readonly snippet: string; +} + +/** Durable cursor envelope returned by the Web event journal. @beta */ +export interface MossWebJournalEvent { + readonly runId: string; + readonly sessionId: string; + readonly seq: number; + readonly time: number; + readonly event: MossWebStreamEvent; +} + +/** Version-one browser event projection for a streamed Moss turn. @beta */ +export type MossWebStreamEvent = + | { readonly type: 'run'; readonly run: MossWebTaskRunSnapshot } + | { readonly type: 'text'; readonly delta: string } + | { readonly type: 'thought'; readonly delta: string } + | { + readonly type: 'tool'; + readonly state: 'start' | 'end'; + readonly toolCallId: string; + readonly name: string; + readonly input?: Readonly>; + readonly result?: string; + readonly isError?: boolean; + } + | { + readonly type: 'done'; + readonly stopReason: string; + readonly run?: MossWebTaskRunSnapshot; + } + | { readonly type: 'retry'; readonly attempt: number; readonly error: string } + | { + readonly type: 'compaction'; + readonly summaryChars: number; + readonly droppedMessages: number; + readonly checkpointOutline?: readonly string[]; + } + | { + readonly type: 'usage'; + readonly inputTokens: number; + readonly outputTokens: number; + readonly cacheReadTokens?: number; + readonly cacheCreationTokens?: number; + readonly contextTokens?: number; + } + | { + readonly type: 'context'; + readonly status: string; + readonly reason: string; + readonly goal: string; + readonly nextAction: string; + } + | { + readonly type: 'interrupted'; + readonly reason: string; + readonly run: MossWebTaskRunSnapshot; + } + | { readonly type: 'error'; readonly message: string }; diff --git a/packages/moss-agent/src/web-ui/web-control-plane-router.ts b/packages/moss-agent/src/web-ui/web-control-plane-router.ts new file mode 100644 index 00000000..c8e1d7a8 --- /dev/null +++ b/packages/moss-agent/src/web-ui/web-control-plane-router.ts @@ -0,0 +1,231 @@ +import type http from 'node:http'; + +import type { MossWebInteractionBroker } from './web-interaction-broker.js'; +import type { MossWebRuntimeService } from './web-runtime-service.js'; +import type { MossWebSettingsSection, MossWebSettingsService } from './web-settings-service.js'; + +interface MossWebControlPlaneRouterOptions { + readonly request: http.IncomingMessage; + readonly response: http.ServerResponse; + readonly url: URL; + readonly mutationAllowed: boolean; + readonly interactions: MossWebInteractionBroker; + readonly runtime: MossWebRuntimeService; + readonly settings: MossWebSettingsService; + readonly readJson: (request: http.IncomingMessage) => Promise>; + readonly sendJson: (response: http.ServerResponse, status: number, value: unknown) => void; +} + +/** Route the Web interaction, runtime, workflow, and settings control plane. @internal */ +export async function handleMossWebControlPlaneRequest( + options: MossWebControlPlaneRouterOptions +): Promise { + const { request, response, url, interactions, runtime, settings, readJson, sendJson } = options; + const send = (status: number, value: unknown): true => { + sendJson(response, status, value); + return true; + }; + + if (request.method === 'GET' && url.pathname === '/api/interactions') { + return send(200, { interactions: interactions.pending() }); + } + if (request.method === 'GET' && url.pathname === '/api/runtime/mode') { + return send(200, { mode: runtime.mode() }); + } + if (request.method === 'GET' && url.pathname === '/api/runtime/inventory') { + return send(200, runtime.inventory()); + } + if (request.method === 'GET' && url.pathname === '/api/runtime/mentions') { + return send(200, runtime.mentionInventory()); + } + if (request.method === 'GET' && url.pathname === '/api/jobs') { + return send(200, { jobs: runtime.jobs() }); + } + if (request.method === 'GET' && url.pathname === '/api/workflows') { + return send(200, { workflows: runtime.workflows() }); + } + if (request.method === 'GET' && url.pathname === '/api/settings') { + return send(200, await settings.snapshot()); + } + if (request.method === 'GET' && url.pathname === '/api/settings/models/catalog') { + return send(200, await settings.modelCatalog()); + } + const settingsSectionMatch = url.pathname.match(/^\/api\/settings\/([^/]+)$/); + if (request.method === 'GET' && settingsSectionMatch) { + const section = decodeURIComponent(settingsSectionMatch[1]) as MossWebSettingsSection; + return send(200, await settings.section(section)); + } + const inboxMatch = url.pathname.match(/^\/api\/sessions\/([^/]+)\/inbox$/); + if (request.method === 'GET' && inboxMatch) { + return send(200, { entries: runtime.inbox(decodeURIComponent(inboxMatch[1])) }); + } + const goalMatch = url.pathname.match(/^\/api\/sessions\/([^/]+)\/goal$/); + if (request.method === 'GET' && goalMatch) { + return send(200, { goal: await runtime.goal(decodeURIComponent(goalMatch[1])) }); + } + const todoMatch = url.pathname.match(/^\/api\/sessions\/([^/]+)\/todos$/); + if (request.method === 'GET' && todoMatch) { + return send(200, { todos: await runtime.todos(decodeURIComponent(todoMatch[1])) }); + } + const trajectoryMatch = url.pathname.match(/^\/api\/runs\/([^/]+)\/trajectory$/); + if (request.method === 'GET' && trajectoryMatch) { + return send(200, runtime.trajectory(decodeURIComponent(trajectoryMatch[1]))); + } + const verdictMatch = url.pathname.match(/^\/api\/runs\/([^/]+)\/verdict$/); + if (request.method === 'GET' && verdictMatch) { + return send(200, runtime.completionVerdict(decodeURIComponent(verdictMatch[1]))); + } + + const interactionMatch = url.pathname.match(/^\/api\/interactions\/([^/]+)\/(resolve|cancel)$/); + const jobStopMatch = url.pathname.match(/^\/api\/jobs\/([^/]+)\/stop$/); + const workflowRunMatch = url.pathname.match(/^\/api\/workflows\/([^/]+)\/run$/); + const settingsValidationMatch = url.pathname.match(/^\/api\/settings\/([^/]+)\/validate$/); + const credentialMatch = url.pathname.match(/^\/api\/settings\/credentials\/([^/]+)$/); + const steerMatch = url.pathname.match(/^\/api\/sessions\/([^/]+)\/steer$/); + const commandMatch = url.pathname.match(/^\/api\/sessions\/([^/]+)\/commands$/); + const isMutation = + (request.method === 'POST' && + Boolean( + interactionMatch || + jobStopMatch || + workflowRunMatch || + settingsValidationMatch || + inboxMatch || + steerMatch || + commandMatch + )) || + (request.method === 'PUT' && + Boolean( + url.pathname === '/api/runtime/mode' || + url.pathname === '/api/runtime/permission-preset' || + url.pathname === '/api/settings/models/selection' || + credentialMatch || + settingsSectionMatch || + goalMatch + )) || + (request.method === 'DELETE' && Boolean(credentialMatch)); + if (isMutation && !options.mutationAllowed) { + return send(403, { error: 'non-local origin denied' }); + } + + if (request.method === 'POST' && interactionMatch) { + const interactionId = decodeURIComponent(interactionMatch[1]); + let resolved: boolean; + if (interactionMatch[2] === 'cancel') { + resolved = interactions.cancel(interactionId); + } else { + const body = await readJson(request); + resolved = interactions.resolve( + interactionId, + typeof body.answer === 'string' ? body.answer : '' + ); + } + return resolved + ? send(200, { interaction: interactions.get(interactionId) }) + : send(409, { error: 'interaction is no longer pending' }); + } + if (request.method === 'PUT' && url.pathname === '/api/runtime/mode') { + const body = await readJson(request); + return send(200, { + mode: runtime.setMode(typeof body.mode === 'string' ? body.mode : ''), + }); + } + if (request.method === 'PUT' && url.pathname === '/api/runtime/permission-preset') { + const body = await readJson(request); + const result = settings.save('general', { + profile: typeof body.profile === 'string' ? body.profile : '', + }); + return send(result.valid ? 200 : 400, { ...result, restartRequired: result.valid }); + } + if (request.method === 'POST' && jobStopMatch) { + const taskId = decodeURIComponent(jobStopMatch[1]); + return runtime.stopJob(taskId) + ? send(200, { taskId, stopped: true }) + : send(404, { error: 'job not found' }); + } + if (request.method === 'POST' && workflowRunMatch) { + const body = await readJson(request); + const sessionId = typeof body.sessionId === 'string' ? body.sessionId : ''; + if (!sessionId) return send(400, { error: 'sessionId is required' }); + return send( + 201, + await runtime.runWorkflow( + sessionId, + decodeURIComponent(workflowRunMatch[1]), + typeof body.args === 'string' ? body.args : '' + ) + ); + } + if (request.method === 'POST' && settingsValidationMatch) { + const section = decodeURIComponent(settingsValidationMatch[1]) as MossWebSettingsSection; + const body = await readJson(request); + const result = settings.validate(section, body.values); + return send(result.valid ? 200 : 400, result); + } + if (request.method === 'PUT' && url.pathname === '/api/settings/models/selection') { + const body = await readJson(request); + return send(200, await settings.selectModel(typeof body.model === 'string' ? body.model : '')); + } + if (credentialMatch && (request.method === 'PUT' || request.method === 'DELETE')) { + const credential = decodeURIComponent(credentialMatch[1]); + if (credential !== 'apiKey') return send(404, { error: 'credential not found' }); + if (request.method === 'PUT') { + const body = await readJson(request); + settings.writeCredential('apiKey', typeof body.value === 'string' ? body.value : ''); + return send(200, { credential, configured: true }); + } + settings.deleteCredential('apiKey'); + return send(200, { credential, configured: false }); + } + if (request.method === 'PUT' && settingsSectionMatch) { + const section = decodeURIComponent(settingsSectionMatch[1]) as MossWebSettingsSection; + const body = await readJson(request); + const result = settings.save(section, body.values); + return send(result.valid ? 200 : 400, result); + } + if (request.method === 'POST' && inboxMatch) { + const body = await readJson(request); + const entry = runtime.admit( + decodeURIComponent(inboxMatch[1]), + typeof body.prompt === 'string' ? body.prompt : '', + body.delivery === 'steer' ? 'steer' : 'queue' + ); + return send(201, { entry }); + } + if (request.method === 'POST' && steerMatch) { + const body = await readJson(request); + const entry = runtime.steer( + decodeURIComponent(steerMatch[1]), + typeof body.prompt === 'string' ? body.prompt : '' + ); + return entry + ? send(201, { entry }) + : send(409, { error: 'steering requires exactly one active agent run' }); + } + if (request.method === 'PUT' && goalMatch) { + const body = await readJson(request); + const action = body.action; + if ( + action !== 'set' && + action !== 'pause' && + action !== 'resume' && + action !== 'complete' && + action !== 'block' && + action !== 'clear' + ) { + return send(400, { error: 'invalid goal action' }); + } + const goal = await runtime.updateGoal(decodeURIComponent(goalMatch[1]), action, { + ...(typeof body.objective === 'string' ? { objective: body.objective } : {}), + ...(typeof body.reason === 'string' ? { reason: body.reason } : {}), + }); + return send(200, { goal }); + } + if (request.method === 'POST' && commandMatch) { + const body = await readJson(request); + const command = typeof body.command === 'string' ? body.command.trim() : ''; + if (!command.startsWith('/')) return send(400, { error: 'slash command required' }); + return send(200, await runtime.dispatchSlash(decodeURIComponent(commandMatch[1]), command)); + } + return false; +} diff --git a/packages/moss-agent/src/web-ui/web-event-journal.ts b/packages/moss-agent/src/web-ui/web-event-journal.ts new file mode 100644 index 00000000..585119f4 --- /dev/null +++ b/packages/moss-agent/src/web-ui/web-event-journal.ts @@ -0,0 +1,146 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import type { MossWebJournalEvent, MossWebStreamEvent } from './web-contracts.js'; + +type MossWebJournalRecord = MossWebJournalEvent; + +type JournalListener = (record: MossWebJournalRecord) => void; + +const WEB_EVENT_TYPES = new Set([ + 'run', + 'text', + 'thought', + 'tool', + 'done', + 'retry', + 'compaction', + 'usage', + 'context', + 'interrupted', + 'error', +]); + +function isJournalRecord(value: unknown): value is MossWebJournalRecord { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + const record = value as Partial; + return ( + typeof record.runId === 'string' && + typeof record.sessionId === 'string' && + Number.isSafeInteger(record.seq) && + (record.seq ?? 0) > 0 && + typeof record.time === 'number' && + Boolean(record.event) && + typeof record.event === 'object' && + typeof (record.event as { type?: unknown }).type === 'string' && + WEB_EVENT_TYPES.has((record.event as MossWebStreamEvent).type) + ); +} + +/** Instance-owned durable browser event stream used for cursor recovery. @internal */ +export class MossWebEventJournal { + private readonly records = new Map(); + + private readonly listeners = new Map>(); + + constructor(private readonly filePath?: string) { + if (filePath) this.load(filePath); + } + + append(runId: string, sessionId: string, event: MossWebStreamEvent): MossWebJournalRecord { + const records = this.records.get(runId) ?? []; + const record: MossWebJournalRecord = { + runId, + sessionId, + seq: records.length + 1, + time: Date.now(), + event, + }; + if (this.filePath) { + fs.mkdirSync(path.dirname(this.filePath), { recursive: true }); + fs.appendFileSync(this.filePath, `${JSON.stringify(record)}\n`, { mode: 0o600 }); + } + records.push(record); + this.records.set(runId, records); + for (const listener of this.listeners.get(runId) ?? []) { + try { + listener({ ...record }); + } catch { + // A disconnected browser must not fail the agent run that owns this journal. + } + } + return { ...record }; + } + + events(runId: string, after = 0): readonly MossWebJournalRecord[] { + return (this.records.get(runId) ?? []) + .filter((record) => record.seq > after) + .map((record) => ({ ...record })); + } + + latestSequence(runId: string): number { + return this.records.get(runId)?.at(-1)?.seq ?? 0; + } + + subscribe(runId: string, listener: JournalListener): () => void { + const listeners = this.listeners.get(runId) ?? new Set(); + listeners.add(listener); + this.listeners.set(runId, listeners); + return () => { + listeners.delete(listener); + if (listeners.size === 0) this.listeners.delete(runId); + }; + } + + private load(filePath: string): void { + let body: string; + try { + body = fs.readFileSync(filePath, 'utf8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return; + throw error; + } + const validLines: string[] = []; + let needsRepair = false; + for (const line of body.split('\n')) { + if (!line.trim()) continue; + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + needsRepair = true; + break; + } + if (!isJournalRecord(parsed)) { + needsRepair = true; + break; + } + const records = this.records.get(parsed.runId) ?? []; + if (parsed.seq !== records.length + 1) { + needsRepair = true; + break; + } + records.push(parsed); + this.records.set(parsed.runId, records); + validLines.push(JSON.stringify(parsed)); + } + if (needsRepair) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, validLines.length > 0 ? `${validLines.join('\n')}\n` : '', { + mode: 0o600, + }); + } + } +} + +/** Parse a numeric or run-scoped browser event cursor without accepting a foreign run. @internal */ +export function parseMossWebEventCursor(runId: string, raw: string | string[] | undefined): number { + const value = Array.isArray(raw) ? raw[0] : raw; + if (!value) return 0; + const separator = value.lastIndexOf(':'); + const cursorRunId = separator >= 0 ? value.slice(0, separator) : runId; + const sequenceText = separator >= 0 ? value.slice(separator + 1) : value; + if (cursorRunId !== runId || !/^\d+$/.test(sequenceText)) return 0; + const sequence = Number(sequenceText); + return Number.isSafeInteger(sequence) && sequence >= 0 ? sequence : 0; +} diff --git a/packages/moss-agent/src/web-ui/web-interaction-bindings.ts b/packages/moss-agent/src/web-ui/web-interaction-bindings.ts new file mode 100644 index 00000000..71801c07 --- /dev/null +++ b/packages/moss-agent/src/web-ui/web-interaction-bindings.ts @@ -0,0 +1,35 @@ +import type { MossAgent } from '../core/agent/moss-agent.js'; +import { + setCliToolApprovalHookAsker, + setCliToolApprovalHookInteractionMode, +} from '../cli/approval.js'; +import type { MossWebInteractionBroker } from './web-interaction-broker.js'; +import type { MossWebRuntimeService } from './web-runtime-service.js'; + +/** Bind host-local browser interactions without changing process-global CLI state. @internal */ +export function bindMossWebInteractions( + agent: MossAgent, + broker: MossWebInteractionBroker, + runtime: MossWebRuntimeService +): () => void { + const unbindApprovalAsker = setCliToolApprovalHookAsker( + agent.config.hooks?.onBeforeToolExec, + broker.askApproval + ); + const unbindApprovalMode = setCliToolApprovalHookInteractionMode( + agent.config.hooks?.onBeforeToolExec, + () => runtime.mode() + ); + const questionCapableAgent = agent as MossAgent & { + setUserQuestionAsker?: MossAgent['setUserQuestionAsker']; + }; + const unbindQuestionAsker = questionCapableAgent.setUserQuestionAsker + ? questionCapableAgent.setUserQuestionAsker(broker.askQuestion) + : () => {}; + + return () => { + unbindApprovalAsker(); + unbindApprovalMode(); + unbindQuestionAsker(); + }; +} diff --git a/packages/moss-agent/src/web-ui/web-interaction-broker.ts b/packages/moss-agent/src/web-ui/web-interaction-broker.ts new file mode 100644 index 00000000..cfd9958e --- /dev/null +++ b/packages/moss-agent/src/web-ui/web-interaction-broker.ts @@ -0,0 +1,124 @@ +import { randomUUID } from 'node:crypto'; + +import { ErrorCode, MossError } from '../errors.js'; + +/** Browser interaction categories that can temporarily take over the composer. @beta */ +export type MossWebInteractionKind = 'approval' | 'question'; +/** Lifecycle state for one browser-mediated interaction. @beta */ +export type MossWebInteractionState = 'pending' | 'resolved' | 'cancelled'; + +/** Browser-safe pending approval or user-question request. @beta */ +export interface MossWebPendingInteraction { + readonly id: string; + readonly kind: MossWebInteractionKind; + readonly prompt: string; + readonly state: MossWebInteractionState; + readonly createdAt: number; + readonly resolvedAt?: number; +} + +interface InteractionRecord { + snapshot: MossWebPendingInteraction; + resolve: (answer: string) => void; + abortSignal?: AbortSignal; + onAbort?: () => void; +} + +/** Bridges blocking CLI approval/question prompts to a browser-owned pending protocol. @internal */ +export class MossWebInteractionBroker { + private readonly records = new Map(); + + readonly askApproval = (prompt: string, abortSignal?: AbortSignal): Promise => + this.ask('approval', prompt, abortSignal); + + readonly askQuestion = (prompt: string, abortSignal?: AbortSignal): Promise => + this.ask('question', prompt, abortSignal); + + pending(): readonly MossWebPendingInteraction[] { + return [...this.records.values()] + .map(({ snapshot }) => snapshot) + .filter(({ state }) => state === 'pending') + .sort((left, right) => left.createdAt - right.createdAt); + } + + get(id: string): MossWebPendingInteraction | undefined { + return this.records.get(id)?.snapshot; + } + + resolve(id: string, answer: string): boolean { + const record = this.records.get(id); + if (!record || record.snapshot.state !== 'pending') return false; + const resolvedAnswer = this.normalizeAnswer(record.snapshot.kind, answer); + this.finish(record, 'resolved', resolvedAnswer); + return true; + } + + cancel(id: string): boolean { + const record = this.records.get(id); + if (!record || record.snapshot.state !== 'pending') return false; + this.finish(record, 'cancelled', ''); + return true; + } + + cancelAll(): void { + for (const record of this.records.values()) { + if (record.snapshot.state === 'pending') this.finish(record, 'cancelled', ''); + } + } + + private ask( + kind: MossWebInteractionKind, + prompt: string, + abortSignal?: AbortSignal + ): Promise { + if (abortSignal?.aborted) return Promise.resolve(''); + return new Promise((resolve) => { + const id = `interaction-${randomUUID()}`; + const record: InteractionRecord = { + snapshot: { + id, + kind, + prompt, + state: 'pending', + createdAt: Date.now(), + }, + resolve, + abortSignal, + }; + record.onAbort = () => this.finish(record, 'cancelled', ''); + abortSignal?.addEventListener('abort', record.onAbort, { once: true }); + this.records.set(id, record); + this.pruneHistory(); + }); + } + + private finish( + record: InteractionRecord, + state: Extract, + answer: string + ): void { + record.abortSignal?.removeEventListener('abort', record.onAbort!); + record.snapshot = { ...record.snapshot, state, resolvedAt: Date.now() }; + record.resolve(answer); + } + + private normalizeAnswer(kind: MossWebInteractionKind, answer: string): string { + if (kind === 'question') return answer; + if (answer === 'allow_once') return 'y'; + if (answer === 'allow_always') return 'a'; + if (answer === 'deny') return 'n'; + throw new MossError({ + code: ErrorCode.USER_INPUT_INVALID, + message: 'approval answer must be allow_once, allow_always, or deny', + }); + } + + private pruneHistory(): void { + if (this.records.size <= 200) return; + for (const [id, record] of this.records) { + if (record.snapshot.state === 'pending') continue; + this.records.delete(id); + if (this.records.size <= 200) break; + } + } +} diff --git a/packages/moss-agent/src/web-ui/web-plugin-lifecycle.ts b/packages/moss-agent/src/web-ui/web-plugin-lifecycle.ts new file mode 100644 index 00000000..79c269f0 --- /dev/null +++ b/packages/moss-agent/src/web-ui/web-plugin-lifecycle.ts @@ -0,0 +1,179 @@ +import type { MossAgent } from '../core/agent/moss-agent.js'; +import type { MossPlugin } from '../core/plugins/plugin-host.js'; +import { ErrorCode, MossError } from '../errors.js'; +import { importDshPackage } from '../plugins/dsh-bundle-compatibility.js'; +import type { MossPluginConfigSchema } from '../plugins/plugin-config-schema.js'; +import { readMossPluginConfigSchema } from '../plugins/plugin-config-store.js'; +import type { MossPluginConfigStore } from '../plugins/plugin-config-store.js'; +import type { + InstalledMossPlugin, + InstalledPluginRegistry, +} from '../plugins/installed-plugin-registry.js'; + +/** Resolve the schema of one installed plugin without exposing secret values. @internal */ +export async function installedPluginSchema( + entry: InstalledMossPlugin +): Promise { + return entry.format === 'dsh-package-v1' + ? (await importDshPackage(entry.root)).configSchema + : readMossPluginConfigSchema(entry.root); +} + +function active(agent: MossAgent, pluginId: string): boolean { + return agent.plugins + .inspect() + .plugins.some(({ id, state }) => id === pluginId && state === 'active'); +} + +/** Activate a same-id immutable npm candidate and restore last-good on failure. @internal */ +export async function activateWebPluginCandidate( + agent: MossAgent, + registry: InstalledPluginRegistry, + entry: InstalledMossPlugin, + wasActive: boolean +): Promise { + if (!entry.lastGood || !entry.enabled) return; + let candidate: MossPlugin | undefined; + try { + candidate = await registry.loadInstalled(entry.id); + } catch (error) { + await registry.rollback(entry.id); + throw error; + } + if (wasActive) { + try { + await agent.plugins.unload(entry.id); + } catch (error) { + await discard(candidate); + await registry.rollback(entry.id); + throw error; + } + } + try { + await agent.plugins.install(candidate); + } catch (error) { + await registry.rollback(entry.id); + if (wasActive) await restoreRuntime(agent, registry, entry.id); + throw error; + } +} + +async function discard(candidate: MossPlugin | undefined): Promise { + await candidate?.disposeCandidate?.(); +} + +async function restoreRuntime( + agent: MossAgent, + registry: InstalledPluginRegistry, + pluginId: string +): Promise { + const rollback = await registry.loadInstalled(pluginId); + await agent.plugins.install(rollback); +} + +/** Enable and activate one statically validated candidate exactly once. @internal */ +export async function enableWebPlugin( + agent: MossAgent, + registry: InstalledPluginRegistry, + pluginId: string +): Promise { + if (active(agent, pluginId)) { + await registry.enable(pluginId); + return; + } + const candidate = await registry.loadInstalled(pluginId); + try { + await registry.enable(pluginId); + } catch (error) { + await discard(candidate); + throw error; + } + try { + await agent.plugins.install(candidate); + } catch (error) { + await registry.disable(pluginId).catch(() => {}); + throw error; + } +} + +/** Disable one generation and restore it if the registry commit fails. @internal */ +export async function disableWebPlugin( + agent: MossAgent, + registry: InstalledPluginRegistry, + pluginId: string +): Promise { + const wasActive = active(agent, pluginId); + if (wasActive) await agent.plugins.unload(pluginId); + try { + await registry.disable(pluginId); + } catch (error) { + if (wasActive) await restoreRuntime(agent, registry, pluginId); + throw error; + } +} + +/** Remove one generation and restore it if the registry commit fails. @internal */ +export async function removeWebPlugin( + agent: MossAgent, + registry: InstalledPluginRegistry, + pluginId: string +): Promise { + const wasActive = active(agent, pluginId); + if (wasActive) await agent.plugins.unload(pluginId); + try { + await registry.remove(pluginId); + } catch (error) { + if (wasActive) await restoreRuntime(agent, registry, pluginId); + throw error; + } +} + +/** Persist config, prepare one candidate, drain old calls, and roll back on failure. @internal */ +export async function mutateWebPluginConfig( + agent: MossAgent, + registry: InstalledPluginRegistry, + store: MossPluginConfigStore, + entry: InstalledMossPlugin, + schema: MossPluginConfigSchema, + mutation: () => Promise +): Promise { + if (!active(agent, entry.id)) { + await mutation(); + return; + } + const before = await store.loadRuntimeConfig(entry.id, schema); + await mutation(); + + let candidate: MossPlugin | undefined; + try { + candidate = await registry.loadInstalled(entry.id); + } catch (error) { + await store.replaceRuntimeConfig(entry.id, schema, before); + throw error; + } + + try { + await agent.plugins.unload(entry.id); + } catch (error) { + await discard(candidate); + await store.replaceRuntimeConfig(entry.id, schema, before); + throw error; + } + + try { + await agent.plugins.install(candidate); + } catch (activationError) { + await store.replaceRuntimeConfig(entry.id, schema, before); + try { + await restoreRuntime(agent, registry, entry.id); + } catch (rollbackError) { + throw new MossError({ + code: ErrorCode.TOOL_EXECUTION_FAILED, + message: `plugin config activation and rollback failed: ${entry.id}`, + cause: new AggregateError([activationError, rollbackError]), + context: { pluginId: entry.id }, + }); + } + throw activationError; + } +} diff --git a/packages/moss-agent/src/web-ui/web-run-streaming.ts b/packages/moss-agent/src/web-ui/web-run-streaming.ts new file mode 100644 index 00000000..876bcb67 --- /dev/null +++ b/packages/moss-agent/src/web-ui/web-run-streaming.ts @@ -0,0 +1,204 @@ +import type http from 'node:http'; + +import type { ChatOptions, MossAgent } from '../core/agent/moss-agent.js'; +import type { TaskRunLedger } from '../core/task-run/task-run-ledger.js'; +import { errorMessage } from '../errors.js'; +import type { MossWebStreamEvent } from './web-contracts.js'; +import type { MossWebEventJournal } from './web-event-journal.js'; + +/** Stream durable run events with cursor replay and terminal close semantics. @internal */ +export function streamRunEvents( + request: http.IncomingMessage, + response: http.ServerResponse, + eventStreams: Set, + journal: MossWebEventJournal, + run: NonNullable>, + after: number +): void { + response.writeHead(200, { + 'content-type': 'text/event-stream; charset=utf-8', + 'cache-control': 'no-store', + connection: 'keep-alive', + 'x-accel-buffering': 'no', + 'x-content-type-options': 'nosniff', + }); + response.write(': moss-event-stream\n\n'); + eventStreams.add(response); + const subscription: { unsubscribe?: () => void } = {}; + let finished = false; + const keepAlive = setInterval(() => response.write(': keep-alive\n\n'), 15_000); + keepAlive.unref(); + const cleanup = () => { + if (finished) return; + finished = true; + clearInterval(keepAlive); + subscription.unsubscribe?.(); + eventStreams.delete(response); + }; + const finish = () => { + cleanup(); + response.end(); + }; + const send = (record: ReturnType) => { + if (finished) return; + response.write(`id: ${record.seq}\n`); + response.write(`event: ${record.event.type}\n`); + response.write(`data: ${JSON.stringify(record)}\n\n`); + if (record.event.type === 'done' || record.event.type === 'interrupted') finish(); + }; + request.once('aborted', cleanup); + response.once('close', cleanup); + for (const record of journal.events(run.id, after)) send(record); + if (finished) return; + if ( + run.status === 'completed' || + run.status === 'failed' || + run.status === 'cancelled' || + run.status === 'interrupted' + ) { + finish(); + return; + } + subscription.unsubscribe = journal.subscribe(run.id, send); +} + +/** Stream one agent turn as legacy NDJSON while journaling resumable browser events. @internal */ +export async function streamPrompt( + agent: MossAgent, + taskRuns: TaskRunLedger, + eventJournal: MossWebEventJournal, + active: Map, + runId: string, + sessionId: string, + prompt: string, + attachments: NonNullable, + response: http.ServerResponse +): Promise { + const controller = new AbortController(); + active.set(sessionId, controller); + response.writeHead(200, { + 'content-type': 'application/x-ndjson; charset=utf-8', + 'cache-control': 'no-store', + connection: 'keep-alive', + 'x-content-type-options': 'nosniff', + }); + const emit = (event: MossWebStreamEvent) => { + try { + eventJournal.append(runId, sessionId, event); + } catch (error) { + console.error(`[web] failed to persist resumable event: ${errorMessage(error)}`); + } + response.write(`${JSON.stringify(event)}\n`); + }; + let stopReason = 'end_turn'; + let streamError: string | undefined; + try { + const before = taskRuns.get(runId); + if (before?.status === 'created') { + const run = taskRuns.append(runId, { + type: 'run.started', + data: { promptLength: prompt.length }, + }); + emit({ type: 'run', run }); + } + for await (const event of agent.streamChat(sessionId, prompt, { + abortSignal: controller.signal, + ...(attachments.length > 0 ? { attachments } : {}), + })) { + if (event.type === 'text_delta') emit({ type: 'text', delta: event.delta }); + if (event.type === 'thinking_delta') emit({ type: 'thought', delta: event.delta }); + if (event.type === 'tool_start') { + taskRuns.append(runId, { + type: 'tool.started', + data: { toolCallId: event.toolCallId, name: event.toolName }, + }); + emit({ + type: 'tool', + state: 'start', + toolCallId: event.toolCallId, + name: event.toolName, + input: event.input, + }); + } + if (event.type === 'tool_end') { + taskRuns.append(runId, { + type: event.isError ? 'tool.failed' : 'tool.succeeded', + data: { toolCallId: event.toolCallId, name: event.toolName, isError: event.isError }, + }); + emit({ + type: 'tool', + state: 'end', + toolCallId: event.toolCallId, + name: event.toolName, + result: event.result, + isError: event.isError, + }); + } + if (event.type === 'retry') + emit({ type: 'retry', attempt: event.attempt, error: event.error }); + if (event.type === 'compaction') { + emit({ + type: 'compaction', + summaryChars: event.summaryChars, + droppedMessages: event.droppedMessages, + ...(event.checkpointOutline ? { checkpointOutline: event.checkpointOutline } : {}), + }); + } + if (event.type === 'llm_usage') { + emit({ + type: 'usage', + inputTokens: event.inputTokens, + outputTokens: event.outputTokens, + ...(event.cacheReadTokens !== undefined + ? { cacheReadTokens: event.cacheReadTokens } + : {}), + ...(event.cacheCreationTokens !== undefined + ? { cacheCreationTokens: event.cacheCreationTokens } + : {}), + ...(event.contextTokens !== undefined ? { contextTokens: event.contextTokens } : {}), + }); + } + if (event.type === 'working_context_checkpoint') { + emit({ + type: 'context', + status: event.status, + reason: event.reason, + goal: event.goal, + nextAction: event.nextAction, + }); + } + if (event.type === 'turn_end') stopReason = event.stopReason; + if (event.type === 'error') { + streamError = event.error; + stopReason = 'error'; + emit({ type: 'error', message: event.error }); + } + if (event.type === 'done') { + stopReason = event.result.stopReason ?? (streamError ? 'error' : stopReason); + } + } + const current = taskRuns.get(runId); + const terminalType = + !streamError && (stopReason === 'end_turn' || stopReason === 'stop_sequence') + ? 'run.completed' + : 'run.failed'; + const run = + current?.status === 'running' + ? taskRuns.append(runId, { + type: terminalType, + data: { stopReason, ...(streamError ? { message: streamError } : {}) }, + }) + : current; + emit({ type: 'done', stopReason, run }); + } catch (error) { + const current = taskRuns.get(runId); + if (current?.status === 'running') { + taskRuns.append(runId, { type: 'run.failed', data: { message: errorMessage(error) } }); + } + emit({ type: 'error', message: errorMessage(error) }); + emit({ type: 'done', stopReason: controller.signal.aborted ? 'aborted_by_user' : 'error' }); + } finally { + if (active.get(sessionId) === controller) active.delete(sessionId); + response.end(); + } +} diff --git a/packages/moss-agent/src/web-ui/web-runtime-service.ts b/packages/moss-agent/src/web-ui/web-runtime-service.ts new file mode 100644 index 00000000..d5829614 --- /dev/null +++ b/packages/moss-agent/src/web-ui/web-runtime-service.ts @@ -0,0 +1,264 @@ +import type { MossAgent } from '../core/agent/moss-agent.js'; +import type { TaskRunLedger } from '../core/task-run/task-run-ledger.js'; +import { parseCliInteractionMode, type CliInteractionMode } from '../cli/approval.js'; +import { + registryCommandNames, + runRegistryCommand, + type CommandContext, + type CommandSpec, +} from '../cli/commands/registry.js'; +import type { CliRuntimeStatus } from '../cli/onboarding.js'; +import { ErrorCode, MossError } from '../errors.js'; +import { parseTodoChecklistText, type TodoItem } from '../tools/todo-tool.js'; + +export interface MossWebRuntimeServiceOptions { + readonly runtime?: CliRuntimeStatus; + readonly locale?: string; + readonly initialMode?: CliInteractionMode; +} + +/** Browser application service over the agent's existing runtime state owners. @internal */ +export class MossWebRuntimeService { + private currentMode: CliInteractionMode; + + constructor( + private readonly agent: MossAgent, + private readonly taskRuns: TaskRunLedger, + private readonly options: MossWebRuntimeServiceOptions = {} + ) { + this.currentMode = options.initialMode ?? 'default'; + } + + mode(): CliInteractionMode { + return this.currentMode; + } + + setMode(raw: string): CliInteractionMode { + const mode = parseCliInteractionMode(raw); + if (!mode) this.invalid('mode must be plan, default, or accept-edits'); + this.currentMode = mode; + return mode; + } + + inbox(sessionId: string) { + return this.agent.inboxPending(sessionId); + } + + admit(sessionId: string, prompt: string, delivery: 'queue' | 'steer' = 'queue') { + const normalized = this.prompt(prompt); + return this.agent.admit(sessionId, normalized, { delivery }); + } + + steer(sessionId: string, prompt: string) { + return this.agent.steer(sessionId, this.prompt(prompt)); + } + + async goal(sessionId: string) { + return this.agent.getGoal(sessionId); + } + + async setGoal(sessionId: string, objective: string) { + return this.agent.setGoal(sessionId, objective); + } + + async updateGoal( + sessionId: string, + action: 'set' | 'pause' | 'resume' | 'complete' | 'block' | 'clear', + input: { objective?: string; reason?: string } + ) { + if (action === 'set') return this.agent.setGoal(sessionId, input.objective ?? ''); + if (action === 'pause') return this.agent.pauseGoal(sessionId, input.reason); + if (action === 'resume') return this.agent.resumeGoal(sessionId); + if (action === 'complete') return this.agent.completeGoal(sessionId, input.reason); + if (action === 'block') return this.agent.blockGoal(sessionId, input.reason); + await this.agent.clearGoal(sessionId); + return undefined; + } + + async todos(sessionId: string): Promise { + const messages = await this.agent.config.sessionStore.loadMessages(sessionId); + for (let messageIndex = messages.length - 1; messageIndex >= 0; messageIndex--) { + const content = messages[messageIndex]?.content; + if (!Array.isArray(content)) continue; + for (let blockIndex = content.length - 1; blockIndex >= 0; blockIndex--) { + const block = content[blockIndex]; + if (block?.type !== 'tool_result' || typeof block.content !== 'string') continue; + const parsed = parseTodoChecklistText(block.content); + if (parsed !== null) return parsed; + } + } + return []; + } + + jobs() { + return this.agent.asyncTasks.list(); + } + + stopJob(taskId: string): boolean { + return this.agent.asyncTasks.stop(taskId, 'user_cancelled'); + } + + workflows() { + return this.agent.plugins.listCommands().map((command) => ({ + id: command.id, + title: command.title, + description: command.description, + available: true, + })); + } + + async runWorkflow(sessionId: string, workflowId: string, args: string) { + const prompt = (await this.agent.plugins.expandCommand(workflowId, args))?.trim(); + if (prompt === undefined) this.invalid(`workflow "${workflowId}" was not found`); + if (!prompt) this.invalid(`workflow "${workflowId}" produced an empty prompt`); + const entry = this.agent.admit(sessionId, prompt, { delivery: 'queue' }); + return { workflowId, prompt, entry }; + } + + trajectory(runId: string) { + const run = this.taskRuns.get(runId); + if (!run) this.invalid(`run "${runId}" was not found`); + const events = this.taskRuns.events(runId); + return { + run, + events, + evidence: events.filter((event) => event.type.startsWith('tool.')), + }; + } + + completionVerdict(runId: string) { + const run = this.taskRuns.get(runId); + if (!run) this.invalid(`run "${runId}" was not found`); + return { + runId, + status: run.status, + verdict: run.verification, + evidenceCount: run.evidenceCount, + complete: run.status === 'completed', + }; + } + + mentionInventory() { + const skills = (this.agent.config.skillRegistry?.list() ?? []).map((skill) => ({ + id: skill.stableId ?? skill.name, + name: skill.name, + description: skill.description, + enabled: skill.enabled !== false, + })); + const configuredExperts = + this.agent.config.subagentExpertRegistry?.list() ?? this.agent.config.subagentExperts ?? []; + const pluginExperts = this.agent.plugins + .inspect() + .plugins.flatMap((plugin) => plugin.experts.map((id) => ({ id, pluginId: plugin.id }))); + return { + commands: [ + ...registryCommandNames(), + ...this.agent.plugins.listCommands().map(({ id }) => `/${id}`), + ], + skills, + experts: [ + ...configuredExperts.map(({ instructions: _, ...expert }) => expert), + ...pluginExperts, + ], + }; + } + + inventory() { + return { + model: this.agent.config.model ?? 'Configured model', + workspace: this.agent.config.workspaceDir, + mode: this.mode(), + tools: this.agent.tools.getAll().map((tool) => ({ + name: tool.name, + description: tool.description, + sideEffect: tool.metadata?.sideEffectClass ?? 'local_write', + })), + plugins: this.agent.plugins.inspect(), + asyncTasks: this.jobs(), + runtime: this.options.runtime + ? { + workspace: this.options.runtime.workspace, + runtimeDir: this.options.runtime.runtimeDir, + execBackend: this.options.runtime.execBackend, + safetyMode: this.options.runtime.safetyMode, + meshEnabled: this.options.runtime.meshEnabled, + mcp: this.options.runtime.mcp, + deviceConnected: Boolean(this.options.runtime.device), + } + : undefined, + }; + } + + async dispatchSlash(sessionId: string, command: string) { + const messages: Array<{ kind: 'system' | 'error'; text: string }> = []; + const modeCommand = command.trim().match(/^\/(?:mode|plan)(?:\s+(.*))?$/); + if (modeCommand) { + const input = modeCommand[1]?.trim(); + if (!input) { + messages.push({ kind: 'system', text: `Interaction mode: ${this.mode()}` }); + } else { + const parsed = parseCliInteractionMode(input); + if (!parsed) { + messages.push({ + kind: 'error', + text: 'Usage: /mode [plan|default|accept-edits]', + }); + } else { + this.currentMode = parsed; + messages.push({ kind: 'system', text: `Switched to ${parsed}` }); + } + } + return { handled: true, messages, mode: this.mode() }; + } + let prefill: string | undefined; + let submittedPrompt: string | undefined; + const context: CommandContext = { + agent: this.agent, + runtime: this.options.runtime, + sessionKey: sessionId, + workspace: this.agent.config.workspaceDir ?? process.cwd(), + locale: this.options.locale, + surface: 'repl', + say: (kind, text) => messages.push({ kind, text }), + prefillInput: (text) => { + prefill = text; + }, + submitPrompt: (text) => { + submittedPrompt = text; + }, + setInteractionMode: (mode) => { + this.currentMode = mode; + }, + }; + const customCommands: CommandSpec[] = this.agent.plugins + .listCommands() + .map((pluginCommand) => ({ + name: `/${pluginCommand.id}`, + summary: pluginCommand.description ?? pluginCommand.title, + run: async (ctx, args) => + ctx.submitPrompt?.( + (await this.agent.plugins.expandCommand(pluginCommand.id, args)) ?? '' + ), + })); + const handled = await runRegistryCommand(command, context, customCommands); + return { + handled, + messages, + mode: this.mode(), + ...(prefill !== undefined ? { prefill } : {}), + ...(submittedPrompt !== undefined ? { submittedPrompt } : {}), + }; + } + + private prompt(value: string): string { + const normalized = value.trim(); + if (!normalized || normalized.length > 20_000) { + this.invalid('prompt must contain 1 to 20000 characters'); + } + return normalized; + } + + private invalid(message: string): never { + throw new MossError({ code: ErrorCode.USER_INPUT_INVALID, message }); + } +} diff --git a/packages/moss-agent/src/web-ui/web-server.ts b/packages/moss-agent/src/web-ui/web-server.ts index 2362c319..b36acc35 100644 --- a/packages/moss-agent/src/web-ui/web-server.ts +++ b/packages/moss-agent/src/web-ui/web-server.ts @@ -1,23 +1,78 @@ import http from 'node:http'; import { randomUUID } from 'node:crypto'; -import type { AddressInfo } from 'node:net'; +import { readFile } from 'node:fs/promises'; +import { isIP, type AddressInfo } from 'node:net'; +import path from 'node:path'; import type { MossAgent } from '../core/agent/moss-agent.js'; import { TaskRunLedger } from '../core/task-run/task-run-ledger.js'; import { ErrorCode, MossError, errorMessage } from '../errors.js'; -import { WEB_CSS, WEB_HTML, WEB_JS } from './web-assets.js'; +import { InstalledPluginRegistry } from '../plugins/installed-plugin-registry.js'; +import { readMossPluginManifest } from '../plugins/installed-plugin-registry.js'; +import { MossPluginConfigStore } from '../plugins/plugin-config-store.js'; +import { CliServices } from '../cli/cli-services.js'; +import type { CliRuntimeStatus } from '../cli/onboarding.js'; +import { handleMossWebAttachmentRequest } from './web-attachment-router.js'; +import { MossWebAttachmentService } from './web-attachment-service.js'; +import { handleMossWebControlPlaneRequest } from './web-control-plane-router.js'; +import { MossWebEventJournal, parseMossWebEventCursor } from './web-event-journal.js'; +import { MossWebInteractionBroker } from './web-interaction-broker.js'; +import { bindMossWebInteractions } from './web-interaction-bindings.js'; +import { streamPrompt, streamRunEvents } from './web-run-streaming.js'; +import { MossWebRuntimeService } from './web-runtime-service.js'; +import { MossWebSessionService } from './web-session-service.js'; +import { MossWebSettingsService } from './web-settings-service.js'; +import { + activateWebPluginCandidate, + disableWebPlugin, + enableWebPlugin, + installedPluginSchema, + mutateWebPluginConfig, + removeWebPlugin, +} from './web-plugin-lifecycle.js'; +import { resolveMossWebStaticAsset, type MossWebClientAssetName } from './web-static-assets.js'; const MAX_BODY_BYTES = 64 * 1024; +interface ActiveWebContribution { + readonly pluginId: string; + readonly id: string; + readonly slot: string; + readonly moduleUrl: string; + readonly source: string; +} + +interface ActiveWebContributionState { + current: readonly ActiveWebContribution[]; +} + +/** Options for the loopback Moss Web host. @beta */ export interface MossWebServerOptions { port?: number; host?: string; abortSignal?: AbortSignal; - /** Optional host-owned task ledger. Defaults to an in-memory ledger. */ + /** Optional host-owned task ledger. Defaults to workspace durability when available. */ taskRunLedger?: TaskRunLedger; /** Optional JSONL path used when the server creates its own ledger. */ taskRunFile?: string; + /** Optional JSONL path for resumable browser stream events. */ + eventJournalFile?: string; + /** Optional JSON path for durable Web-only session titles. */ + sessionMetadataFile?: string; + /** Config root used by the explicit installed-plugin registry. */ + configDir?: string; + /** Optional CLI service facade reused by browser settings and model selection. */ + cliServices?: CliServices; + /** Optional live CLI runtime inventory exposed without credentials. */ + runtime?: CliRuntimeStatus; + /** Optional explicit config path used by browser settings writes. */ + settingsConfigPath?: string; + /** Optional host-owned interaction broker. */ + interactionBroker?: MossWebInteractionBroker; + /** Optional storage root for uploaded attachments and copied generated artifacts. */ + attachmentStoreDir?: string; } +/** Running loopback Moss Web host. @beta */ export interface MossWebServerHandle { readonly url: string; readonly host: string; @@ -48,6 +103,24 @@ function sendAsset(response: http.ServerResponse, type: string, body: string): v response.end(body); } +function sendMarkdown(response: http.ServerResponse, sessionId: string, body: string): void { + response.writeHead(200, { + 'content-type': 'text/markdown; charset=utf-8', + 'content-disposition': `attachment; filename*=UTF-8''${encodeURIComponent(sessionId)}.md`, + 'cache-control': 'no-store', + 'x-content-type-options': 'nosniff', + }); + response.end(body); +} + +async function sendClientAsset( + response: http.ServerResponse, + filename: MossWebClientAssetName +): Promise { + const body = await readFile(new URL(`./client/${filename}`, import.meta.url), 'utf8'); + sendAsset(response, filename.endsWith('.css') ? 'text/css' : 'text/javascript', body); +} + async function readJson(request: http.IncomingMessage): Promise> { let body = ''; for await (const chunk of request) { @@ -67,28 +140,236 @@ async function readJson(request: http.IncomingMessage): Promise; } -function isLocalOrigin(request: http.IncomingMessage): boolean { +function isExpectedHost(request: http.IncomingMessage, expectedOrigin: string): boolean { + try { + return request.headers.host === new URL(expectedOrigin).host; + } catch { + return false; + } +} + +function formatHttpOrigin(host: string, port: number): string { + const hostname = isIP(host) === 6 && !host.startsWith('[') ? `[${host}]` : host; + return `http://${hostname}:${port}`; +} + +function isAuthorizedMutation( + request: http.IncomingMessage, + expectedOrigin: string, + csrfToken: string +): boolean { const origin = request.headers.origin; - if (!origin) return true; + const submittedToken = request.headers['x-moss-csrf']; + if (!origin || Array.isArray(submittedToken) || submittedToken !== csrfToken) return false; try { - const host = new URL(origin).hostname; - return host === '127.0.0.1' || host === 'localhost' || host === '::1' || host === '[::1]'; + return new URL(origin).origin === expectedOrigin; } catch { return false; } } -/** Start the local Moss browser host around an existing fully composed agent. @internal */ +async function sessionTimeline(agent: MossAgent, sessionId: string): Promise { + const messages = await agent.config.sessionStore.loadMessages(sessionId); + const timeline: unknown[] = []; + for (const [messageIndex, message] of messages.entries()) { + if (typeof message.content === 'string') { + if (message.content) { + timeline.push({ + id: `message-${messageIndex}`, + kind: message.role === 'user' ? 'user' : 'assistant', + text: message.content, + }); + } + } else { + for (const [blockIndex, block] of message.content.entries()) { + const id = `message-${messageIndex}-${blockIndex}`; + if (block.type === 'text' && block.text) { + timeline.push({ + id, + kind: message.role === 'user' ? 'user' : 'assistant', + text: block.text, + }); + } + if (block.type === 'tool_use') { + timeline.push({ + id: block.id, + kind: 'tool', + name: block.name, + state: 'complete', + input: block.input, + }); + } + if (block.type === 'tool_result') { + const tool = timeline.find( + (item) => + item && + typeof item === 'object' && + 'id' in item && + (item as { id: unknown }).id === block.tool_use_id + ) as Record | undefined; + if (tool) { + tool.result = block.content; + tool.state = block.is_error ? 'failed' : 'complete'; + } + } + } + } + if (message.thinking?.length) { + timeline.push({ + id: `thinking-${messageIndex}`, + kind: 'reasoning', + text: message.thinking.join('\n'), + }); + } + } + return timeline; +} + +async function snapshotActiveWebContributions( + registry: InstalledPluginRegistry | undefined, + agent: MossAgent +): Promise { + if (!registry) return Object.freeze([]); + const activePluginIds = new Set( + agent.plugins + .inspect() + .plugins.filter(({ state }) => state === 'active') + .map(({ id }) => id) + ); + const activeContributionIds = new Set(agent.plugins.getWebContributions().map(({ id }) => id)); + const generation = agent.plugins.inspect().generation; + const contributions: ActiveWebContribution[] = []; + for (const entry of await registry.list()) { + if (!activePluginIds.has(entry.id)) continue; + try { + const manifest = await readMossPluginManifest(entry.root); + for (const contribution of manifest.web?.contributions ?? []) { + if (!activeContributionIds.has(contribution.id)) continue; + contributions.push({ + pluginId: entry.id, + id: contribution.id, + slot: contribution.slot, + moduleUrl: `/plugin-assets/${encodeURIComponent(entry.id)}/${encodeURIComponent(contribution.id)}.js?generation=${generation}`, + source: await readFile(path.resolve(entry.root, contribution.module), 'utf8'), + }); + } + } catch { + // A broken plugin stays isolated and remains visible in the installed inventory. + } + } + return Object.freeze(contributions); +} + +async function sendPluginAsset( + response: http.ServerResponse, + contributions: readonly ActiveWebContribution[], + pluginId: string, + contributionId: string +): Promise { + const contribution = contributions.find( + (candidate) => candidate.pluginId === pluginId && candidate.id === contributionId + ); + if (!contribution) return sendJson(response, 404, { error: 'plugin contribution not found' }); + sendAsset(response, 'text/javascript', contribution.source); +} + +/** Start the local Moss browser host around an existing fully composed agent. @beta */ export async function startMossWebServer( agent: MossAgent, options: MossWebServerOptions = {} ): Promise { const host = options.host ?? '127.0.0.1'; - const taskRuns = options.taskRunLedger ?? new TaskRunLedger(options.taskRunFile); + const runtimeDir = agent.config.workspaceDir + ? path.join(agent.config.workspaceDir, '.moss') + : undefined; + const taskRuns = + options.taskRunLedger ?? + new TaskRunLedger( + options.taskRunFile ?? (runtimeDir ? path.join(runtimeDir, 'task-runs.jsonl') : undefined) + ); + const eventJournal = new MossWebEventJournal( + options.eventJournalFile ?? (runtimeDir ? path.join(runtimeDir, 'web-events.jsonl') : undefined) + ); + const interruptedRuns = taskRuns + .list() + .filter(({ status }) => status === 'created' || status === 'running'); taskRuns.recoverInterrupted(); + for (const interrupted of interruptedRuns) { + const run = taskRuns.get(interrupted.id); + if (run) { + eventJournal.append(run.id, run.sessionId, { + type: 'interrupted', + reason: 'host restarted', + run, + }); + } + } + const sessions = new MossWebSessionService(agent, taskRuns, { + workspaceDir: agent.config.workspaceDir, + metadataFile: + options.sessionMetadataFile ?? + (runtimeDir ? path.join(runtimeDir, 'web-session-metadata.json') : undefined), + }); const active = new Map(); + const eventStreams = new Set(); + const pluginRegistry = options.configDir + ? new InstalledPluginRegistry({ configDir: options.configDir }) + : undefined; + const interactionBroker = options.interactionBroker ?? new MossWebInteractionBroker(); + const attachments = new MossWebAttachmentService({ + storageDir: + options.attachmentStoreDir ?? + path.join(agent.config.workspaceDir ?? process.cwd(), '.moss', 'web-attachments'), + workspaceDir: agent.config.workspaceDir, + }); + const runtime = new MossWebRuntimeService(agent, taskRuns, { runtime: options.runtime }); + const settings = new MossWebSettingsService(agent, options.cliServices ?? new CliServices(), { + configPath: + options.settingsConfigPath ?? + (options.configDir ? path.join(options.configDir, 'config.json') : undefined), + pluginRegistry, + }); + const pluginConfigStore = options.configDir + ? new MossPluginConfigStore({ configDir: options.configDir }) + : undefined; + const webContributions: ActiveWebContributionState = { + current: await snapshotActiveWebContributions(pluginRegistry, agent), + }; + const compositionStreams = new Set(); + const unsubscribeComposition = agent.plugins.subscribe((snapshot) => { + void snapshotActiveWebContributions(pluginRegistry, agent) + .then((current) => { + webContributions.current = current; + const payload = `event: composition\ndata: ${JSON.stringify(snapshot)}\n\n`; + for (const stream of compositionStreams) stream.write(payload); + }) + .catch((error: unknown) => { + console.error(`[web] failed to refresh plugin composition: ${errorMessage(error)}`); + }); + }); + const expectedOriginRef: { value?: string } = {}; + const csrfToken = randomUUID(); const server = http.createServer((request, response) => { - void handleRequest(agent, taskRuns, active, request, response).catch((error: unknown) => { + void handleRequest( + agent, + taskRuns, + eventJournal, + sessions, + interactionBroker, + runtime, + settings, + attachments, + active, + eventStreams, + pluginRegistry, + pluginConfigStore, + webContributions, + compositionStreams, + expectedOriginRef.value, + csrfToken, + request, + response + ).catch((error: unknown) => { if (!response.headersSent) sendJson(response, 400, { error: errorMessage(error) }); else response.end(); }); @@ -101,11 +382,20 @@ export async function startMossWebServer( }); }); const address = server.address() as AddressInfo; + expectedOriginRef.value = formatHttpOrigin(host, address.port); + const unbindInteractions = bindMossWebInteractions(agent, interactionBroker, runtime); let closePromise: Promise | undefined; const close = () => { closePromise ??= (async () => { for (const controller of active.values()) controller.abort(); active.clear(); + for (const stream of eventStreams) stream.end(); + eventStreams.clear(); + await unsubscribeComposition(); + for (const stream of compositionStreams) stream.end(); + compositionStreams.clear(); + unbindInteractions(); + interactionBroker.cancelAll(); await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())) ); @@ -115,33 +405,155 @@ export async function startMossWebServer( options.abortSignal?.addEventListener('abort', () => void close().catch(() => {}), { once: true, }); - return { url: `http://${host}:${address.port}`, host, port: address.port, taskRuns, close }; + return { url: expectedOriginRef.value, host, port: address.port, taskRuns, close }; } async function handleRequest( agent: MossAgent, taskRuns: TaskRunLedger, + eventJournal: MossWebEventJournal, + sessions: MossWebSessionService, + interactions: MossWebInteractionBroker, + runtime: MossWebRuntimeService, + settings: MossWebSettingsService, + attachments: MossWebAttachmentService, active: Map, + eventStreams: Set, + pluginRegistry: InstalledPluginRegistry | undefined, + pluginConfigStore: MossPluginConfigStore | undefined, + webContributions: ActiveWebContributionState, + compositionStreams: Set, + expectedOrigin: string | undefined, + csrfToken: string, request: http.IncomingMessage, response: http.ServerResponse ): Promise { + if (!expectedOrigin || !isExpectedHost(request, expectedOrigin)) { + return sendJson(response, 403, { error: 'unexpected host denied' }); + } const url = new URL(request.url ?? '/', 'http://moss.local'); - if (request.method === 'GET' && url.pathname === '/') - return sendAsset(response, 'text/html', WEB_HTML); - if (request.method === 'GET' && url.pathname === '/styles.css') - return sendAsset(response, 'text/css', WEB_CSS); - if (request.method === 'GET' && url.pathname === '/app.js') - return sendAsset(response, 'text/javascript', WEB_JS); + const staticAsset = request.method === 'GET' ? resolveMossWebStaticAsset(url) : undefined; + if (staticAsset && 'client' in staticAsset) return sendClientAsset(response, staticAsset.client); + if (staticAsset) return sendAsset(response, staticAsset.type, staticAsset.body); + const pluginAssetMatch = url.pathname.match(/^\/plugin-assets\/([^/]+)\/([^/]+)\.js$/); + if (request.method === 'GET' && pluginAssetMatch) { + return sendPluginAsset( + response, + webContributions.current, + decodeURIComponent(pluginAssetMatch[1]), + decodeURIComponent(pluginAssetMatch[2]) + ); + } + if ( + await handleMossWebAttachmentRequest({ + request, + response, + url, + mutationAllowed: isAuthorizedMutation(request, expectedOrigin, csrfToken), + attachments, + sendJson, + }) + ) { + return; + } + if ( + await handleMossWebControlPlaneRequest({ + request, + response, + url, + mutationAllowed: isAuthorizedMutation(request, expectedOrigin, csrfToken), + interactions, + runtime, + settings, + readJson, + sendJson, + }) + ) { + return; + } if (request.method === 'GET' && url.pathname === '/api/bootstrap') { return sendJson(response, 200, { tools: agent.tools.getNames(), plugins: agent.plugins.inspect().plugins, + pluginComposition: agent.plugins.inspect(), taskRuns: taskRuns.list(), model: agent.config.model ?? 'Configured model', + csrfToken, }); } if (request.method === 'GET' && url.pathname === '/api/runs') return sendJson(response, 200, { runs: taskRuns.list() }); + if (request.method === 'GET' && url.pathname === '/api/runs/active') { + return sendJson(response, 200, { + runs: taskRuns.list().filter(({ status }) => status === 'created' || status === 'running'), + }); + } + if (request.method === 'GET' && url.pathname === '/api/workspaces') { + return sendJson(response, 200, { workspaces: await sessions.listWorkspaces() }); + } + if (request.method === 'GET' && url.pathname === '/api/sessions/search') { + return sendJson(response, 200, { + hits: await sessions.search(url.searchParams.get('q') ?? ''), + }); + } + if (request.method === 'GET' && url.pathname === '/api/sessions') + return sendJson(response, 200, { sessions: await sessions.listSessions() }); + if (request.method === 'GET' && url.pathname === '/api/plugins') { + const installed = ((await pluginRegistry?.list()) ?? []).map(({ id, version, enabled }) => ({ + id, + version, + enabled, + })); + return sendJson(response, 200, { + generation: agent.plugins.inspect().generation, + installed, + active: agent.plugins.inspect().plugins, + contributions: webContributions.current.map(({ source: _, ...contribution }) => contribution), + }); + } + if (request.method === 'GET' && url.pathname === '/api/plugins/events') { + return streamPluginComposition(request, response, compositionStreams, agent); + } + const messageMatch = url.pathname.match(/^\/api\/sessions\/([^/]+)\/messages$/); + if (request.method === 'GET' && messageMatch) { + const sessionId = decodeURIComponent(messageMatch[1]); + if (!(await agent.config.sessionStore.exists(sessionId))) { + return sendJson(response, 404, { error: 'session not found' }); + } + return sendJson(response, 200, { items: await sessionTimeline(agent, sessionId) }); + } + const sessionExportMatch = url.pathname.match(/^\/api\/sessions\/([^/]+)\/export$/); + if (request.method === 'GET' && sessionExportMatch) { + const sessionId = decodeURIComponent(sessionExportMatch[1]); + return sendMarkdown(response, sessionId, await sessions.exportMarkdown(sessionId)); + } + const activeRunMatch = url.pathname.match(/^\/api\/sessions\/([^/]+)\/active-run$/); + if (request.method === 'GET' && activeRunMatch) { + const sessionId = decodeURIComponent(activeRunMatch[1]); + const run = taskRuns + .list() + .find( + (candidate) => + candidate.sessionId === sessionId && + (candidate.status === 'created' || candidate.status === 'running') + ); + return run + ? sendJson(response, 200, { + run, + cursor: eventJournal.latestSequence(run.id), + eventsUrl: `/api/runs/${encodeURIComponent(run.id)}/events`, + }) + : sendJson(response, 404, { error: 'active run not found' }); + } + const runEventsMatch = url.pathname.match(/^\/api\/runs\/([^/]+)\/events$/); + if (request.method === 'GET' && runEventsMatch) { + const runId = decodeURIComponent(runEventsMatch[1]); + const run = taskRuns.get(runId); + if (!run) return sendJson(response, 404, { error: 'run not found' }); + const queryCursor = url.searchParams.get('after'); + const after = parseMossWebEventCursor(runId, queryCursor ?? request.headers['last-event-id']); + return streamRunEvents(request, response, eventStreams, eventJournal, run, after); + } const runMatch = url.pathname.match(/^\/api\/runs\/([^/]+)$/); if (request.method === 'GET' && runMatch) { const runId = decodeURIComponent(runMatch[1]); @@ -152,123 +564,211 @@ async function handleRequest( ? sendJson(response, 200, { run, events: taskRuns.events(runId, cursor) }) : sendJson(response, 404, { error: 'run not found' }); } - if (!isLocalOrigin(request)) return sendJson(response, 403, { error: 'non-local origin denied' }); + if (!isAuthorizedMutation(request, expectedOrigin, csrfToken)) { + return sendJson(response, 403, { error: 'mutation requires same-origin CSRF authorization' }); + } + if (request.method === 'POST' && url.pathname === '/api/plugins/add') { + if (!pluginRegistry) return sendJson(response, 501, { error: 'plugin registry unavailable' }); + const body = await readJson(request); + const source = typeof body.source === 'string' ? body.source.trim() : ''; + if (!source) return sendJson(response, 400, { error: 'plugin source is required' }); + const entry = await pluginRegistry.add(source); + const wasActive = agent.plugins + .inspect() + .plugins.some(({ id, state }) => id === entry.id && state === 'active'); + await activateWebPluginCandidate(agent, pluginRegistry, entry, wasActive); + return sendJson(response, 201, { + plugin: { id: entry.id, version: entry.version, enabled: entry.enabled }, + generation: agent.plugins.inspect().generation, + restartRequired: false, + }); + } + if (request.method === 'POST' && url.pathname === '/api/plugins/doctor') { + if (!pluginRegistry) return sendJson(response, 501, { error: 'plugin registry unavailable' }); + return sendJson(response, 200, { + results: await pluginRegistry.doctor(), + generation: agent.plugins.inspect().generation, + restartRequired: false, + }); + } + const pluginMatch = url.pathname.match(/^\/api\/plugins\/([^/]+)\/(enable|disable|remove)$/); + if (request.method === 'POST' && pluginMatch) { + if (!pluginRegistry) return sendJson(response, 501, { error: 'plugin registry unavailable' }); + const pluginId = decodeURIComponent(pluginMatch[1]); + const action = pluginMatch[2]; + if (action === 'enable') await enableWebPlugin(agent, pluginRegistry, pluginId); + if (action === 'disable') await disableWebPlugin(agent, pluginRegistry, pluginId); + if (action === 'remove') await removeWebPlugin(agent, pluginRegistry, pluginId); + webContributions.current = await snapshotActiveWebContributions(pluginRegistry, agent); + return sendJson(response, 200, { + pluginId, + action, + generation: agent.plugins.inspect().generation, + restartRequired: false, + }); + } + const pluginConfigMatch = url.pathname.match(/^\/api\/plugins\/([^/]+)\/config$/); + if (pluginConfigMatch && (request.method === 'GET' || request.method === 'PUT')) { + if (!pluginRegistry || !pluginConfigStore) { + return sendJson(response, 501, { error: 'plugin configuration unavailable' }); + } + const pluginId = decodeURIComponent(pluginConfigMatch[1]); + const entry = (await pluginRegistry.list()).find(({ id }) => id === pluginId); + if (!entry) return sendJson(response, 404, { error: 'plugin not found' }); + const schema = await installedPluginSchema(entry); + if (!schema) return sendJson(response, 404, { error: 'plugin has no config schema' }); + if (request.method === 'PUT') { + const body = await readJson(request); + const values = + body.values && typeof body.values === 'object' && !Array.isArray(body.values) + ? (body.values as Record) + : body; + await mutateWebPluginConfig(agent, pluginRegistry, pluginConfigStore, entry, schema, () => + pluginConfigStore.update(pluginId, schema, values) + ); + } + return sendJson(response, 200, { + schema, + config: await pluginConfigStore.getView(pluginId, schema), + generation: agent.plugins.inspect().generation, + restartRequired: false, + }); + } + const pluginSecretMatch = url.pathname.match( + /^\/api\/plugins\/([^/]+)\/config\/secrets\/([^/]+)$/ + ); + if (pluginSecretMatch && (request.method === 'PUT' || request.method === 'DELETE')) { + if (!pluginRegistry || !pluginConfigStore) { + return sendJson(response, 501, { error: 'plugin configuration unavailable' }); + } + const pluginId = decodeURIComponent(pluginSecretMatch[1]); + const name = decodeURIComponent(pluginSecretMatch[2]); + const entry = (await pluginRegistry.list()).find(({ id }) => id === pluginId); + if (!entry) { + return sendJson(response, 404, { error: 'plugin config not found' }); + } + const schema = await installedPluginSchema(entry); + if (!schema) return sendJson(response, 404, { error: 'plugin has no config schema' }); + if (request.method === 'PUT') { + const body = await readJson(request); + await mutateWebPluginConfig(agent, pluginRegistry, pluginConfigStore, entry, schema, () => + pluginConfigStore.putSecret(pluginId, schema, name, body.value) + ); + } else { + await mutateWebPluginConfig(agent, pluginRegistry, pluginConfigStore, entry, schema, () => + pluginConfigStore.deleteSecret(pluginId, schema, name) + ); + } + return sendJson(response, 200, { + config: await pluginConfigStore.getView(pluginId, schema), + generation: agent.plugins.inspect().generation, + restartRequired: false, + }); + } if (request.method === 'POST' && url.pathname === '/api/sessions') { - const sessionId = `web-${randomUUID()}`; - await agent.config.sessionStore.replaceMessages(sessionId, []); - return sendJson(response, 201, { sessionId }); + const body = await readJson(request); + const workspaceId = typeof body.workspaceId === 'string' ? body.workspaceId : 'current'; + return sendJson(response, 201, await sessions.create(workspaceId)); + } + const sessionMutationMatch = url.pathname.match(/^\/api\/sessions\/([^/]+)$/); + if (request.method === 'PATCH' && sessionMutationMatch) { + const sessionId = decodeURIComponent(sessionMutationMatch[1]); + const body = await readJson(request); + const title = typeof body.title === 'string' ? body.title : ''; + return sendJson(response, 200, { session: await sessions.rename(sessionId, title) }); + } + if (request.method === 'DELETE' && sessionMutationMatch) { + const sessionId = decodeURIComponent(sessionMutationMatch[1]); + if (active.has(sessionId)) { + return sendJson(response, 409, { error: 'cannot delete a session with an active turn' }); + } + const body = await readJson(request); + const confirmation = typeof body.confirmation === 'string' ? body.confirmation : ''; + await sessions.delete(sessionId, confirmation); + return sendJson(response, 200, { sessionId, deleted: true }); + } + const forkMatch = url.pathname.match(/^\/api\/sessions\/([^/]+)\/fork$/); + if (request.method === 'POST' && forkMatch) { + const sessionId = decodeURIComponent(forkMatch[1]); + return sendJson(response, 201, { session: await sessions.fork(sessionId) }); + } + const rewindMatch = url.pathname.match(/^\/api\/sessions\/([^/]+)\/rewind$/); + if (request.method === 'POST' && rewindMatch) { + const sessionId = decodeURIComponent(rewindMatch[1]); + const body = await readJson(request); + const messageCount = typeof body.messageCount === 'number' ? body.messageCount : Number.NaN; + return sendJson(response, 201, await sessions.rewind(sessionId, messageCount)); } - const messageMatch = url.pathname.match(/^\/api\/sessions\/([^/]+)\/messages$/); if (request.method === 'POST' && messageMatch) { const sessionId = decodeURIComponent(messageMatch[1]); + if (active.has(sessionId)) { + return sendJson(response, 409, { error: 'session already has an active turn' }); + } const body = await readJson(request); const prompt = typeof body.prompt === 'string' ? body.prompt.trim() : ''; if (!prompt) return sendJson(response, 400, { error: 'prompt is required' }); - let run = taskRuns - .list() - .find((candidate) => candidate.sessionId === sessionId && candidate.status === 'running'); - if (!run) { - const runId = `run-${randomUUID()}`; - run = taskRuns.create({ id: runId, sessionId, title: prompt.slice(0, 80) }); + const attachmentIds = Array.isArray(body.attachmentIds) + ? body.attachmentIds.filter((id): id is string => typeof id === 'string') + : []; + if ( + attachmentIds.length !== (Array.isArray(body.attachmentIds) ? body.attachmentIds.length : 0) + ) { + return sendJson(response, 400, { error: 'attachmentIds must contain only strings' }); } - return streamPrompt(agent, taskRuns, active, run.id, sessionId, prompt, response); + const promptAttachments = await attachments.resolveForPrompt(attachmentIds); + await sessions.titleFromPrompt(sessionId, prompt); + const runId = `run-${randomUUID()}`; + const run = taskRuns.create({ id: runId, sessionId, title: prompt.slice(0, 80) }); + return streamPrompt( + agent, + taskRuns, + eventJournal, + active, + run.id, + sessionId, + prompt, + promptAttachments, + response + ); } const cancelMatch = url.pathname.match(/^\/api\/sessions\/([^/]+)\/cancel$/); if (request.method === 'POST' && cancelMatch) { const sessionId = decodeURIComponent(cancelMatch[1]); const controller = active.get(sessionId); controller?.abort(); - active.delete(sessionId); const run = taskRuns.list().find((candidate) => candidate.sessionId === sessionId); - if (controller && run && (run.status === 'created' || run.status === 'running')) - taskRuns.append(run.id, { type: 'run.cancelled', data: { reason: 'user requested' } }); + if (controller && run && (run.status === 'created' || run.status === 'running')) { + taskRuns.append(run.id, { + type: 'run.cancelled', + data: { reason: 'user requested' }, + }); + } return sendJson(response, 200, { sessionId, cancelled: Boolean(controller) }); } sendJson(response, 404, { error: 'not found' }); } -async function streamPrompt( - agent: MossAgent, - taskRuns: TaskRunLedger, - active: Map, - runId: string, - sessionId: string, - prompt: string, - response: http.ServerResponse -): Promise { - active.get(sessionId)?.abort(); - const controller = new AbortController(); - active.set(sessionId, controller); +function streamPluginComposition( + request: http.IncomingMessage, + response: http.ServerResponse, + streams: Set, + agent: MossAgent +): void { response.writeHead(200, { - 'content-type': 'application/x-ndjson; charset=utf-8', + 'content-type': 'text/event-stream; charset=utf-8', 'cache-control': 'no-store', connection: 'keep-alive', + 'x-accel-buffering': 'no', 'x-content-type-options': 'nosniff', }); - const emit = (value: unknown) => response.write(`${JSON.stringify(value)}\n`); - let stopReason = 'end_turn'; - try { - const before = taskRuns.get(runId); - if (before?.status === 'created') { - const run = taskRuns.append(runId, { - type: 'run.started', - data: { promptLength: prompt.length }, - }); - emit({ type: 'run', run }); - } - for await (const event of agent.streamChat(sessionId, prompt, { - abortSignal: controller.signal, - })) { - if (event.type === 'text_delta') emit({ type: 'text', delta: event.delta }); - if (event.type === 'thinking_delta') emit({ type: 'thought', delta: event.delta }); - if (event.type === 'tool_start') { - taskRuns.append(runId, { - type: 'tool.started', - data: { toolCallId: event.toolCallId, name: event.toolName }, - }); - emit({ - type: 'tool', - state: 'start', - toolCallId: event.toolCallId, - name: event.toolName, - input: event.input, - }); - } - if (event.type === 'tool_end') { - taskRuns.append(runId, { - type: event.isError ? 'tool.failed' : 'tool.succeeded', - data: { - toolCallId: event.toolCallId, - name: event.toolName, - isError: event.isError, - }, - }); - emit({ - type: 'tool', - state: 'end', - toolCallId: event.toolCallId, - name: event.toolName, - result: event.result, - isError: event.isError, - }); - } - if (event.type === 'turn_end') stopReason = event.stopReason; - } - const current = taskRuns.get(runId); - const terminalType = - stopReason === 'end_turn' || stopReason === 'stop_sequence' ? 'run.completed' : 'run.failed'; - const run = - current?.status === 'running' - ? taskRuns.append(runId, { type: terminalType, data: { stopReason } }) - : current; - emit({ type: 'done', stopReason, run }); - } catch (error) { - const current = taskRuns.get(runId); - if (current?.status === 'running') - taskRuns.append(runId, { type: 'run.failed', data: { message: errorMessage(error) } }); - emit({ type: 'error', message: errorMessage(error) }); - } finally { - if (active.get(sessionId) === controller) active.delete(sessionId); - response.end(); - } + response.write(`event: composition\ndata: ${JSON.stringify(agent.plugins.inspect())}\n\n`); + streams.add(response); + const keepAlive = setInterval(() => response.write(': keep-alive\n\n'), 15_000); + keepAlive.unref(); + const cleanup = () => { + clearInterval(keepAlive); + streams.delete(response); + }; + request.once('aborted', cleanup); + response.once('close', cleanup); } diff --git a/packages/moss-agent/src/web-ui/web-session-service.ts b/packages/moss-agent/src/web-ui/web-session-service.ts new file mode 100644 index 00000000..9c99242d --- /dev/null +++ b/packages/moss-agent/src/web-ui/web-session-service.ts @@ -0,0 +1,274 @@ +import path from 'node:path'; +import { readFile } from 'node:fs/promises'; +import { randomUUID } from 'node:crypto'; + +import type { MossAgent } from '../core/agent/moss-agent.js'; +import type { TaskRunLedger } from '../core/task-run/task-run-ledger.js'; +import { renderSessionMarkdown, searchSessions } from '../cli/command-dispatcher.js'; +import { ErrorCode, MossError, wrapAsMoss } from '../errors.js'; +import { atomicWriteFile } from '../utils/atomic-write.js'; +import type { + MossWebSessionSearchHit, + MossWebSessionSummary, + MossWebWorkspaceSummary, +} from './web-contracts.js'; + +interface WebSessionMetadataFile { + readonly schemaVersion: 1; + readonly titles: Readonly>; +} + +interface MossWebSessionServiceOptions { + readonly metadataFile?: string; + readonly workspaceDir?: string; +} + +/** Host-owned Web session application service. @internal */ +export class MossWebSessionService { + private readonly titles = new Map(); + + private readonly ready: Promise; + + private mutationTail: Promise = Promise.resolve(); + + constructor( + private readonly agent: MossAgent, + private readonly taskRuns: TaskRunLedger, + private readonly options: MossWebSessionServiceOptions = {} + ) { + this.ready = this.loadMetadata(); + } + + async listWorkspaces(): Promise { + const workspaceDir = path.resolve( + this.options.workspaceDir ?? this.agent.config.workspaceDir ?? '.' + ); + return [ + { + id: 'current', + name: path.basename(workspaceDir) || workspaceDir, + current: true, + }, + ]; + } + + async listSessions(): Promise { + await this.ready; + const stored = await this.agent.config.sessionStore.listSessions(); + const runs = this.taskRuns.list(); + const bySession = new Map(); + for (const run of runs) { + if (!bySession.has(run.sessionId)) bySession.set(run.sessionId, run); + } + return stored + .map((session) => { + const run = bySession.get(session.sessionKey); + return { + sessionId: session.sessionKey, + title: + this.titles.get(session.sessionKey) ?? session.title ?? run?.title ?? 'Untitled task', + updatedAt: Math.max(session.updatedAt, run?.updatedAt ?? 0), + messageCount: session.messageCount, + ...(run ? { runId: run.id, runStatus: run.status } : {}), + }; + }) + .sort((left, right) => right.updatedAt - left.updatedAt); + } + + async search(query: string): Promise { + const needle = query.trim().toLowerCase(); + if (!needle) return []; + const [sessions, contentHits] = await Promise.all([ + this.listSessions(), + searchSessions(this.agent.config.sessionStore, query), + ]); + const bySession = new Map(); + for (const hit of contentHits) { + const summary = sessions.find(({ sessionId }) => sessionId === hit.key); + bySession.set(hit.key, { + sessionId: hit.key, + title: summary?.title ?? 'Untitled task', + updatedAt: hit.updatedAt, + messageCount: hit.messageCount, + snippet: hit.snippet, + }); + } + for (const session of sessions) { + if (!session.title.toLowerCase().includes(needle) || bySession.has(session.sessionId)) + continue; + bySession.set(session.sessionId, { ...session, snippet: session.title }); + } + return [...bySession.values()].sort((left, right) => right.updatedAt - left.updatedAt); + } + + async create(workspaceId = 'current'): Promise { + if (workspaceId !== 'current') this.invalid(`unknown workspace "${workspaceId}"`); + const sessionId = `web-${randomUUID()}`; + await this.agent.config.sessionStore.replaceMessages(sessionId, []); + try { + await this.mutateMetadata(() => this.titles.set(sessionId, 'New task')); + } catch (error) { + await this.agent.config.sessionStore.deleteSession(sessionId).catch(() => {}); + throw error; + } + return (await this.summary(sessionId))!; + } + + async rename(sessionId: string, title: string): Promise { + await this.assertExists(sessionId); + const normalized = title.replace(/\s+/g, ' ').trim(); + if (!normalized || normalized.length > 120) { + this.invalid('session title must contain 1 to 120 characters'); + } + await this.mutateMetadata(() => this.titles.set(sessionId, normalized)); + return (await this.summary(sessionId))!; + } + + async titleFromPrompt(sessionId: string, prompt: string): Promise { + await this.assertExists(sessionId); + const title = prompt.replace(/\s+/g, ' ').trim().slice(0, 80); + if (title) await this.mutateMetadata(() => this.titles.set(sessionId, title)); + } + + async exportMarkdown(sessionId: string): Promise { + await this.assertExists(sessionId); + const messages = await this.agent.config.sessionStore.loadMessages(sessionId); + return renderSessionMarkdown(sessionId, messages); + } + + async delete(sessionId: string, confirmation: string): Promise { + await this.assertExists(sessionId); + if (confirmation !== sessionId) { + this.invalid(`delete confirmation must exactly match session id "${sessionId}"`); + } + await this.ready; + const previousTitle = this.titles.get(sessionId); + await this.mutateMetadata(() => this.titles.delete(sessionId)); + try { + await this.agent.config.sessionStore.deleteSession(sessionId); + } catch (error) { + if (previousTitle) { + await this.mutateMetadata(() => this.titles.set(sessionId, previousTitle)).catch(() => {}); + } + throw wrapAsMoss(error, ErrorCode.SESSION_PERSIST_FAILED, { + message: `Failed to delete session "${sessionId}"`, + }); + } + } + + async fork(sessionId: string): Promise { + await this.assertExists(sessionId); + const messages = await this.agent.config.sessionStore.loadMessages(sessionId); + const forkId = `web-fork-${randomUUID()}`; + await this.agent.config.sessionStore.replaceMessages(forkId, messages); + const source = await this.summary(sessionId); + try { + await this.mutateMetadata(() => + this.titles.set(forkId, `${source?.title ?? 'Untitled task'} (fork)`.slice(0, 120)) + ); + } catch (error) { + await this.agent.config.sessionStore.deleteSession(forkId).catch(() => {}); + throw error; + } + return (await this.summary(forkId))!; + } + + async rewind( + sessionId: string, + messageCount: number + ): Promise<{ + readonly session: MossWebSessionSummary; + readonly sourceSessionId: string; + readonly truncated: number; + }> { + await this.assertExists(sessionId); + if (!Number.isSafeInteger(messageCount) || messageCount < 0) { + this.invalid('messageCount must be a non-negative integer'); + } + const messages = await this.agent.config.sessionStore.loadMessages(sessionId); + if (messageCount > messages.length) { + this.invalid(`messageCount cannot exceed the session length (${messages.length})`); + } + const rewindId = `web-rewind-${randomUUID()}`; + await this.agent.config.sessionStore.replaceMessages(rewindId, messages); + let result: { truncated: number }; + try { + result = await this.agent.rewindConversation(rewindId, messageCount); + const source = await this.summary(sessionId); + await this.mutateMetadata(() => + this.titles.set(rewindId, `${source?.title ?? 'Untitled task'} (rewind)`.slice(0, 120)) + ); + } catch (error) { + await this.agent.config.sessionStore.deleteSession(rewindId).catch(() => {}); + throw error; + } + return { + session: (await this.summary(rewindId))!, + sourceSessionId: sessionId, + truncated: result.truncated, + }; + } + + private async summary(sessionId: string): Promise { + return (await this.listSessions()).find((session) => session.sessionId === sessionId); + } + + private async assertExists(sessionId: string): Promise { + if (!(await this.agent.config.sessionStore.exists(sessionId))) { + this.invalid(`session "${sessionId}" was not found`); + } + } + + private async loadMetadata(): Promise { + if (!this.options.metadataFile) return; + try { + const parsed = JSON.parse(await readFile(this.options.metadataFile, 'utf8')) as unknown; + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return; + const candidate = parsed as Partial; + if ( + candidate.schemaVersion !== 1 || + !candidate.titles || + typeof candidate.titles !== 'object' + ) { + return; + } + for (const [sessionId, title] of Object.entries(candidate.titles)) { + if (typeof title === 'string' && title.trim()) this.titles.set(sessionId, title); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw wrapAsMoss(error, ErrorCode.CONFIG_IO_FAILED, { + message: 'Failed to load Web session metadata', + }); + } + } + } + + private async mutateMetadata(mutation: () => void): Promise { + await this.ready; + const operation = this.mutationTail.then(async () => { + const previous = new Map(this.titles); + try { + mutation(); + if (!this.options.metadataFile) return; + const body: WebSessionMetadataFile = { + schemaVersion: 1, + titles: Object.fromEntries(this.titles), + }; + await atomicWriteFile(this.options.metadataFile, `${JSON.stringify(body, null, 2)}\n`); + } catch (error) { + this.titles.clear(); + for (const [sessionId, title] of previous) this.titles.set(sessionId, title); + throw wrapAsMoss(error, ErrorCode.CONFIG_IO_FAILED, { + message: 'Failed to persist Web session metadata', + }); + } + }); + this.mutationTail = operation.catch(() => {}); + await operation; + } + + private invalid(message: string): never { + throw new MossError({ code: ErrorCode.USER_INPUT_INVALID, message }); + } +} diff --git a/packages/moss-agent/src/web-ui/web-settings-service.ts b/packages/moss-agent/src/web-ui/web-settings-service.ts new file mode 100644 index 00000000..c847130f --- /dev/null +++ b/packages/moss-agent/src/web-ui/web-settings-service.ts @@ -0,0 +1,349 @@ +import type { MossAgent } from '../core/agent/moss-agent.js'; +import type { InstalledPluginRegistry } from '../plugins/installed-plugin-registry.js'; +import type { CliServices } from '../cli/cli-services.js'; +import type { ConfigFile } from '../cli/config.js'; +import { ErrorCode, MossError, wrapAsMoss } from '../errors.js'; + +export type MossWebSettingsSection = + | 'general' + | 'models' + | 'permissions' + | 'skills' + | 'mcp' + | 'plugins' + | 'runtime'; + +export interface MossWebSettingsServiceOptions { + readonly configPath?: string; + readonly pluginRegistry?: InstalledPluginRegistry; +} + +interface ValidationResult { + readonly valid: boolean; + readonly dirty: boolean; + readonly errors: Readonly>; +} + +const EDITABLE_SECTIONS = new Set([ + 'general', + 'models', + 'permissions', + 'skills', + 'mcp', +]); + +/** Settings application service that reuses CLI config validation and persistence. @internal */ +export class MossWebSettingsService { + private readonly configPath: string; + + constructor( + private readonly agent: MossAgent, + private readonly services: Pick, + private readonly options: MossWebSettingsServiceOptions = {} + ) { + this.configPath = options.configPath ?? services.config.resolveConfigPath(); + } + + async snapshot() { + const sections = await Promise.all( + (['general', 'models', 'permissions', 'skills', 'mcp', 'plugins', 'runtime'] as const).map( + async (section) => [section, await this.section(section)] as const + ) + ); + return { sections: Object.fromEntries(sections) }; + } + + async section(section: MossWebSettingsSection) { + this.assertSection(section); + const config = this.load(); + if (section === 'general') { + return this.view(section, { + profile: config.profile, + workspace: config.workspace, + promptCache: config.promptCache, + agent: config.agent, + }); + } + if (section === 'models') { + return { + ...this.view(section, { + provider: config.provider, + model: config.model, + baseUrl: config.baseUrl, + }), + credentials: { apiKey: { configured: Boolean(config.apiKey) } }, + }; + } + if (section === 'permissions') { + return this.view(section, { + safetyMode: config.safetyMode, + approvalPolicy: config.approvalPolicy, + trustedTools: config.trustedTools ?? [], + deniedTools: config.deniedTools ?? [], + guardrails: config.guardrails, + }); + } + if (section === 'skills') { + return { + ...this.view(section, { ...(config.skills ?? {}) }), + inventory: + this.agent.config.skillRegistry?.list().map((skill) => ({ + id: skill.stableId ?? skill.name, + name: skill.name, + description: skill.description, + enabled: skill.enabled !== false, + })) ?? [], + diagnostics: this.agent.config.skillRegistry?.diagnostics() ?? [], + }; + } + if (section === 'mcp') { + return this.view(section, { ...(config.mcp ?? {}) }); + } + if (section === 'plugins') { + return { + ...this.view(section, {}), + installed: (await this.options.pluginRegistry?.list()) ?? [], + active: this.agent.plugins.inspect(), + }; + } + return { + ...this.view(section, {}), + inventory: { + model: this.agent.config.model, + workspace: this.agent.config.workspaceDir, + tools: this.agent.tools.getNames(), + asyncTasks: this.agent.asyncTasks.list(), + plugins: this.agent.plugins.inspect(), + }, + }; + } + + validate(section: MossWebSettingsSection, values: unknown): ValidationResult { + this.assertEditable(section); + const errors: Record = {}; + if (!values || typeof values !== 'object' || Array.isArray(values)) { + return { valid: false, dirty: false, errors: { values: 'expected an object' } }; + } + const draft = values as Record; + if (section === 'general') { + if ( + draft.profile !== undefined && + draft.profile !== null && + this.services.config.normalizeConfigProfile(String(draft.profile)) === null + ) { + errors.profile = 'profile must be cautious, balanced, or autonomous'; + } + if ( + draft.workspace !== undefined && + draft.workspace !== null && + typeof draft.workspace !== 'string' + ) { + errors.workspace = 'workspace must be a path string'; + } + } + if (section === 'models') { + for (const field of ['provider', 'model', 'baseUrl'] as const) { + if ( + draft[field] !== undefined && + draft[field] !== null && + typeof draft[field] !== 'string' + ) { + errors[field] = `${field} must be a string`; + } + } + if (typeof draft.baseUrl === 'string') { + try { + const protocol = new URL(draft.baseUrl).protocol; + if (protocol !== 'http:' && protocol !== 'https:') + errors.baseUrl = 'baseUrl must use http or https'; + } catch { + errors.baseUrl = 'baseUrl must be a valid URL'; + } + } + if ('apiKey' in draft) errors.apiKey = 'credentials use the write-only credential endpoint'; + } + if (section === 'permissions') { + if ( + draft.safetyMode !== undefined && + draft.safetyMode !== null && + this.services.config.normalizeSafetyModeConfig(String(draft.safetyMode)) === null + ) { + errors.safetyMode = 'safetyMode must be read-only, workspace-write, or full-access'; + } + if ( + draft.approvalPolicy !== undefined && + draft.approvalPolicy !== null && + this.services.config.normalizeApprovalPolicyConfig(String(draft.approvalPolicy)) === null + ) { + errors.approvalPolicy = 'approvalPolicy must be prompt or never'; + } + for (const field of ['trustedTools', 'deniedTools'] as const) { + if ( + draft[field] !== undefined && + draft[field] !== null && + (!Array.isArray(draft[field]) || draft[field].some((item) => typeof item !== 'string')) + ) { + errors[field] = `${field} must be an array of strings`; + } + } + } + if (section === 'skills') { + if ( + draft.extraRoots !== undefined && + draft.extraRoots !== null && + (!Array.isArray(draft.extraRoots) || + draft.extraRoots.some((item) => typeof item !== 'string')) + ) { + errors.extraRoots = 'extraRoots must be an array of path strings'; + } + } + if (section === 'mcp') { + if ( + draft.enabled !== undefined && + draft.enabled !== null && + typeof draft.enabled !== 'boolean' + ) { + errors.enabled = 'enabled must be a boolean'; + } + if ( + draft.configPath !== undefined && + draft.configPath !== null && + typeof draft.configPath !== 'string' + ) { + errors.configPath = 'configPath must be a path string'; + } + } + const current = this.sectionValues(section, this.load()); + return { + valid: Object.keys(errors).length === 0, + dirty: JSON.stringify(current) !== JSON.stringify({ ...current, ...draft }), + errors, + }; + } + + save(section: MossWebSettingsSection, values: unknown): ValidationResult { + const validation = this.validate(section, values); + if (!validation.valid) return validation; + const draft = values as Record; + const current = this.load(); + const next = this.applySection(section, current, draft); + this.persist(next); + return { valid: true, dirty: false, errors: {} }; + } + + async modelCatalog() { + const config = this.services.config.resolveCliConfig({}, this.load(), { + configPath: this.configPath, + }); + return this.services.models.loadModelChoicesForRuntime(config, config.model ?? ''); + } + + async selectModel(input: string) { + const catalog = await this.modelCatalog(); + const selected = this.services.models.resolveModelSelection(input, catalog.choices); + if (!selected) this.invalid(`unknown model "${input}"`); + const model = + (selected as { model?: string; id?: string }).model ?? (selected as { id?: string }).id; + if (!model) this.invalid('selected model has no model id'); + const config = this.load(); + this.persist({ ...config, model }); + return { model, provider: selected.provider, restartRequired: true }; + } + + writeCredential(name: 'apiKey', value: string): void { + if (name !== 'apiKey') this.invalid(`unknown credential "${name}"`); + const normalized = value.trim(); + if (!normalized || normalized.length > 16_384) { + this.invalid('credential must contain 1 to 16384 characters'); + } + this.persist({ ...this.load(), apiKey: normalized }); + } + + deleteCredential(name: 'apiKey'): void { + if (name !== 'apiKey') this.invalid(`unknown credential "${name}"`); + const { apiKey: _, _apiKeyEncrypted: __, ...rest } = this.load(); + this.persist(rest); + } + + private view(section: MossWebSettingsSection, values: Readonly>) { + return { + section, + values, + valid: true, + dirty: false, + errors: {}, + editable: EDITABLE_SECTIONS.has(section), + }; + } + + private sectionValues( + section: MossWebSettingsSection, + config: ConfigFile + ): Record { + if (section === 'general') + return { + profile: config.profile, + workspace: config.workspace, + promptCache: config.promptCache, + agent: config.agent, + }; + if (section === 'models') + return { provider: config.provider, model: config.model, baseUrl: config.baseUrl }; + if (section === 'permissions') + return { + safetyMode: config.safetyMode, + approvalPolicy: config.approvalPolicy, + trustedTools: config.trustedTools ?? [], + deniedTools: config.deniedTools ?? [], + guardrails: config.guardrails, + }; + if (section === 'skills') return { ...(config.skills ?? {}) }; + if (section === 'mcp') return { ...(config.mcp ?? {}) }; + return {}; + } + + private applySection( + section: MossWebSettingsSection, + config: ConfigFile, + draft: Record + ): ConfigFile { + const values = { ...this.sectionValues(section, config), ...draft }; + for (const [key, value] of Object.entries(values)) if (value === null) delete values[key]; + if (section === 'general') return { ...config, ...values } as ConfigFile; + if (section === 'models') return { ...config, ...values } as ConfigFile; + if (section === 'permissions') return { ...config, ...values } as ConfigFile; + if (section === 'skills') return { ...config, skills: values as ConfigFile['skills'] }; + return { ...config, mcp: values as ConfigFile['mcp'] }; + } + + private load(): ConfigFile { + return this.services.config.loadConfigFile(this.configPath); + } + + private persist(config: ConfigFile): void { + try { + this.services.config.saveConfigFileAtPath(config, this.configPath); + } catch (error) { + throw wrapAsMoss(error, ErrorCode.CONFIG_IO_FAILED, { + message: 'Failed to persist Web settings', + }); + } + } + + private assertSection(section: string): asserts section is MossWebSettingsSection { + if ( + !['general', 'models', 'permissions', 'skills', 'mcp', 'plugins', 'runtime'].includes(section) + ) { + this.invalid(`unknown settings section "${section}"`); + } + } + + private assertEditable(section: MossWebSettingsSection): void { + this.assertSection(section); + if (!EDITABLE_SECTIONS.has(section)) this.invalid(`settings section "${section}" is read-only`); + } + + private invalid(message: string): never { + throw new MossError({ code: ErrorCode.USER_INPUT_INVALID, message }); + } +} diff --git a/packages/moss-agent/src/web-ui/web-static-assets.ts b/packages/moss-agent/src/web-ui/web-static-assets.ts new file mode 100644 index 00000000..061a3c9d --- /dev/null +++ b/packages/moss-agent/src/web-ui/web-static-assets.ts @@ -0,0 +1,31 @@ +import { LEGACY_WEB_CSS, LEGACY_WEB_HTML, LEGACY_WEB_JS } from './legacy-web-assets.js'; +import { WEB_HTML } from './web-assets.js'; + +export type MossWebClientAssetName = + | 'moss-web-components.css' + | 'moss-web-components.js' + | 'workbench.css' + | 'workbench.js'; + +export type MossWebStaticAsset = + | { readonly type: string; readonly body: string } + | { readonly client: MossWebClientAssetName }; + +/** Resolve built-in current and one-release rollback assets without widening the HTTP router. @internal */ +export function resolveMossWebStaticAsset(url: URL): MossWebStaticAsset | undefined { + if (url.pathname === '/') { + return { + type: 'text/html', + body: url.searchParams.has('legacy') ? LEGACY_WEB_HTML : WEB_HTML, + }; + } + const inline: Record = { + '/assets/legacy-workbench.css': { type: 'text/css', body: LEGACY_WEB_CSS }, + '/assets/legacy-workbench.js': { type: 'text/javascript', body: LEGACY_WEB_JS }, + }; + if (inline[url.pathname]) return inline[url.pathname]; + const client = url.pathname.match( + /^\/assets\/(moss-web-components\.(?:css|js)|workbench\.(?:css|js))$/ + )?.[1] as MossWebClientAssetName | undefined; + return client ? { client } : undefined; +} diff --git a/packages/moss-agent/test/cli-args.spec.mjs b/packages/moss-agent/test/cli-args.spec.mjs index 9c1852a6..7b7e6839 100644 --- a/packages/moss-agent/test/cli-args.spec.mjs +++ b/packages/moss-agent/test/cli-args.spec.mjs @@ -41,7 +41,17 @@ import { parseCliArgs, closestKnownCommand } from '../dist/cli/args.js'; // ─── Commands ──────────────────────────────────────────────────────────────── -for (const cmd of ['setup', 'config', 'doctor', 'sessions', 'resume', 'update', 'mcp', 'web']) { +for (const cmd of [ + 'setup', + 'config', + 'doctor', + 'sessions', + 'resume', + 'update', + 'mcp', + 'plugins', + 'web', +]) { const args = parseCliArgs([cmd]); assert.equal(args.command, cmd, `'${cmd}' is recognized as a subcommand`); } diff --git a/packages/moss-agent/test/cli-command-dispatcher.spec.mjs b/packages/moss-agent/test/cli-command-dispatcher.spec.mjs index 6f85c683..3548e574 100644 --- a/packages/moss-agent/test/cli-command-dispatcher.spec.mjs +++ b/packages/moss-agent/test/cli-command-dispatcher.spec.mjs @@ -20,6 +20,7 @@ const expectedPhases = { auth: CliPhase.ConfigOnly, config: CliPhase.ConfigOnly, mcp: CliPhase.ConfigOnly, + plugins: CliPhase.ConfigOnly, doctor: CliPhase.ConfigOnly, update: CliPhase.ConfigOnly, migrate: CliPhase.ConfigOnly, diff --git a/packages/moss-agent/test/dsh-bundle-compatibility.spec.mjs b/packages/moss-agent/test/dsh-bundle-compatibility.spec.mjs new file mode 100644 index 00000000..0fb53162 --- /dev/null +++ b/packages/moss-agent/test/dsh-bundle-compatibility.spec.mjs @@ -0,0 +1,129 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { createMossPluginHost } from '../dist/core/plugins/plugin-host.js'; +import { + importDshPackage, + inspectDshPackageCompatibility, +} from '../dist/plugins/dsh-bundle-compatibility.js'; + +async function packageFixture(root, packageJson, patch) { + await mkdir(root, { recursive: true }); + await writeFile(path.join(root, 'package.json'), JSON.stringify(packageJson)); + await writeFile(path.join(root, 'cordis.patch.yml'), patch); +} + +test('a real DSH package shape imports only SKILL.md and explicit Moss adapter data', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'moss dsh package 配置 ')); + await packageFixture( + root, + { + name: '@fixture/dsh-protocol', + version: '1.0.0', + dependencies: { '@deepseek-ai/cordis': '1.0.0' }, + }, + 'services:\n protocol:\n enabled: true\n' + ); + await writeFile( + path.join(root, 'SKILL.md'), + '---\nname: dsh-protocol\n---\nCheck the protocol contract and cite evidence.\n' + ); + await writeFile( + path.join(root, 'moss.dsh-adapter.json'), + JSON.stringify({ + commands: [{ id: 'dsh-review', title: 'Review DSH', prompt: 'Review:\n\n{{args}}' }], + mcp: [ + { + id: 'dsh-tools', + name: 'DSH tools', + command: 'npx', + args: ['-y', '@fixture/dsh-tools@1.0.0'], + env: { MODE: 'readonly' }, + }, + ], + configSchema: { + type: 'object', + properties: { apiKey: { type: 'string', writeOnly: true } }, + }, + }) + ); + + const report = await inspectDshPackageCompatibility(root); + assert.equal(report.compatible, true); + assert.deepEqual(report.imported, [ + 'skill:fixture-dsh-protocol', + 'command:dsh-review', + 'mcp:dsh-tools', + 'config:schema', + ]); + assert.deepEqual(report.skipped, ['cordis.patch.yml runtime']); + + const imported = await importDshPackage(root); + assert.equal(imported.configSchema?.properties.apiKey.writeOnly, true); + const skills = new Map(); + const host = createMossPluginHost({ + hasTool: () => false, + registerTool: () => () => {}, + hasSkill: (id) => skills.has(id), + registerSkill: (skill) => { + skills.set(skill.stableId ?? skill.name, skill); + return () => skills.delete(skill.stableId ?? skill.name); + }, + hasExpert: () => false, + registerExpert: () => () => {}, + }); + try { + await host.install(imported.plugin); + assert.match(skills.get('fixture-dsh-protocol')?.body ?? '', /protocol contract/); + assert.equal(await host.expandCommand('dsh-review', 'provider'), 'Review:\n\nprovider'); + assert.deepEqual(host.getMcpPreset('dsh-tools')?.server.env, { MODE: 'readonly' }); + } finally { + await host.close(); + } +}); + +test('a real DSH Cordis client package is rejected instead of half-loaded', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'moss-dsh-ui-')); + await packageFixture( + root, + { + name: '@fixture/dsh-client-ui', + version: '1.0.0', + dependencies: { '@deepseek-ai/dsh-client': '1.0.0' }, + dsh: { client: './client.js' }, + }, + 'inject:\n - dsh.client.slot.conversation\n' + ); + await writeFile(path.join(root, 'SKILL.md'), 'This must not be half-loaded.\n'); + + const report = await inspectDshPackageCompatibility(root); + assert.equal(report.compatible, false); + assert.deepEqual(report.imported, []); + assert.match(report.reasons.join(' '), /unsupported Cordis client UI slots/); + await assert.rejects(importDshPackage(root), /unsupported Cordis client UI slots/); +}); + +test('DSH adapter MCP env values are validated one by one', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'moss-dsh-env-')); + await packageFixture(root, { name: 'dsh-invalid-env', version: '1.0.0' }, 'services: {}\n'); + await writeFile( + path.join(root, 'moss.dsh-adapter.json'), + JSON.stringify({ mcp: [{ id: 'bad', name: 'Bad', command: 'node', env: { PORT: 1234 } }] }) + ); + const report = await inspectDshPackageCompatibility(root); + assert.equal(report.compatible, false); + assert.match(report.reasons.join(' '), /env\.PORT must be a string/); +}); + +test('DSH package identity requires a Moss-safe id and exact semantic version', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'moss-dsh-identity-')); + await packageFixture(root, { name: '---', version: 'workspace:*' }, 'services: {}\n'); + await writeFile(path.join(root, 'SKILL.md'), 'Identity must fail before import.\n'); + const report = await inspectDshPackageCompatibility(root); + assert.equal(report.compatible, false); + assert.match(report.reasons.join(' '), /invalid plugin id|exact semantic version/); +}); diff --git a/packages/moss-agent/test/installed-plugin-registry.spec.mjs b/packages/moss-agent/test/installed-plugin-registry.spec.mjs new file mode 100644 index 00000000..5aea4b25 --- /dev/null +++ b/packages/moss-agent/test/installed-plugin-registry.spec.mjs @@ -0,0 +1,291 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { mkdtemp, mkdir, readFile, symlink, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { + InstalledPluginRegistry, + readMossPluginManifest, +} from '../dist/plugins/installed-plugin-registry.js'; + +const root = await mkdtemp(path.join(os.tmpdir(), 'moss-plugin-registry-')); +const configDir = path.join(root, 'config'); +const pluginDir = path.join(root, 'sample-plugin'); +await mkdir(pluginDir, { recursive: true }); +await writeFile( + path.join(pluginDir, 'moss.plugin.json'), + JSON.stringify({ + schemaVersion: 1, + id: 'sample/plugin', + version: '1.2.3', + runtime: { module: './plugin.mjs', export: 'plugin' }, + web: { contributions: [{ id: 'sample-settings', slot: 'settings.plugin', module: './ui.js' }] }, + configSchema: './config.schema.json', + }) +); +await writeFile( + path.join(pluginDir, 'plugin.mjs'), + "export const plugin = { id: 'sample/plugin', setup() {} };\n" +); +await writeFile(path.join(pluginDir, 'ui.js'), 'export default { mount() {} };\n'); +await writeFile(path.join(pluginDir, 'config.schema.json'), '{"type":"object"}\n'); + +const manifest = await readMossPluginManifest(pluginDir); +assert.equal(manifest.id, 'sample/plugin'); +assert.equal(manifest.web?.contributions[0]?.slot, 'settings.plugin'); + +const registry = new InstalledPluginRegistry({ configDir }); +const installed = await registry.add(pluginDir); +assert.equal(installed.id, 'sample/plugin'); +assert.equal(installed.enabled, false); +assert.equal((await registry.list()).length, 1); + +await registry.enable('sample/plugin'); + +const loaded = await registry.loadEnabled(); +assert.equal(loaded.plugins.length, 1); +assert.equal(loaded.plugins[0]?.id, 'sample/plugin'); +assert.deepEqual(loaded.failures, []); +await loaded.plugins[0]?.disposeCandidate?.(); + +const report = await registry.doctor(); +assert.equal(report[0]?.status, 'ok'); +assert.ok(report[0]?.message.includes('1.2.3')); + +const persisted = JSON.parse( + await readFile(path.join(configDir, 'plugins', 'installed.json'), 'utf8') +); +assert.equal(persisted.schemaVersion, 1); +assert.equal(persisted.plugins[0]?.source, pluginDir); + +await registry.remove('sample/plugin'); +assert.deepEqual(await registry.list(), []); + +await assert.rejects( + () => readMossPluginManifest(path.join(root, 'missing')), + /moss\.plugin\.json/ +); +await assert.rejects(() => registry.add('example-plugin'), /npm plugins require an exact version/); + +const duplicateWebDir = path.join(root, 'duplicate-web-plugin'); +await mkdir(duplicateWebDir, { recursive: true }); +await writeFile( + path.join(duplicateWebDir, 'moss.plugin.json'), + JSON.stringify({ + schemaVersion: 1, + id: 'sample/duplicate-web', + version: '1.0.0', + runtime: { module: './plugin.mjs' }, + web: { + contributions: [ + { id: 'duplicate', slot: 'settings.plugin', module: './ui.js' }, + { id: 'duplicate', slot: 'conversation.header', module: './ui.js' }, + ], + }, + }) +); +await writeFile( + path.join(duplicateWebDir, 'plugin.mjs'), + "export default { id: 'sample/duplicate-web', setup() {} };\n" +); +await writeFile(path.join(duplicateWebDir, 'ui.js'), 'export default { mount() {} };\n'); +await assert.rejects( + () => readMossPluginManifest(duplicateWebDir), + /duplicate web contribution id/ +); + +const throwingPluginDir = path.join(root, 'throwing-plugin'); +await mkdir(throwingPluginDir, { recursive: true }); +await writeFile( + path.join(throwingPluginDir, 'moss.plugin.json'), + JSON.stringify({ + schemaVersion: 1, + id: 'sample/throwing', + version: '1.0.0', + runtime: { module: './plugin.mjs' }, + }) +); +await writeFile( + path.join(throwingPluginDir, 'plugin.mjs'), + "export default { id: 'sample/throwing', setup() { throw new Error('SETUP_FAILED'); } };\n" +); +await registry.add(throwingPluginDir); +const throwingReport = await registry.doctor(); +assert.equal(throwingReport[0]?.status, 'disabled'); +await registry.enable('sample/throwing'); +const throwingLoad = await registry.loadEnabled(); +assert.deepEqual(throwingLoad.plugins, []); +assert.match(throwingLoad.failures[0]?.message ?? '', /SETUP_FAILED/); + +const concurrentConfigDir = path.join(root, 'concurrent-config'); +const concurrentPluginDirs = []; +for (const suffix of ['one', 'two']) { + const directory = path.join(root, `concurrent-${suffix}`); + await mkdir(directory, { recursive: true }); + await writeFile( + path.join(directory, 'moss.plugin.json'), + JSON.stringify({ + schemaVersion: 1, + id: `concurrent/${suffix}`, + version: '1.0.0', + runtime: { module: './plugin.mjs' }, + }) + ); + await writeFile( + path.join(directory, 'plugin.mjs'), + `export default { id: 'concurrent/${suffix}', setup() {} };\n` + ); + concurrentPluginDirs.push(directory); +} +const workerPath = path.join(root, 'add-plugin-worker.mjs'); +await writeFile( + workerPath, + `import { InstalledPluginRegistry } from ${JSON.stringify(new URL('../dist/plugins/installed-plugin-registry.js', import.meta.url).href)}; +await new InstalledPluginRegistry({ configDir: process.argv[2] }).add(process.argv[3]);\n` +); +const runWorker = (pluginRoot) => + new Promise((resolve, reject) => { + const child = spawn(process.execPath, [workerPath, concurrentConfigDir, pluginRoot], { + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stderr = ''; + child.stderr.on('data', (chunk) => { + stderr += String(chunk); + }); + child.once('error', reject); + child.once('exit', (code) => + code === 0 ? resolve() : reject(new Error(`plugin worker exited ${code}: ${stderr}`)) + ); + }); +await Promise.all([runWorker(concurrentPluginDirs[0]), runWorker(concurrentPluginDirs[1])]); +assert.deepEqual( + (await new InstalledPluginRegistry({ configDir: concurrentConfigDir }).list()).map( + ({ id }) => id + ), + ['concurrent/one', 'concurrent/two'] +); + +const hangingPluginDir = path.join(root, 'hanging-plugin'); +await mkdir(hangingPluginDir, { recursive: true }); +await writeFile( + path.join(hangingPluginDir, 'moss.plugin.json'), + JSON.stringify({ + schemaVersion: 1, + id: 'sample/hanging', + version: '1.0.0', + runtime: { module: './plugin.mjs' }, + }) +); +await writeFile( + path.join(hangingPluginDir, 'plugin.mjs'), + `export default { + id: 'sample/hanging', + setup() { + setInterval(() => {}, 1000); + return new Promise(() => {}); + } +}; +` +); +const hangingConfigDir = path.join(root, 'hanging-config'); +const hangingRegistry = new InstalledPluginRegistry({ configDir: hangingConfigDir }); +await hangingRegistry.add(hangingPluginDir); +await hangingRegistry.enable('sample/hanging'); +const hangingLoad = await hangingRegistry.loadEnabled(); +assert.match(hangingLoad.failures[0]?.message ?? '', /timed out after 15 seconds/); +assert.equal((await hangingRegistry.list())[0]?.enabled, true); + +const escapedPluginDir = path.join(root, 'escaped-plugin'); +const outsideRuntimeDir = path.join(root, 'outside-runtime'); +await mkdir(escapedPluginDir, { recursive: true }); +await mkdir(outsideRuntimeDir, { recursive: true }); +await writeFile( + path.join(outsideRuntimeDir, 'plugin.mjs'), + "export default { id: 'sample/escaped', setup() {} };\n" +); +await symlink( + outsideRuntimeDir, + path.join(escapedPluginDir, 'linked-runtime'), + process.platform === 'win32' ? 'junction' : 'dir' +); +await writeFile( + path.join(escapedPluginDir, 'moss.plugin.json'), + JSON.stringify({ + schemaVersion: 1, + id: 'sample/escaped', + version: '1.0.0', + runtime: { module: './linked-runtime/plugin.mjs' }, + }) +); +await assert.rejects(() => readMossPluginManifest(escapedPluginDir), /escapes plugin root/); + +const invalidVersionDir = path.join(root, 'invalid-version-plugin'); +await mkdir(invalidVersionDir, { recursive: true }); +await writeFile( + path.join(invalidVersionDir, 'plugin.mjs'), + "export default { id: 'sample/invalid-version', setup() {} };\n" +); +for (const version of ['01.2.3', '1.2.3-.']) { + await writeFile( + path.join(invalidVersionDir, 'moss.plugin.json'), + JSON.stringify({ + schemaVersion: 1, + id: 'sample/invalid-version', + version, + runtime: { module: './plugin.mjs' }, + }) + ); + await assert.rejects(() => readMossPluginManifest(invalidVersionDir), /semantic version/); +} + +const mutablePluginDir = path.join(root, 'mutable-plugin'); +const mutableConfigDir = path.join(root, 'mutable-config'); +await mkdir(mutablePluginDir, { recursive: true }); +await writeFile( + path.join(mutablePluginDir, 'moss.plugin.json'), + JSON.stringify({ + schemaVersion: 1, + id: 'mutable/original', + version: '1.0.0', + runtime: { module: './plugin.mjs' }, + }) +); +await writeFile( + path.join(mutablePluginDir, 'plugin.mjs'), + "export default { id: 'mutable/original', setup() {} };\n" +); +const mutableRegistry = new InstalledPluginRegistry({ configDir: mutableConfigDir }); +await mutableRegistry.add(mutablePluginDir); +await writeFile( + path.join(mutablePluginDir, 'moss.plugin.json'), + JSON.stringify({ + schemaVersion: 1, + id: 'mutable/replaced', + version: '2.0.0', + runtime: { module: './plugin.mjs' }, + }) +); +await assert.rejects( + mutableRegistry.enable('mutable/original'), + /installed plugin identity changed/ +); +await assert.rejects( + mutableRegistry.loadInstalled('mutable/original'), + /installed plugin identity changed/ +); +assert.equal((await mutableRegistry.list())[0]?.enabled, false); + +const corruptConfigDir = path.join(root, 'corrupt-config'); +await mkdir(path.join(corruptConfigDir, 'plugins'), { recursive: true }); +await writeFile( + path.join(corruptConfigDir, 'plugins', 'installed.json'), + JSON.stringify({ schemaVersion: 1, plugins: [null] }) +); +await assert.rejects( + () => new InstalledPluginRegistry({ configDir: corruptConfigDir }).list(), + /unable to read/ +); + +console.log(' [PASS] installed plugin registry: manifest, lifecycle, isolation'); diff --git a/packages/moss-agent/test/official-deepseek-plugin.spec.mjs b/packages/moss-agent/test/official-deepseek-plugin.spec.mjs new file mode 100644 index 00000000..4b8b8e6c --- /dev/null +++ b/packages/moss-agent/test/official-deepseek-plugin.spec.mjs @@ -0,0 +1,51 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { mkdtemp } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { createMossPluginHost } from '../dist/core/plugins/plugin-host.js'; +import { InstalledPluginRegistry } from '../dist/plugins/installed-plugin-registry.js'; +import { SkillRegistry } from '../dist/skills/registry.js'; + +const configDir = await mkdtemp(path.join(os.tmpdir(), 'moss-official-plugin-')); +const workspaceDir = await mkdtemp(path.join(os.tmpdir(), 'moss-official-workspace-')); +const installed = new InstalledPluginRegistry({ configDir }); +const entry = await installed.add('official:deepseek-harness'); +assert.equal(entry.id, 'deepseek/harness'); +assert.equal(entry.enabled, false); + +await installed.enable(entry.id); +const loaded = await installed.loadEnabled(); +assert.deepEqual(loaded.failures, []); +assert.equal(loaded.plugins.length, 1); + +const skills = new SkillRegistry({ workspaceDir, includeBuiltin: false }); +const host = createMossPluginHost({ + hasTool: () => false, + registerTool: () => () => {}, + hasSkill: (id) => skills.hasStableId(id), + registerSkill: (skill) => skills.registerInline(skill), + hasExpert: () => false, + registerExpert: () => () => {}, +}); +await host.install(loaded.plugins[0]); +const skill = skills.list().find(({ stableId }) => stableId === 'deepseek-harness'); +assert.ok(skill); +assert.match(skill.body ?? '', /reasoning_content/); +assert.match(skill.body ?? '', /parallel tool-call deltas/); +assert.match( + await host.getCommand('deepseek-protocol')?.expand('Review this provider'), + /Review this provider/ +); +assert.deepEqual(host.getMcpPreset('deepseek-harness')?.server, { + command: 'npx', + args: ['-y', '@deepseek-harness/mcp@0.2.0'], + requestTimeoutMs: 30_000, +}); +await host.close(); +assert.equal(skills.hasStableId('deepseek-harness'), false); +assert.equal(host.getCommand('deepseek-protocol'), undefined); +assert.equal(host.getMcpPreset('deepseek-harness'), undefined); + +console.log(' [PASS] official DeepSeek Harness plugin owns its skill, command, and MCP preset'); diff --git a/packages/moss-agent/test/plugin-composition-generation.spec.mjs b/packages/moss-agent/test/plugin-composition-generation.spec.mjs new file mode 100644 index 00000000..c6c7cb2b --- /dev/null +++ b/packages/moss-agent/test/plugin-composition-generation.spec.mjs @@ -0,0 +1,297 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { MossAgent } from '../dist/core/agent/moss-agent.js'; +import { InMemorySessionStore } from '../dist/core/session/session.js'; + +function makeAgent(id) { + return new MossAgent({ + llmProvider: { + id, + displayName: id, + capabilities: { streaming: false }, + async complete() { + return { stopReason: 'end_turn', content: [] }; + }, + async stream() { + throw new Error('streaming disabled'); + }, + }, + sessionStore: new InMemorySessionStore(), + domainPrompt: false, + includeLanguagePolicyPrompt: false, + includeAgentBehaviorPrompt: false, + }); +} + +function contributionPlugin(id = 'fixture/composition') { + return { + id, + setup(context) { + context.registerCommand({ + id: 'fixture-command', + title: 'Fixture command', + expand: (args) => `Fixture: ${args}`, + }); + context.registerProvider({ + id: 'fixture-provider', + displayName: 'Fixture provider', + async create() { + return { + id: 'fixture-provider-runtime', + displayName: 'Fixture provider runtime', + capabilities: { streaming: false }, + async complete() { + return { stopReason: 'end_turn', content: [] }; + }, + async stream() { + throw new Error('streaming disabled'); + }, + }; + }, + }); + context.registerMcpPreset({ + id: 'fixture-mcp', + displayName: 'Fixture MCP', + server: { command: 'node', args: ['fixture-server.mjs'] }, + }); + }, + }; +} + +test('command, provider, and MCP contributions are owned, redacted, and instance-local', async () => { + const first = makeAgent('first-instance'); + const second = makeAgent('second-instance'); + try { + const before = first.plugins.inspect().generation; + const handle = await first.plugins.install(contributionPlugin()); + const snapshot = first.plugins.inspect(); + const plugin = snapshot.plugins.find(({ id }) => id === 'fixture/composition'); + + assert.equal(snapshot.generation, before + 1); + assert.deepEqual(plugin?.commands, ['fixture-command']); + assert.deepEqual(plugin?.providers, ['fixture-provider']); + assert.deepEqual(plugin?.mcpPresets, ['fixture-mcp']); + assert.doesNotMatch(JSON.stringify(snapshot), /Fixture command|fixture-server/); + assert.deepEqual( + first.plugins.listCommands().map(({ id }) => id), + ['fixture-command'] + ); + assert.equal(await first.plugins.expandCommand('fixture-command', 'ok'), 'Fixture: ok'); + assert.equal(first.plugins.getProvider('fixture-provider')?.displayName, 'Fixture provider'); + const runtimeProvider = await first.plugins.createProvider('fixture-provider', {}); + assert.equal(runtimeProvider?.id, 'fixture-provider-runtime'); + assert.deepEqual( + await runtimeProvider?.complete({ model: 'fixture', systemPrompt: '', messages: [] }), + { + stopReason: 'end_turn', + content: [], + } + ); + assert.equal(first.plugins.getMcpPreset('fixture-mcp')?.server.command, 'node'); + assert.deepEqual( + first.plugins.listMcpPresets().map(({ id }) => id), + ['fixture-mcp'] + ); + let mcpDisposed = false; + assert.equal( + await first.plugins.activateMcpPreset('fixture-mcp', async () => ({ + value: 'MCP_ACTIVE', + dispose: () => { + mcpDisposed = true; + }, + })), + 'MCP_ACTIVE' + ); + assert.equal(second.plugins.getCommand('fixture-command'), undefined); + + await second.plugins.install(contributionPlugin()); + await assert.rejects( + first.plugins.install(contributionPlugin('fixture/conflict')), + (error) => + error instanceof Error && + error.cause instanceof Error && + /plugin command already registered: fixture-command/.test(error.cause.message) + ); + assert.equal(first.plugins.inspect().generation, snapshot.generation); + assert.equal(first.plugins.getCommand('fixture-command')?.expand('still'), 'Fixture: still'); + + await assert.rejects( + first.plugins.install({ + id: 'fixture/rollback', + setup(context) { + context.registerCommand({ + id: 'rollback-command', + title: 'Rollback command', + expand: () => 'must disappear', + }); + context.registerProvider({ + id: 'rollback-provider', + displayName: 'Rollback provider', + create: async () => { + throw new Error('must not be created'); + }, + }); + context.registerMcpPreset({ + id: 'rollback-mcp', + displayName: 'Rollback MCP', + server: { command: 'node' }, + }); + context.effect(async () => { + throw new Error('ROLLBACK_SENTINEL'); + }); + }, + }), + /failed to install plugin fixture\/rollback/ + ); + assert.equal(first.plugins.inspect().generation, snapshot.generation); + assert.equal(first.plugins.getCommand('rollback-command'), undefined); + assert.equal(first.plugins.getProvider('rollback-provider'), undefined); + assert.equal(first.plugins.getMcpPreset('rollback-mcp'), undefined); + + await handle.dispose(); + assert.equal(mcpDisposed, true); + assert.equal(first.plugins.inspect().generation, snapshot.generation + 1); + assert.equal(first.plugins.getCommand('fixture-command'), undefined); + assert.equal(first.plugins.getProvider('fixture-provider'), undefined); + assert.equal(first.plugins.getMcpPreset('fixture-mcp'), undefined); + assert.equal(second.plugins.getCommand('fixture-command')?.expand('ok'), 'Fixture: ok'); + } finally { + await Promise.allSettled([first.close(), second.close()]); + } +}); + +test('active plugin tool leases drain before unload and timeout restores the last-good generation', async () => { + const agent = makeAgent('lease-instance'); + let release; + let shouldBlock = true; + const gate = new Promise((resolve) => { + release = resolve; + }); + try { + await agent.plugins.install({ + id: 'fixture/leased', + setup(context) { + context.registerTool({ + name: 'leased_fixture', + description: 'Hold one plugin call open.', + metadata: { sideEffectClass: 'readonly', planMode: 'allow' }, + inputSchema: { type: 'object', properties: {} }, + async execute() { + if (shouldBlock) await gate; + return 'LEASED_OK'; + }, + }); + }, + }); + const activeGeneration = agent.plugins.inspect().generation; + assert.equal( + agent.plugins.inspect().plugins.find(({ id }) => id === 'fixture/leased')?.callState, + 'accepting' + ); + const tool = agent.tools.get('leased_fixture'); + assert.ok(tool); + const activeCall = tool.execute({}, { sessionKey: 'lease', workspaceDir: process.cwd() }); + + await assert.rejects( + agent.plugins.unload('fixture/leased', { timeoutMs: 20 }), + /did not become quiescent/ + ); + assert.equal(agent.plugins.inspect().generation, activeGeneration); + assert.equal( + agent.plugins.inspect().plugins.find(({ id }) => id === 'fixture/leased')?.state, + 'active' + ); + assert.equal(agent.tools.get('leased_fixture'), tool); + + shouldBlock = false; + assert.equal( + await tool.execute({}, { sessionKey: 'lease-after-timeout', workspaceDir: process.cwd() }), + 'LEASED_OK' + ); + + const unloading = agent.plugins.unload('fixture/leased', { timeoutMs: 1_000 }); + assert.equal( + agent.plugins.inspect().plugins.find(({ id }) => id === 'fixture/leased')?.callState, + 'draining' + ); + await assert.rejects( + tool.execute({}, { sessionKey: 'lease-draining', workspaceDir: process.cwd() }), + /plugin is draining: fixture\/leased/ + ); + release(); + assert.equal(await activeCall, 'LEASED_OK'); + await unloading; + assert.equal(agent.tools.get('leased_fixture'), undefined); + assert.equal(agent.plugins.inspect().generation, activeGeneration + 1); + } finally { + release?.(); + await agent.close(); + } +}); + +test('plugin command leases reject new expansion while an earlier expansion drains', async () => { + const agent = makeAgent('command-lease-instance'); + let release; + const gate = new Promise((resolve) => { + release = resolve; + }); + try { + await agent.plugins.install({ + id: 'fixture/leased-command', + setup(context) { + context.registerCommand({ + id: 'leased-command', + title: 'Leased command', + async expand(args) { + await gate; + return `expanded:${args}`; + }, + }); + }, + }); + const activeCall = agent.plugins.expandCommand('leased-command', 'first'); + const unloading = agent.plugins.unload('fixture/leased-command', { timeoutMs: 1_000 }); + await assert.rejects( + agent.plugins.expandCommand('leased-command', 'second'), + /plugin is draining: fixture\/leased-command/ + ); + release(); + assert.equal(await activeCall, 'expanded:first'); + await unloading; + assert.equal(agent.plugins.getCommand('leased-command'), undefined); + } finally { + release?.(); + await agent.close(); + } +}); + +test('cancelling a loading plugin does not advance the last-good generation', async () => { + const agent = makeAgent('cancel-loading-instance'); + let releaseSetup; + const setupGate = new Promise((resolve) => { + releaseSetup = resolve; + }); + const before = agent.plugins.inspect().generation; + const installing = agent.plugins.install({ + id: 'fixture/loading', + async setup(context) { + await setupGate; + context.registerTool({ + name: 'never_activated_fixture', + description: 'Must not create a composition generation.', + metadata: { sideEffectClass: 'readonly', planMode: 'allow' }, + inputSchema: { type: 'object', properties: {} }, + async execute() { + return 'never'; + }, + }); + }, + }); + const closing = agent.close(); + await closing; + releaseSetup(); + await assert.rejects(installing, /plugin installation was cancelled/); + assert.equal(agent.plugins.inspect().generation, before); +}); diff --git a/packages/moss-agent/test/plugin-config-store.spec.mjs b/packages/moss-agent/test/plugin-config-store.spec.mjs new file mode 100644 index 00000000..8bc91c76 --- /dev/null +++ b/packages/moss-agent/test/plugin-config-store.spec.mjs @@ -0,0 +1,122 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { chmod, mkdir, mkdtemp, readFile, stat, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +test('plugin config validates its schema and redacts writeOnly values in a Windows-safe path', async () => { + const runtime = await import('../dist/runtime/index.js'); + assert.equal(typeof runtime.MossPluginConfigStore, 'function'); + assert.equal(typeof runtime.readMossPluginConfigSchema, 'function'); + + const root = await mkdtemp(path.join(os.tmpdir(), 'moss plugin 配置 ')); + const pluginRoot = path.join(root, 'plugin root with spaces'); + const configDir = path.join(root, 'config root with spaces'); + await mkdir(pluginRoot, { recursive: true }); + await writeFile( + path.join(pluginRoot, 'moss.plugin.json'), + JSON.stringify({ + schemaVersion: 1, + id: 'fixture/config', + version: '1.0.0', + runtime: { module: './plugin.mjs' }, + configSchema: './config.schema.json', + }) + ); + await writeFile( + path.join(pluginRoot, 'plugin.mjs'), + "export default { id: 'fixture/config', setup() {} };\n" + ); + await writeFile( + path.join(pluginRoot, 'config.schema.json'), + JSON.stringify({ + type: 'object', + additionalProperties: false, + required: ['endpoint', 'apiKey'], + properties: { + endpoint: { type: 'string', format: 'uri' }, + retries: { type: 'integer', minimum: 0, maximum: 5 }, + apiKey: { type: 'string', minLength: 8, writeOnly: true }, + }, + }) + ); + + const schema = await runtime.readMossPluginConfigSchema(pluginRoot); + assert.equal(schema?.properties.apiKey.writeOnly, true); + const store = new runtime.MossPluginConfigStore({ configDir }); + + await store.update('fixture/config', schema, { + endpoint: 'https://example.invalid/api', + retries: 2, + }); + await assert.rejects( + store.update('fixture/config', schema, { retries: 8 }), + /Number must be <= 5/ + ); + await assert.rejects( + store.update('fixture/config', schema, { apiKey: 'must-use-secret-api' }), + /writeOnly/ + ); + await store.putSecret('fixture/config', schema, 'apiKey', 'secret-value'); + + const view = await store.getView('fixture/config', schema); + assert.deepEqual(view.values, { + endpoint: 'https://example.invalid/api', + retries: 2, + }); + assert.deepEqual(view.secrets, { apiKey: { configured: true } }); + assert.doesNotMatch(JSON.stringify(view), /secret-value/); + assert.equal((await store.loadRuntimeConfig('fixture/config', schema)).apiKey, 'secret-value'); + + const storedPath = path.join( + configDir, + 'plugins', + 'config', + `${encodeURIComponent('fixture/config')}.json` + ); + assert.equal(path.dirname(storedPath), path.join(configDir, 'plugins', 'config')); + assert.equal(path.basename(storedPath).includes('/'), false); + if (process.platform !== 'win32') { + assert.equal((await stat(storedPath)).mode & 0o777, 0o600); + } + + await store.deleteSecret('fixture/config', schema, 'apiKey'); + assert.deepEqual((await store.getView('fixture/config', schema)).secrets, { + apiKey: { configured: false }, + }); + await assert.rejects( + store.loadRuntimeConfig('fixture/config', schema), + /Missing required property: "apiKey"/ + ); + + await chmod(storedPath, 0o600).catch(() => {}); + const persisted = await readFile(storedPath, 'utf8'); + assert.doesNotMatch(persisted, /secret-value/); +}); + +test('plugin config schema rejects unsupported keywords before any config is written', async () => { + const runtime = await import('../dist/runtime/index.js'); + const root = await mkdtemp(path.join(os.tmpdir(), 'moss-plugin-invalid-schema-')); + await writeFile( + path.join(root, 'moss.plugin.json'), + JSON.stringify({ + schemaVersion: 1, + id: 'fixture/invalid-config', + version: '1.0.0', + runtime: { module: './plugin.mjs' }, + configSchema: './config.schema.json', + }) + ); + await writeFile( + path.join(root, 'plugin.mjs'), + "export default { id: 'fixture/invalid-config', setup() {} };\n" + ); + await writeFile( + path.join(root, 'config.schema.json'), + JSON.stringify({ type: 'object', unevaluatedProperties: false }) + ); + const registry = new runtime.InstalledPluginRegistry({ configDir: path.join(root, 'config') }); + await assert.rejects(registry.add(root), /unsupported schema keyword/); + await assert.rejects(runtime.readMossPluginConfigSchema(root), /unsupported schema keyword/); +}); diff --git a/packages/moss-agent/test/plugin-interactive-commands.spec.mjs b/packages/moss-agent/test/plugin-interactive-commands.spec.mjs new file mode 100644 index 00000000..cd159aad --- /dev/null +++ b/packages/moss-agent/test/plugin-interactive-commands.spec.mjs @@ -0,0 +1,44 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { dispatchPlugin, pluginCommandRows } from '../dist/cli/plugin-interactive-commands.js'; + +function fixture() { + const calls = []; + return { + calls, + listCommands: () => [ + { id: 'review-pack', title: 'Review pack', description: 'Run the plugin review' }, + { id: 'status', title: 'Must not shadow built-in' }, + ], + async expandCommand(id, args) { + calls.push([id, args]); + return id === 'review-pack' ? `Review ${args}` : undefined; + }, + }; +} + +test('plugin command rows exclude reserved built-ins', () => { + assert.deepEqual(pluginCommandRows(fixture()), [['/review-pack', 'Run the plugin review']]); +}); + +test('plugin command dispatch preserves the complete argument string', async () => { + const plugins = fixture(); + const prompts = []; + assert.equal( + await dispatchPlugin('/review-pack src --strict', plugins, (value) => prompts.push(value)), + true + ); + assert.deepEqual(plugins.calls, [['review-pack', 'src --strict']]); + assert.deepEqual(prompts, ['Review src --strict']); +}); + +test('plugin command dispatch never shadows a built-in', async () => { + const plugins = fixture(); + assert.equal( + await dispatchPlugin('/status', plugins, () => assert.fail('must not submit')), + false + ); + assert.deepEqual(plugins.calls, []); +}); diff --git a/packages/moss-agent/test/plugin-npm-generations.spec.mjs b/packages/moss-agent/test/plugin-npm-generations.spec.mjs new file mode 100644 index 00000000..8ac5a78e --- /dev/null +++ b/packages/moss-agent/test/plugin-npm-generations.spec.mjs @@ -0,0 +1,107 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { chmod, mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { InstalledPluginRegistry } from '../dist/plugins/installed-plugin-registry.js'; + +test('npm candidates use isolated immutable roots and a failed candidate leaves the registry last-good', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'moss npm generations 配置 ')); + const bin = path.join(root, 'fake npm bin'); + const configDir = path.join(root, 'config with spaces 配置'); + await mkdir(bin, { recursive: true }); + const fakeNpm = path.join(bin, 'fake-npm.mjs'); + await writeFile( + fakeNpm, + `import { mkdir, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +const args = process.argv.slice(2); +const prefix = args[args.indexOf('--prefix') + 1]; +const source = args.at(-1); +const name = source.slice(0, source.lastIndexOf('@')); +const version = source.slice(source.lastIndexOf('@') + 1); +const packageRoot = path.join(prefix, 'node_modules', ...name.split('/')); +await mkdir(packageRoot, { recursive: true }); +await writeFile(path.join(packageRoot, 'moss.plugin.json'), JSON.stringify({ + schemaVersion: 1, + id: name.replaceAll('_', '-'), + version, + runtime: { module: './plugin.mjs' } +})); +await writeFile(path.join(packageRoot, 'plugin.mjs'), name.includes('bad') || (name.includes('recover') && version === '2.0.0') + ? "await new Promise(() => {}); export default { id: 'fixture-npm-bad', setup() {} };\\n" + : \`export default { id: \${JSON.stringify(name.replaceAll('_', '-'))}, setup() {} };\\n\`); +` + ); + const npmCommand = path.join(bin, process.platform === 'win32' ? 'npm.cmd' : 'npm'); + if (process.platform === 'win32') { + await writeFile(npmCommand, `@"${process.execPath}" "%~dp0\\fake-npm.mjs" %*\r\n`); + } else { + await writeFile(npmCommand, `#!${process.execPath}\nimport './fake-npm.mjs';\n`); + await chmod(npmCommand, 0o755); + } + const previousPath = process.env.PATH; + const previousNpmExecPath = process.env.npm_execpath; + process.env.PATH = `${bin}${path.delimiter}${previousPath ?? ''}`; + if (process.platform === 'win32') process.env.npm_execpath = fakeNpm; + try { + const registry = new InstalledPluginRegistry({ configDir, setupTimeoutMs: 75 }); + const first = await registry.add('fixture_npm_one@1.0.0'); + const second = await registry.add('fixture_npm_two@1.0.0'); + assert.notEqual(first.root, second.root); + assert.match(first.root, /npm[/\\]versions/); + assert.equal( + (await readFile(path.join(first.root, 'moss.plugin.json'), 'utf8')).includes( + 'fixture-npm-one' + ), + true + ); + await registry.add('fixture_npm_bad@1.0.0'); + await registry.enable('fixture-npm-bad'); + const isolated = await registry.loadEnabled(); + assert.equal( + isolated.plugins.some(({ id }) => id === 'fixture-npm-bad'), + false + ); + assert.match( + isolated.failures.find(({ id }) => id === 'fixture-npm-bad')?.message ?? '', + /timed out/ + ); + + const original = await registry.add('fixture_npm_upgrade@1.0.0'); + await registry.enable('fixture-npm-upgrade'); + const candidate = await registry.add('fixture_npm_upgrade@2.0.0'); + assert.equal(candidate.version, '2.0.0'); + assert.equal(candidate.enabled, true); + assert.equal(candidate.lastGood?.version, '1.0.0'); + assert.notEqual(candidate.root, original.root); + const restored = await registry.rollback('fixture-npm-upgrade'); + assert.equal(restored.version, '1.0.0'); + assert.equal(restored.root, original.root); + await assert.rejects(() => readFile(path.join(candidate.root, 'moss.plugin.json')), /ENOENT/); + + await registry.add('fixture_npm_recover@1.0.0'); + await registry.enable('fixture-npm-recover'); + await registry.add('fixture_npm_recover@2.0.0'); + const recovered = await registry.loadEnabled(); + assert.equal( + recovered.plugins.some(({ id }) => id === 'fixture-npm-recover'), + true + ); + assert.match( + recovered.failures.find(({ id }) => id === 'fixture-npm-recover')?.message ?? '', + /last-good 1\.0\.0 was restored/ + ); + assert.equal( + (await registry.list()).find(({ id }) => id === 'fixture-npm-recover')?.version, + '1.0.0' + ); + await Promise.all(recovered.plugins.map((plugin) => plugin.disposeCandidate?.())); + } finally { + process.env.PATH = previousPath; + if (previousNpmExecPath === undefined) delete process.env.npm_execpath; + else process.env.npm_execpath = previousNpmExecPath; + } +}); diff --git a/packages/moss-agent/test/plugin-runtime.spec.mjs b/packages/moss-agent/test/plugin-runtime.spec.mjs index c7583076..6d98148c 100644 --- a/packages/moss-agent/test/plugin-runtime.spec.mjs +++ b/packages/moss-agent/test/plugin-runtime.spec.mjs @@ -81,6 +81,11 @@ try { allowedTools: ['plugin_inspect_fixture'], }); context.addPromptLayer('## Plugin review policy\nUse plugin evidence when requested.'); + context.registerWebContribution({ + id: 'example-review-settings', + slot: 'settings.plugin', + module: './web/review-settings.js', + }); context.effect(() => () => cleanup.push('first'), 'first custom effect'); context.effect(() => async () => cleanup.push('second'), 'second custom effect'); }, @@ -105,6 +110,7 @@ try { assert.deepEqual(plugin?.skills, ['plugin-review']); assert.deepEqual(plugin?.experts, ['plugin-reviewer']); assert.equal(plugin?.promptLayerCount, 1); + assert.deepEqual(plugin?.webContributions, ['example-review-settings']); assert.doesNotMatch(JSON.stringify(snapshot), /Plugin review policy|Use only the plugin/); let loadTurn = 0; diff --git a/packages/moss-agent/test/plugin-startup-isolation.spec.mjs b/packages/moss-agent/test/plugin-startup-isolation.spec.mjs new file mode 100644 index 00000000..5fb4a273 --- /dev/null +++ b/packages/moss-agent/test/plugin-startup-isolation.spec.mjs @@ -0,0 +1,156 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { InstalledPluginRegistry } from '../dist/plugins/installed-plugin-registry.js'; +import { createMossPluginHost } from '../dist/core/plugins/plugin-host.js'; + +async function fixture(root, id, source) { + const pluginRoot = path.join(root, id.replace('/', '-')); + await mkdir(pluginRoot, { recursive: true }); + await writeFile( + path.join(pluginRoot, 'moss.plugin.json'), + JSON.stringify({ schemaVersion: 1, id, version: '1.0.0', runtime: { module: './plugin.mjs' } }) + ); + await writeFile(path.join(pluginRoot, 'plugin.mjs'), source); + return pluginRoot; +} + +test('sync and async setup hangs are terminated before enabled plugins reach the core runtime', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'moss-plugin-hangs-')); + const configDir = path.join(root, 'config with spaces 配置'); + const asyncRoot = await fixture( + root, + 'hang/async', + "export default { id: 'hang/async', async setup() { await new Promise(() => {}); } };\n" + ); + const syncRoot = await fixture( + root, + 'hang/sync', + "export default { id: 'hang/sync', setup() { while (true) {} } };\n" + ); + const importRoot = await fixture( + root, + 'hang/import', + "await new Promise(() => {}); export default { id: 'hang/import', setup() {} };\n" + ); + const registry = new InstalledPluginRegistry({ configDir, setupTimeoutMs: 50 }); + const asyncEntry = await registry.add(asyncRoot); + const syncEntry = await registry.add(syncRoot); + const importEntry = await registry.add(importRoot); + await mkdir(path.join(configDir, 'plugins'), { recursive: true }); + await writeFile( + path.join(configDir, 'plugins', 'installed.json'), + JSON.stringify({ + schemaVersion: 1, + plugins: [ + { ...asyncEntry, enabled: true }, + { ...syncEntry, enabled: true }, + { ...importEntry, enabled: true }, + ], + }) + ); + + const startedAt = Date.now(); + const loaded = await registry.loadEnabled(); + assert.deepEqual(loaded.plugins, []); + assert.deepEqual( + loaded.failures.map(({ id }) => id), + ['hang/async', 'hang/import', 'hang/sync'] + ); + assert.ok(Date.now() - startedAt < 2_000); + assert.match(loaded.failures.map(({ message }) => message).join(' '), /timed out/); +}); + +test('abort signals cross the Worker RPC boundary and release the plugin call lease', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'moss-plugin-cancel-')); + const pluginRoot = await fixture( + root, + 'cancel/worker', + `export default { + id: 'cancel/worker', + setup(context) { + context.registerTool({ + name: 'wait_for_abort', + description: 'Wait for cancellation.', + metadata: { sideEffectClass: 'readonly', planMode: 'allow' }, + inputSchema: { type: 'object', properties: {} }, + async execute(_input, toolContext) { + await new Promise((resolve) => toolContext.abortSignal.addEventListener('abort', resolve, { once: true })); + throw new Error('WORKER_ABORT_OBSERVED'); + } + }); + } + };\n` + ); + const registry = new InstalledPluginRegistry({ + configDir: path.join(root, 'config'), + setupTimeoutMs: 250, + }); + await registry.add(pluginRoot); + const plugin = await registry.loadInstalled('cancel/worker'); + const tools = new Map(); + const host = createMossPluginHost({ + hasTool: (name) => tools.has(name), + registerTool: (tool) => { + tools.set(tool.name, tool); + return () => tools.delete(tool.name); + }, + hasSkill: () => false, + registerSkill: () => () => {}, + hasExpert: () => false, + registerExpert: () => () => {}, + }); + try { + await host.install(plugin); + const controller = new AbortController(); + const running = tools + .get('wait_for_abort') + .execute({}, { workspaceDir: root, sessionKey: 'cancel', abortSignal: controller.signal }); + controller.abort(); + await assert.rejects(running, /plugin call aborted/); + await host.unload('cancel/worker', { timeoutMs: 250 }); + assert.equal(tools.has('wait_for_abort'), false); + } finally { + await host.close(); + } +}); + +test('an exited plugin Worker closes the RPC boundary for every later call', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'moss-plugin-worker-exit-')); + const pluginRoot = await fixture( + root, + 'exit/worker', + `export default { + id: 'exit/worker', + setup(context) { + context.registerCommand({ + id: 'exit-worker', + title: 'Exit worker', + expand() { process.exit(9); } + }); + } + };\n` + ); + const registry = new InstalledPluginRegistry({ configDir: path.join(root, 'config') }); + await registry.add(pluginRoot); + const plugin = await registry.loadInstalled('exit/worker'); + const host = createMossPluginHost({ + hasTool: () => false, + registerTool: () => () => {}, + hasSkill: () => false, + registerSkill: () => () => {}, + hasExpert: () => false, + registerExpert: () => () => {}, + }); + try { + await host.install(plugin); + await assert.rejects(host.expandCommand('exit-worker', ''), /exited with code 9/); + await assert.rejects(host.expandCommand('exit-worker', ''), /disposed/); + } finally { + await host.close(); + } +}); diff --git a/packages/moss-agent/test/plugins-runtime.spec.mjs b/packages/moss-agent/test/plugins-runtime.spec.mjs new file mode 100644 index 00000000..7ec60930 --- /dev/null +++ b/packages/moss-agent/test/plugins-runtime.spec.mjs @@ -0,0 +1,183 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { installConfiguredPlugins } from '../dist/cli/plugins-runtime.js'; +import { MossAgent } from '../dist/core/agent/moss-agent.js'; +import { InMemorySessionStore } from '../dist/core/session/session.js'; +import { InstalledPluginRegistry } from '../dist/plugins/installed-plugin-registry.js'; + +const configDir = await fs.mkdtemp(path.join(os.tmpdir(), 'moss-plugin-startup-')); +const pluginDir = path.join(configDir, 'broken-plugin'); +await fs.mkdir(pluginDir, { recursive: true }); +await fs.writeFile( + path.join(pluginDir, 'moss.plugin.json'), + JSON.stringify({ + schemaVersion: 1, + id: 'broken/startup', + version: '1.0.0', + runtime: { module: './plugin.mjs' }, + }) +); +await fs.writeFile( + path.join(pluginDir, 'plugin.mjs'), + "export default { id: 'broken/startup', setup() { throw new Error('BROKEN_SETUP'); } };\n" +); +const registry = new InstalledPluginRegistry({ configDir }); +await registry.add(pluginDir); +await registry.enable('broken/startup'); + +const agent = new MossAgent({ + llmProvider: { + id: 'plugin-startup-test', + displayName: 'Plugin startup test', + capabilities: { streaming: false }, + async complete() { + return { stopReason: 'end_turn', content: [] }; + }, + async stream() { + throw new Error('streaming disabled'); + }, + }, + sessionStore: new InMemorySessionStore(), + domainPrompt: false, + includeLanguagePolicyPrompt: false, + includeAgentBehaviorPrompt: false, +}); + +const errors = []; +const originalError = console.error; +console.error = (...args) => errors.push(args.join(' ')); +try { + await installConfiguredPlugins(agent, configDir); +} finally { + console.error = originalError; + await agent.close(); +} + +assert.deepEqual(agent.plugins.inspect().plugins, []); +assert.ok(errors.some((line) => line.includes('broken/startup') && line.includes('isolated'))); +console.log(' [PASS] configured plugin setup failures are isolated from runtime startup'); + +const recoveryConfigDir = await fs.mkdtemp(path.join(os.tmpdir(), 'moss-plugin-recovery-')); +const recoveryRegistryDir = path.join(recoveryConfigDir, 'plugins'); +const lastGoodDir = path.join(recoveryConfigDir, 'last-good'); +const candidateDir = path.join(recoveryConfigDir, 'candidate'); +await Promise.all([ + fs.mkdir(recoveryRegistryDir, { recursive: true }), + fs.mkdir(lastGoodDir, { recursive: true }), + fs.mkdir(candidateDir, { recursive: true }), +]); +for (const [root, version] of [ + [lastGoodDir, '1.0.0'], + [candidateDir, '2.0.0'], +]) { + await fs.writeFile( + path.join(root, 'moss.plugin.json'), + JSON.stringify({ + schemaVersion: 1, + id: 'startup/recovery', + version, + runtime: { module: './plugin.mjs' }, + }) + ); +} +await fs.writeFile( + path.join(lastGoodDir, 'plugin.mjs'), + `export default { + id: 'startup/recovery', + setup(context) { + context.registerCommand({ + id: 'last-good-check', + title: 'Last-good check', + expand() { return 'last-good-active'; } + }); + } + };\n` +); +await fs.writeFile( + path.join(candidateDir, 'plugin.mjs'), + `export default { + id: 'startup/recovery', + setup(context) { + context.registerTool({ + name: 'startup_collision', + description: 'Force host activation failure.', + metadata: { sideEffectClass: 'readonly', planMode: 'allow' }, + inputSchema: { type: 'object', properties: {} }, + execute() { return 'candidate'; } + }); + } + };\n` +); +await fs.writeFile( + path.join(recoveryRegistryDir, 'installed.json'), + JSON.stringify({ + schemaVersion: 1, + plugins: [ + { + id: 'startup/recovery', + version: '2.0.0', + source: 'startup-recovery@2.0.0', + root: candidateDir, + enabled: true, + installedAt: new Date().toISOString(), + format: 'moss-v1', + lastGood: { + version: '1.0.0', + source: 'startup-recovery@1.0.0', + root: lastGoodDir, + format: 'moss-v1', + }, + }, + ], + }) +); + +const recoveryAgent = new MossAgent({ + llmProvider: { + id: 'plugin-recovery-test', + displayName: 'Plugin recovery test', + capabilities: { streaming: false }, + async complete() { + return { stopReason: 'end_turn', content: [] }; + }, + async stream() { + throw new Error('streaming disabled'); + }, + }, + sessionStore: new InMemorySessionStore(), + domainPrompt: false, + includeLanguagePolicyPrompt: false, + includeAgentBehaviorPrompt: false, +}); +recoveryAgent.tools.register({ + name: 'startup_collision', + description: 'Existing tool.', + metadata: { sideEffectClass: 'readonly', planMode: 'allow' }, + inputSchema: { type: 'object', properties: {} }, + execute() { + return 'existing'; + }, +}); +const warnings = []; +const originalWarn = console.warn; +console.warn = (...args) => warnings.push(args.join(' ')); +try { + await installConfiguredPlugins(recoveryAgent, recoveryConfigDir); + assert.equal( + await recoveryAgent.plugins.expandCommand('last-good-check', ''), + 'last-good-active' + ); + assert.equal( + (await new InstalledPluginRegistry({ configDir: recoveryConfigDir }).list())[0]?.version, + '1.0.0' + ); + assert.ok(warnings.some((line) => line.includes('last-good 1.0.0 was restored'))); +} finally { + console.warn = originalWarn; + await recoveryAgent.close(); +} +console.log(' [PASS] configured plugin host activation restores and loads last-good generation'); diff --git a/packages/moss-agent/test/tool-registry-ownership.spec.mjs b/packages/moss-agent/test/tool-registry-ownership.spec.mjs new file mode 100644 index 00000000..d8bbd2a2 --- /dev/null +++ b/packages/moss-agent/test/tool-registry-ownership.spec.mjs @@ -0,0 +1,34 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; + +import { ToolRegistry } from '../dist/core/tools/tool-registry.js'; + +function tool(name, marker) { + return { + name, + description: marker, + inputSchema: { type: 'object', properties: {} }, + sideEffects: { kind: 'readonly' }, + async execute() { + return marker; + }, + }; +} + +const registry = new ToolRegistry(); +const pluginTool = tool('shared_name', 'plugin'); +registry.registerScoped(pluginTool, 'plugin:sample'); + +assert.throws( + () => registry.register(tool('shared_name', 'late builtin')), + /tool already registered: shared_name/ +); +assert.equal(registry.get('shared_name'), pluginTool); +assert.equal(registry.getGroupForTool('shared_name'), 'plugin:sample'); + +const replacement = tool('shared_name', 'explicit replacement'); +registry.replace(replacement, 'host:configured'); +assert.equal(registry.get('shared_name'), replacement); +assert.equal(registry.getGroupForTool('shared_name'), 'host:configured'); + +console.log(' [PASS] tool registry preserves lifecycle ownership unless replacement is explicit'); diff --git a/packages/moss-agent/test/visual/baseline/desktop-approval.png b/packages/moss-agent/test/visual/baseline/desktop-approval.png new file mode 100644 index 00000000..9fdba4d0 Binary files /dev/null and b/packages/moss-agent/test/visual/baseline/desktop-approval.png differ diff --git a/packages/moss-agent/test/visual/baseline/desktop-home.png b/packages/moss-agent/test/visual/baseline/desktop-home.png new file mode 100644 index 00000000..ca661799 Binary files /dev/null and b/packages/moss-agent/test/visual/baseline/desktop-home.png differ diff --git a/packages/moss-agent/test/visual/baseline/desktop-plan.png b/packages/moss-agent/test/visual/baseline/desktop-plan.png new file mode 100644 index 00000000..bc8d6dc2 Binary files /dev/null and b/packages/moss-agent/test/visual/baseline/desktop-plan.png differ diff --git a/packages/moss-agent/test/visual/baseline/desktop-plugins.png b/packages/moss-agent/test/visual/baseline/desktop-plugins.png new file mode 100644 index 00000000..dbccf10a Binary files /dev/null and b/packages/moss-agent/test/visual/baseline/desktop-plugins.png differ diff --git a/packages/moss-agent/test/visual/baseline/desktop-running.png b/packages/moss-agent/test/visual/baseline/desktop-running.png new file mode 100644 index 00000000..5380742b Binary files /dev/null and b/packages/moss-agent/test/visual/baseline/desktop-running.png differ diff --git a/packages/moss-agent/test/visual/baseline/desktop-settings.png b/packages/moss-agent/test/visual/baseline/desktop-settings.png new file mode 100644 index 00000000..7fcf8666 Binary files /dev/null and b/packages/moss-agent/test/visual/baseline/desktop-settings.png differ diff --git a/packages/moss-agent/test/visual/baseline/desktop-tool.png b/packages/moss-agent/test/visual/baseline/desktop-tool.png new file mode 100644 index 00000000..bf3eae48 Binary files /dev/null and b/packages/moss-agent/test/visual/baseline/desktop-tool.png differ diff --git a/packages/moss-agent/test/visual/baseline/narrow-conversation.png b/packages/moss-agent/test/visual/baseline/narrow-conversation.png new file mode 100644 index 00000000..73ade5a0 Binary files /dev/null and b/packages/moss-agent/test/visual/baseline/narrow-conversation.png differ diff --git a/packages/moss-agent/test/visual/baseline/narrow-home.png b/packages/moss-agent/test/visual/baseline/narrow-home.png new file mode 100644 index 00000000..d12c8e57 Binary files /dev/null and b/packages/moss-agent/test/visual/baseline/narrow-home.png differ diff --git a/packages/moss-agent/test/visual/baseline/tablet-gallery-dialog.png b/packages/moss-agent/test/visual/baseline/tablet-gallery-dialog.png new file mode 100644 index 00000000..e7ad231e Binary files /dev/null and b/packages/moss-agent/test/visual/baseline/tablet-gallery-dialog.png differ diff --git a/packages/moss-agent/test/visual/baseline/tablet-gallery.png b/packages/moss-agent/test/visual/baseline/tablet-gallery.png new file mode 100644 index 00000000..f54219a2 Binary files /dev/null and b/packages/moss-agent/test/visual/baseline/tablet-gallery.png differ diff --git a/packages/moss-agent/test/visual/baseline/tablet-home.png b/packages/moss-agent/test/visual/baseline/tablet-home.png new file mode 100644 index 00000000..517c1fd4 Binary files /dev/null and b/packages/moss-agent/test/visual/baseline/tablet-home.png differ diff --git a/packages/moss-agent/test/visual/reference/deepseek-47f9438/README.md b/packages/moss-agent/test/visual/reference/deepseek-47f9438/README.md new file mode 100644 index 00000000..c86855ba --- /dev/null +++ b/packages/moss-agent/test/visual/reference/deepseek-47f9438/README.md @@ -0,0 +1,19 @@ +# DeepSeek Harness reference captures + +These images were rendered from the official +[`deepseek-ai/deepseek-harness`](https://github.com/deepseek-ai/deepseek-harness) repository at +`47f943859bef60e4160492346772ded9b24f765a` using its keyless `?fixture` browser mode. + +They are acceptance evidence, not assets shipped by `@rdk-moss/agent`. Moss must reproduce layout, +component density, interaction states, and responsive behavior while retaining Moss branding and +copy. The fixture's one-time testing notice was removed before ordinary workbench captures because +it is DeepSeek-specific release copy; Moss safety notices are verified separately. + +Viewport naming: + +- `desktop`: 1440 × 960 +- `narrow`: 1120 × 800 +- `tablet`: 820 × 1180 + +The desktop state set covers the empty workbench, active response, user interaction takeover, +command/plan menu, settings, plugin inventory, and settled conversation/tool-detail layout. diff --git a/packages/moss-agent/test/visual/reference/deepseek-47f9438/desktop-approval.png b/packages/moss-agent/test/visual/reference/deepseek-47f9438/desktop-approval.png new file mode 100644 index 00000000..5d497712 Binary files /dev/null and b/packages/moss-agent/test/visual/reference/deepseek-47f9438/desktop-approval.png differ diff --git a/packages/moss-agent/test/visual/reference/deepseek-47f9438/desktop-home.png b/packages/moss-agent/test/visual/reference/deepseek-47f9438/desktop-home.png new file mode 100644 index 00000000..f401c4a4 Binary files /dev/null and b/packages/moss-agent/test/visual/reference/deepseek-47f9438/desktop-home.png differ diff --git a/packages/moss-agent/test/visual/reference/deepseek-47f9438/desktop-plan.png b/packages/moss-agent/test/visual/reference/deepseek-47f9438/desktop-plan.png new file mode 100644 index 00000000..4e40d50a Binary files /dev/null and b/packages/moss-agent/test/visual/reference/deepseek-47f9438/desktop-plan.png differ diff --git a/packages/moss-agent/test/visual/reference/deepseek-47f9438/desktop-plugins.png b/packages/moss-agent/test/visual/reference/deepseek-47f9438/desktop-plugins.png new file mode 100644 index 00000000..ee36616e Binary files /dev/null and b/packages/moss-agent/test/visual/reference/deepseek-47f9438/desktop-plugins.png differ diff --git a/packages/moss-agent/test/visual/reference/deepseek-47f9438/desktop-running.png b/packages/moss-agent/test/visual/reference/deepseek-47f9438/desktop-running.png new file mode 100644 index 00000000..7ae1ad5b Binary files /dev/null and b/packages/moss-agent/test/visual/reference/deepseek-47f9438/desktop-running.png differ diff --git a/packages/moss-agent/test/visual/reference/deepseek-47f9438/desktop-settings.png b/packages/moss-agent/test/visual/reference/deepseek-47f9438/desktop-settings.png new file mode 100644 index 00000000..14126ab0 Binary files /dev/null and b/packages/moss-agent/test/visual/reference/deepseek-47f9438/desktop-settings.png differ diff --git a/packages/moss-agent/test/visual/reference/deepseek-47f9438/desktop-tool.png b/packages/moss-agent/test/visual/reference/deepseek-47f9438/desktop-tool.png new file mode 100644 index 00000000..90a64f2c Binary files /dev/null and b/packages/moss-agent/test/visual/reference/deepseek-47f9438/desktop-tool.png differ diff --git a/packages/moss-agent/test/visual/reference/deepseek-47f9438/narrow-home.png b/packages/moss-agent/test/visual/reference/deepseek-47f9438/narrow-home.png new file mode 100644 index 00000000..d344b0f7 Binary files /dev/null and b/packages/moss-agent/test/visual/reference/deepseek-47f9438/narrow-home.png differ diff --git a/packages/moss-agent/test/visual/reference/deepseek-47f9438/tablet-home.png b/packages/moss-agent/test/visual/reference/deepseek-47f9438/tablet-home.png new file mode 100644 index 00000000..6676d8ad Binary files /dev/null and b/packages/moss-agent/test/visual/reference/deepseek-47f9438/tablet-home.png differ diff --git a/packages/moss-agent/test/web-attachment-security.spec.mjs b/packages/moss-agent/test/web-attachment-security.spec.mjs new file mode 100644 index 00000000..fa786c17 --- /dev/null +++ b/packages/moss-agent/test/web-attachment-security.spec.mjs @@ -0,0 +1,107 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { InMemorySessionStore } from '../dist/core/session/session.js'; +import { startMossWebServer } from '../dist/web-ui/web-server.js'; + +function fakeAgent(workspaceDir, capture) { + return { + config: { + sessionStore: new InMemorySessionStore(), + model: 'attachment-model', + workspaceDir, + }, + tools: { getNames: () => [] }, + plugins: { + inspect: () => ({ generation: 0, plugins: [] }), + listCommands: () => [], + subscribe: () => () => {}, + }, + setUserQuestionAsker() { + return () => {}; + }, + async *streamChat(_sessionId, _prompt, options) { + capture.attachments = options.attachments; + yield { + type: 'done', + result: { response: '', toolCalls: [], toolResults: [], stopReason: 'end_turn' }, + }; + }, + }; +} + +test('Web mutations require strict Origin and per-server CSRF while attachment GET stays readable', async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'moss-web-attachment-http-')); + const capture = {}; + const web = await startMossWebServer(fakeAgent(tempDir, capture), { port: 0 }); + try { + const bootstrap = await fetch(`${web.url}/api/bootstrap`).then((response) => response.json()); + assert.equal(typeof bootstrap.csrfToken, 'string'); + const authorizedHeaders = { + origin: web.url, + 'x-moss-csrf': bootstrap.csrfToken, + 'content-type': 'application/json', + }; + + assert.equal((await fetch(`${web.url}/api/sessions`, { method: 'POST' })).status, 403); + assert.equal( + ( + await fetch(`${web.url}/api/sessions`, { + method: 'POST', + headers: { ...authorizedHeaders, 'x-moss-csrf': 'wrong' }, + }) + ).status, + 403 + ); + assert.equal( + ( + await fetch(`${web.url}/api/sessions`, { + method: 'POST', + headers: { ...authorizedHeaders, origin: 'http://attacker.invalid' }, + }) + ).status, + 403 + ); + + const sessionResponse = await fetch(`${web.url}/api/sessions`, { + method: 'POST', + headers: authorizedHeaders, + }); + assert.equal(sessionResponse.status, 201); + const session = await sessionResponse.json(); + + const uploaded = await fetch(`${web.url}/api/attachments`, { + method: 'POST', + headers: authorizedHeaders, + body: JSON.stringify({ + filename: 'note.txt', + mimeType: 'text/plain', + contentBase64: Buffer.from('real attachment body').toString('base64'), + }), + }).then((response) => response.json()); + const download = await fetch(`${web.url}${uploaded.attachment.downloadUrl}`); + assert.match(download.headers.get('content-disposition') ?? '', /^attachment;/); + assert.equal(await download.text(), 'real attachment body'); + + const turn = await fetch(`${web.url}/api/sessions/${session.sessionId}/messages`, { + method: 'POST', + headers: authorizedHeaders, + body: JSON.stringify({ + prompt: 'Read the attachment', + attachmentIds: [uploaded.attachment.id], + }), + }); + assert.equal(turn.status, 200); + await turn.text(); + assert.deepEqual(capture.attachments, [ + { type: 'text', text: '[Attachment: note.txt]\nreal attachment body' }, + ]); + } finally { + await web.close(); + await fs.rm(tempDir, { recursive: true, force: true }); + } +}); diff --git a/packages/moss-agent/test/web-attachment-service.spec.mjs b/packages/moss-agent/test/web-attachment-service.spec.mjs new file mode 100644 index 00000000..140c14f2 --- /dev/null +++ b/packages/moss-agent/test/web-attachment-service.spec.mjs @@ -0,0 +1,83 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { MossWebAttachmentService } from '../dist/web-ui/web-attachment-service.js'; + +test('Web attachments use random server names and resolve bounded text and image prompt blocks', async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'moss attachments 空格 ')); + const service = new MossWebAttachmentService({ + storageDir: path.join(tempDir, '.moss', 'web attachments'), + workspaceDir: tempDir, + }); + try { + const text = await service.upload({ + filename: '../../notes.txt', + mimeType: 'text/plain', + contentBase64: Buffer.from('hello attachment').toString('base64'), + }); + const image = await service.upload({ + filename: 'preview.png', + mimeType: 'image/png', + contentBase64: Buffer.from([0x89, 0x50, 0x4e, 0x47]).toString('base64'), + }); + assert.equal(text.filename, 'notes.txt'); + assert.match(text.id, /^attachment-[0-9a-f-]+$/); + assert.equal(text.downloadUrl, `/api/attachments/${text.id}`); + const storedNames = await fs.readdir(path.join(tempDir, '.moss', 'web attachments')); + assert.equal( + storedNames.some((name) => name.includes('notes.txt')), + false + ); + + assert.deepEqual(await service.resolveForPrompt([text.id, image.id]), [ + { type: 'text', text: '[Attachment: notes.txt]\nhello attachment' }, + { + type: 'image', + data: Buffer.from([0x89, 0x50, 0x4e, 0x47]).toString('base64'), + mimeType: 'image/png', + filename: 'preview.png', + }, + ]); + await assert.rejects( + service.upload({ + filename: 'payload.svg', + mimeType: 'image/svg+xml', + contentBase64: Buffer.from('').toString('base64'), + }), + /unsupported attachment type/ + ); + await assert.rejects(service.resolveForPrompt(Array(9).fill(text.id)), /at most 8/); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } +}); + +test('Generated artifact registration enforces real workspace containment before copying', async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'moss-web-artifacts-')); + const workspace = path.join(tempDir, 'workspace'); + const outside = path.join(tempDir, 'outside.txt'); + await fs.mkdir(path.join(workspace, 'output'), { recursive: true }); + await fs.writeFile(path.join(workspace, 'output', 'report.md'), '# report'); + await fs.writeFile(outside, 'outside'); + const service = new MossWebAttachmentService({ + storageDir: path.join(tempDir, 'attachments'), + workspaceDir: workspace, + }); + try { + const artifact = await service.registerArtifact('output/report.md'); + assert.equal(artifact.kind, 'artifact'); + assert.equal((await service.read(artifact.id)).body.toString('utf8'), '# report'); + await assert.rejects(service.registerArtifact('../outside.txt'), /workspace/); + await assert.rejects(service.registerArtifact(outside), /relative/); + if (process.platform !== 'win32') { + await fs.symlink(outside, path.join(workspace, 'output', 'escape.txt')); + await assert.rejects(service.registerArtifact('output/escape.txt'), /workspace/); + } + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } +}); diff --git a/packages/moss-agent/test/web-authorized-fetch.mjs b/packages/moss-agent/test/web-authorized-fetch.mjs new file mode 100644 index 00000000..7c8c51cc --- /dev/null +++ b/packages/moss-agent/test/web-authorized-fetch.mjs @@ -0,0 +1,24 @@ +const csrfByOrigin = new Map(); + +export async function authorizedWebFetch(input, init = {}) { + const url = + typeof input === 'string' || input instanceof URL ? new URL(input) : new URL(input.url); + const method = String( + init.method ?? (typeof input === 'object' ? input.method : 'GET') + ).toUpperCase(); + if (method === 'GET' || method === 'HEAD' || method === 'OPTIONS') { + return globalThis.fetch(input, init); + } + let csrfToken = csrfByOrigin.get(url.origin); + if (!csrfToken) { + const bootstrap = await globalThis + .fetch(`${url.origin}/api/bootstrap`) + .then((response) => response.json()); + csrfToken = bootstrap.csrfToken; + csrfByOrigin.set(url.origin, csrfToken); + } + const headers = new Headers(init.headers); + if (!headers.has('origin')) headers.set('origin', url.origin); + if (!headers.has('x-moss-csrf')) headers.set('x-moss-csrf', csrfToken); + return globalThis.fetch(input, { ...init, headers }); +} diff --git a/packages/moss-agent/test/web-control-plane.spec.mjs b/packages/moss-agent/test/web-control-plane.spec.mjs new file mode 100644 index 00000000..0065de6b --- /dev/null +++ b/packages/moss-agent/test/web-control-plane.spec.mjs @@ -0,0 +1,115 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { authorizedWebFetch as fetch } from './web-authorized-fetch.mjs'; + +import { InMemorySessionStore } from '../dist/core/session/session.js'; +import { MossAgent } from '../dist/core/agent/moss-agent.js'; +import { MossWebInteractionBroker } from '../dist/web-ui/web-interaction-broker.js'; +import { startMossWebServer } from '../dist/web-ui/web-server.js'; + +function createAgent(workspaceDir) { + return new MossAgent({ + llmProvider: { + id: 'web-control-plane-test', + capabilities: { streaming: false }, + async complete() { + return { stopReason: 'end_turn', content: [] }; + }, + }, + sessionStore: new InMemorySessionStore(), + workspaceDir, + model: 'control-model', + domainPrompt: false, + includeLanguagePolicyPrompt: false, + includeAgentBehaviorPrompt: false, + }); +} + +test('Web control plane resolves interactions and drives runtime and settings owners', async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'moss-web-control-plane-')); + const agent = createAgent(tempDir); + const broker = new MossWebInteractionBroker(); + const web = await startMossWebServer(agent, { + port: 0, + configDir: path.join(tempDir, 'config'), + interactionBroker: broker, + }); + try { + const waiting = broker.askQuestion('Which target?'); + const interactions = await fetch(`${web.url}/api/interactions`).then((response) => + response.json() + ); + assert.equal(interactions.interactions[0].kind, 'question'); + const resolved = await fetch( + `${web.url}/api/interactions/${interactions.interactions[0].id}/resolve`, + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ answer: 'Device' }), + } + ); + assert.equal(resolved.status, 200); + assert.equal(await waiting, 'Device'); + + const session = await fetch(`${web.url}/api/sessions`, { method: 'POST' }).then((response) => + response.json() + ); + const queued = await fetch(`${web.url}/api/sessions/${session.sessionId}/inbox`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ prompt: 'Run next', delivery: 'queue' }), + }); + assert.equal(queued.status, 201); + assert.equal( + ( + await fetch(`${web.url}/api/sessions/${session.sessionId}/inbox`).then((response) => + response.json() + ) + ).entries[0].prompt, + 'Run next' + ); + + const mode = await fetch(`${web.url}/api/runtime/mode`, { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ mode: 'plan' }), + }).then((response) => response.json()); + assert.equal(mode.mode, 'plan'); + + const goal = await fetch(`${web.url}/api/sessions/${session.sessionId}/goal`, { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ action: 'set', objective: 'Complete control plane' }), + }).then((response) => response.json()); + assert.equal(goal.goal.objective, 'Complete control plane'); + + const saved = await fetch(`${web.url}/api/settings/permissions`, { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + values: { safetyMode: 'workspace-write', approvalPolicy: 'prompt' }, + }), + }); + assert.equal(saved.status, 200); + const secretWrite = await fetch(`${web.url}/api/settings/credentials/apiKey`, { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ value: 'control-plane-secret' }), + }); + assert.equal(secretWrite.status, 200); + const modelSettings = await fetch(`${web.url}/api/settings/models`).then((response) => + response.json() + ); + assert.equal(modelSettings.credentials.apiKey.configured, true); + assert.equal(JSON.stringify(modelSettings).includes('control-plane-secret'), false); + } finally { + await web.close(); + await agent.close(); + await fs.rm(tempDir, { recursive: true, force: true }); + } +}); diff --git a/packages/moss-agent/test/web-design-system.spec.mjs b/packages/moss-agent/test/web-design-system.spec.mjs new file mode 100644 index 00000000..ca82f4a0 --- /dev/null +++ b/packages/moss-agent/test/web-design-system.spec.mjs @@ -0,0 +1,56 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import test from 'node:test'; + +const clientRoot = new URL('../dist/web-ui/client/', import.meta.url); + +test('bundled workbench exposes the complete Moss design system and layout contract', async () => { + const [styles, script] = await Promise.all([ + readFile(new URL('workbench.css', clientRoot), 'utf8'), + readFile(new URL('workbench.js', clientRoot), 'utf8'), + ]); + + for (const token of [ + '--moss-font-family-body', + '--moss-font-family-code', + '--moss-font-size-1', + '--moss-line-height-body', + '--moss-space-10', + '--moss-radius-round', + '--moss-shadow-dialog', + '--moss-z-dialog', + '--moss-state-success', + '--moss-state-interrupted', + ]) { + assert.match(styles, new RegExp(`${token}:`), `missing design token ${token}`); + } + + for (const primitive of [ + 'button', + 'input', + 'tabs', + 'dialog', + 'toast', + 'tooltip', + 'card', + 'disclosure', + 'code', + 'diff', + 'terminal', + ]) { + assert.match( + script, + new RegExp(`data-moss-ui.{0,20}${primitive}`), + `missing ${primitive} primitive` + ); + } + + assert.match(script, /data-moss-component-gallery/); + assert.match(script, /moss-layout-v1/); + assert.match(script, /data-resize-handle/); + assert.match(script, /aria-orientation/); + assert.match(styles, /\.moss-skip-link/); + assert.match(styles, /\[data-mobile-drawer/); + assert.match(styles, /prefers-reduced-motion/); +}); diff --git a/packages/moss-agent/test/web-event-journal.spec.mjs b/packages/moss-agent/test/web-event-journal.spec.mjs new file mode 100644 index 00000000..e536aec1 --- /dev/null +++ b/packages/moss-agent/test/web-event-journal.spec.mjs @@ -0,0 +1,201 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { authorizedWebFetch as fetch } from './web-authorized-fetch.mjs'; + +import { InMemorySessionStore } from '../dist/core/session/session.js'; +import { TaskRunLedger } from '../dist/core/task-run/task-run-ledger.js'; +import { MossWebEventJournal, parseMossWebEventCursor } from '../dist/web-ui/web-event-journal.js'; +import { startMossWebServer } from '../dist/web-ui/web-server.js'; + +function fakeAgent(streamChat, store = new InMemorySessionStore()) { + return { + config: { sessionStore: store, model: 'journal-model' }, + tools: { getNames: () => [] }, + plugins: { + inspect: () => ({ generation: 0, plugins: [] }), + listCommands: () => [], + subscribe: () => () => {}, + }, + streamChat, + }; +} + +test('Web event journal survives restart and validates run-scoped cursors', async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'moss web journal 空格 ')); + const journalFile = path.join(tempDir, 'nested', 'events.jsonl'); + try { + const first = new MossWebEventJournal(journalFile); + first.append('run:windows-safe', 'session/a', { type: 'text', delta: 'one' }); + first.append('run:windows-safe', 'session/a', { + type: 'retry', + attempt: 2, + error: 'retryable', + }); + const reloaded = new MossWebEventJournal(journalFile); + assert.deepEqual( + reloaded.events('run:windows-safe', 1).map(({ seq, event }) => [seq, event.type]), + [[2, 'retry']] + ); + assert.equal(parseMossWebEventCursor('run:windows-safe', 'run:windows-safe:2'), 2); + assert.equal(parseMossWebEventCursor('run:windows-safe', 'other-run:99'), 0); + assert.equal(parseMossWebEventCursor('run:windows-safe', 'not-a-cursor'), 0); + assert.equal(parseMossWebEventCursor('run:windows-safe', '2trailing-data'), 0); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } +}); + +test('Web stream journals rich events and replays them as SSE after Last-Event-ID', async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'moss-web-sse-')); + const journalFile = path.join(tempDir, '.moss', 'events.jsonl'); + const store = new InMemorySessionStore(); + const agent = fakeAgent(async function* () { + yield { type: 'text_delta', delta: 'first' }; + yield { type: 'retry', attempt: 1, error: 'temporary' }; + yield { type: 'compaction', summaryChars: 12, droppedMessages: 3 }; + yield { type: 'llm_usage', inputTokens: 20, outputTokens: 5, contextTokens: 100 }; + yield { + type: 'working_context_checkpoint', + status: 'active', + reason: 'tool result', + goal: 'finish', + nextAction: 'verify', + }; + yield { + type: 'done', + result: { + response: 'first', + toolCalls: [], + toolResults: [], + stopReason: 'end_turn', + }, + }; + }, store); + const taskRuns = new TaskRunLedger(); + const firstHost = await startMossWebServer(agent, { + port: 0, + taskRunLedger: taskRuns, + eventJournalFile: journalFile, + }); + let runId; + try { + const session = await fetch(`${firstHost.url}/api/sessions`, { method: 'POST' }).then( + (response) => response.json() + ); + const stream = await fetch(`${firstHost.url}/api/sessions/${session.sessionId}/messages`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ prompt: 'journal this turn' }), + }).then((response) => response.text()); + const events = stream + .trim() + .split('\n') + .map((line) => JSON.parse(line)); + assert.deepEqual( + events.map(({ type }) => type), + ['run', 'text', 'retry', 'compaction', 'usage', 'context', 'done'] + ); + runId = events[0].run.id; + } finally { + await firstHost.close(); + } + + const secondHost = await startMossWebServer(agent, { + port: 0, + taskRunLedger: taskRuns, + eventJournalFile: journalFile, + }); + try { + const replay = await fetch(`${secondHost.url}/api/runs/${encodeURIComponent(runId)}/events`, { + headers: { 'last-event-id': `${runId}:2` }, + }); + assert.match(replay.headers.get('content-type') ?? '', /text\/event-stream/); + const body = await replay.text(); + assert.doesNotMatch(body, /"delta":"first"/); + assert.match(body, /event: retry/); + assert.match(body, /event: compaction/); + assert.match(body, /event: usage/); + assert.match(body, /event: context/); + assert.match(body, /event: done/); + assert.match(body, /id: 7/); + } finally { + await secondHost.close(); + await fs.rm(tempDir, { recursive: true, force: true }); + } +}); + +test('refresh discovers an active run and SSE remains attached through cancellation', async () => { + const store = new InMemorySessionStore(); + const agent = fakeAgent(async function* (_sessionId, _prompt, options) { + await new Promise((resolve) => + options.abortSignal.addEventListener('abort', resolve, { once: true }) + ); + yield { + type: 'done', + result: { + response: '', + toolCalls: [], + toolResults: [], + stopReason: 'aborted_by_user', + }, + }; + }, store); + const web = await startMossWebServer(agent, { port: 0 }); + try { + const session = await fetch(`${web.url}/api/sessions`, { method: 'POST' }).then((response) => + response.json() + ); + const pending = await fetch(`${web.url}/api/sessions/${session.sessionId}/messages`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ prompt: 'keep running' }), + }); + const active = await fetch(`${web.url}/api/sessions/${session.sessionId}/active-run`).then( + (response) => response.json() + ); + assert.equal(active.run.status, 'running'); + assert.ok(active.cursor >= 1); + + const replay = await fetch(`${web.url}${active.eventsUrl}?after=0`); + const cancelled = await fetch(`${web.url}/api/sessions/${session.sessionId}/cancel`, { + method: 'POST', + }).then((response) => response.json()); + assert.equal(cancelled.cancelled, true); + assert.match(await pending.text(), /"stopReason":"aborted_by_user"/); + const replayBody = await replay.text(); + assert.match(replayBody, /event: run/); + assert.match(replayBody, /event: done/); + } finally { + await web.close(); + } +}); + +test('host restart records an interrupted event instead of inventing completion', async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'moss-web-interrupted-')); + const journalFile = path.join(tempDir, 'events.jsonl'); + const taskRuns = new TaskRunLedger(); + taskRuns.create({ id: 'restart-run', sessionId: 'restart-session', title: 'Restart task' }); + taskRuns.append('restart-run', { type: 'run.started' }); + const agent = fakeAgent(async function* () {}); + const web = await startMossWebServer(agent, { + port: 0, + taskRunLedger: taskRuns, + eventJournalFile: journalFile, + }); + try { + assert.equal(taskRuns.get('restart-run').status, 'interrupted'); + const replay = await fetch(`${web.url}/api/runs/restart-run/events`); + const body = await replay.text(); + assert.match(body, /event: interrupted/); + assert.match(body, /"reason":"host restarted"/); + assert.doesNotMatch(body, /"type":"done"/); + } finally { + await web.close(); + await fs.rm(tempDir, { recursive: true, force: true }); + } +}); diff --git a/packages/moss-agent/test/web-interaction-broker.spec.mjs b/packages/moss-agent/test/web-interaction-broker.spec.mjs new file mode 100644 index 00000000..9ca4cbd6 --- /dev/null +++ b/packages/moss-agent/test/web-interaction-broker.spec.mjs @@ -0,0 +1,251 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { InMemorySessionStore } from '../dist/core/session/session.js'; +import { MossAgent } from '../dist/core/agent/moss-agent.js'; +import { createCliToolApprovalHook } from '../dist/cli/approval.js'; +import { MossWebInteractionBroker } from '../dist/web-ui/web-interaction-broker.js'; +import { startMossWebServer } from '../dist/web-ui/web-server.js'; +import { authorizedWebFetch } from './web-authorized-fetch.mjs'; + +test('Web interaction broker exposes approval and question requests until resolved', async () => { + const broker = new MossWebInteractionBroker(); + + const approval = broker.askApproval('Allow once or deny?'); + const question = broker.askQuestion('Choose a target\n 1. Local\n 2. Device'); + const pending = broker.pending(); + + assert.deepEqual( + pending.map(({ kind, prompt, state }) => [kind, prompt, state]), + [ + ['approval', 'Allow once or deny?', 'pending'], + ['question', 'Choose a target\n 1. Local\n 2. Device', 'pending'], + ] + ); + + assert.equal(broker.resolve(pending[0].id, 'allow_once'), true); + assert.equal(broker.resolve(pending[1].id, '2'), true); + assert.equal(await approval, 'y'); + assert.equal(await question, '2'); + assert.deepEqual(broker.pending(), []); + assert.equal(broker.resolve(pending[0].id, 'deny'), false, 'cannot resolve twice'); +}); + +test('Web interaction broker cancellation and abort release the waiting agent call', async () => { + const broker = new MossWebInteractionBroker(); + const controller = new AbortController(); + const cancelled = broker.askQuestion('What next?'); + const aborted = broker.askApproval('Proceed?', controller.signal); + const [cancelledRequest, abortedRequest] = broker.pending(); + + assert.equal(broker.cancel(cancelledRequest.id), true); + controller.abort(); + + assert.equal(await cancelled, ''); + assert.equal(await aborted, ''); + assert.equal(broker.get(abortedRequest.id)?.state, 'cancelled'); + assert.deepEqual(broker.pending(), []); +}); + +test('Web interaction broker rejects answers that do not match the interaction kind', async () => { + const broker = new MossWebInteractionBroker(); + const pending = broker.askApproval('Proceed?'); + const request = broker.pending()[0]; + + assert.throws(() => broker.resolve(request.id, 'arbitrary free text'), /approval answer/); + assert.equal(broker.resolve(request.id, 'deny'), true); + assert.equal(await pending, 'n'); +}); + +test('two Web servers keep approval prompts and resolutions isolated by agent instance', async () => { + const create = (id) => { + const approval = createCliToolApprovalHook('workspace-write', {}, {}); + const agent = new MossAgent({ + llmProvider: { + id, + capabilities: { streaming: false }, + async complete() { + return { stopReason: 'end_turn', content: [] }; + }, + }, + sessionStore: new InMemorySessionStore(), + hooks: { onBeforeToolExec: approval }, + domainPrompt: false, + includeLanguagePolicyPrompt: false, + includeAgentBehaviorPrompt: false, + }); + return { agent, approval }; + }; + const first = create('first-agent'); + const second = create('second-agent'); + const firstWeb = await startMossWebServer(first.agent, { port: 0 }); + const secondWeb = await startMossWebServer(second.agent, { port: 0 }); + const tool = { + name: 'remember_test', + description: 'test', + metadata: { sideEffectClass: 'memory_write' }, + inputSchema: { type: 'object', properties: {} }, + async execute() { + return 'ok'; + }, + }; + try { + const firstDecision = first.approval({ + tool, + input: { value: 'first' }, + sessionKey: 'first', + runId: 'first-run', + toolCallId: 'first-call', + abortSignal: new AbortController().signal, + }); + const secondDecision = second.approval({ + tool, + input: { value: 'second' }, + sessionKey: 'second', + runId: 'second-run', + toolCallId: 'second-call', + abortSignal: new AbortController().signal, + }); + const firstBootstrap = await fetch(`${firstWeb.url}/api/bootstrap`).then((response) => + response.json() + ); + const secondBootstrap = await fetch(`${secondWeb.url}/api/bootstrap`).then((response) => + response.json() + ); + const firstPending = await fetch(`${firstWeb.url}/api/interactions`).then((response) => + response.json() + ); + const secondPending = await fetch(`${secondWeb.url}/api/interactions`).then((response) => + response.json() + ); + assert.equal(firstPending.interactions.length, 1); + assert.equal(secondPending.interactions.length, 1); + assert.notEqual(firstPending.interactions[0].id, secondPending.interactions[0].id); + + const resolve = (web, csrfToken, interactionId, answer) => + fetch(`${web.url}/api/interactions/${interactionId}/resolve`, { + method: 'POST', + headers: { + origin: web.url, + 'x-moss-csrf': csrfToken, + 'content-type': 'application/json', + }, + body: JSON.stringify({ answer }), + }); + assert.equal( + ( + await resolve( + firstWeb, + firstBootstrap.csrfToken, + firstPending.interactions[0].id, + 'allow_once' + ) + ).status, + 200 + ); + assert.equal( + ( + await resolve( + secondWeb, + secondBootstrap.csrfToken, + secondPending.interactions[0].id, + 'deny' + ) + ).status, + 200 + ); + assert.deepEqual(await firstDecision, { approved: true }); + assert.deepEqual(await secondDecision, { + approved: false, + reason: 'User denied remember_test.', + }); + } finally { + await Promise.all([firstWeb.close(), secondWeb.close()]); + await Promise.all([first.agent.close(), second.agent.close()]); + } +}); + +test('Web runtime mode controls only its own agent approval hook', async () => { + const create = (id) => { + const approval = createCliToolApprovalHook('workspace-write', {}, {}); + const agent = new MossAgent({ + llmProvider: { + id, + capabilities: { streaming: false }, + async complete() { + return { stopReason: 'end_turn', content: [] }; + }, + }, + sessionStore: new InMemorySessionStore(), + hooks: { onBeforeToolExec: approval }, + domainPrompt: false, + includeLanguagePolicyPrompt: false, + includeAgentBehaviorPrompt: false, + }); + return { agent, approval }; + }; + const first = create('first-mode-agent'); + const second = create('second-mode-agent'); + const firstWeb = await startMossWebServer(first.agent, { port: 0 }); + const secondWeb = await startMossWebServer(second.agent, { port: 0 }); + const tool = { + name: 'remember_mode_test', + description: 'test', + metadata: { sideEffectClass: 'memory_write' }, + inputSchema: { type: 'object', properties: {} }, + async execute() { + return 'ok'; + }, + }; + const request = (value) => ({ + tool, + input: { value }, + sessionKey: value, + runId: `${value}-run`, + toolCallId: `${value}-call`, + abortSignal: new AbortController().signal, + }); + try { + const switched = await authorizedWebFetch(`${firstWeb.url}/api/runtime/mode`, { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ mode: 'plan' }), + }); + assert.equal(switched.status, 200); + + const firstDecision = await first.approval(request('first')); + assert.equal(firstDecision.approved, false); + assert.match(firstDecision.reason, /Plan mode/); + + const secondDecision = second.approval(request('second')); + const firstPending = await fetch(`${firstWeb.url}/api/interactions`).then((response) => + response.json() + ); + const secondPending = await fetch(`${secondWeb.url}/api/interactions`).then((response) => + response.json() + ); + assert.equal(firstPending.interactions.length, 0); + assert.equal(secondPending.interactions.length, 1); + const secondBootstrap = await fetch(`${secondWeb.url}/api/bootstrap`).then((response) => + response.json() + ); + const resolved = await fetch( + `${secondWeb.url}/api/interactions/${secondPending.interactions[0].id}/resolve`, + { + method: 'POST', + headers: { + origin: secondWeb.url, + 'x-moss-csrf': secondBootstrap.csrfToken, + 'content-type': 'application/json', + }, + body: JSON.stringify({ answer: 'deny' }), + } + ); + assert.equal(resolved.status, 200); + assert.equal((await secondDecision).approved, false); + } finally { + await Promise.all([firstWeb.close(), secondWeb.close()]); + await Promise.all([first.agent.close(), second.agent.close()]); + } +}); diff --git a/packages/moss-agent/test/web-plugin-hot-reload.spec.mjs b/packages/moss-agent/test/web-plugin-hot-reload.spec.mjs new file mode 100644 index 00000000..cb915719 --- /dev/null +++ b/packages/moss-agent/test/web-plugin-hot-reload.spec.mjs @@ -0,0 +1,151 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { MossAgent } from '../dist/core/agent/moss-agent.js'; +import { InMemorySessionStore } from '../dist/core/session/session.js'; +import { InstalledPluginRegistry } from '../dist/plugins/installed-plugin-registry.js'; +import { startMossWebServer } from '../dist/web-ui/web-server.js'; + +function agent() { + return new MossAgent({ + llmProvider: { + id: 'web-plugin-test', + displayName: 'Web plugin test', + capabilities: { streaming: false }, + async complete() { + return { stopReason: 'end_turn', content: [] }; + }, + async stream() { + throw new Error('streaming disabled'); + }, + }, + sessionStore: new InMemorySessionStore(), + domainPrompt: false, + includeLanguagePolicyPrompt: false, + includeAgentBehaviorPrompt: false, + }); +} + +let csrfToken = ''; + +async function json(url, init) { + if (init?.method && !csrfToken) { + const bootstrap = await fetch(`${new URL(url).origin}/api/bootstrap`); + csrfToken = (await bootstrap.json()).csrfToken; + } + const response = await fetch(url, { + ...init, + headers: { + origin: new URL(url).origin, + 'content-type': 'application/json', + ...(csrfToken ? { 'x-moss-csrf': csrfToken } : {}), + ...init?.headers, + }, + }); + const body = await response.json(); + assert.equal(response.ok, true, JSON.stringify(body)); + return body; +} + +test('Web plugin mutations hot-apply one composition generation and keep secrets write-only', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'moss web plugin 配置 ')); + const configDir = path.join(root, 'config with spaces'); + const pluginRoot = path.join(root, 'plugin with spaces'); + await mkdir(pluginRoot, { recursive: true }); + await writeFile( + path.join(pluginRoot, 'moss.plugin.json'), + JSON.stringify({ + schemaVersion: 1, + id: 'web/hot-plugin', + version: '1.0.0', + runtime: { module: './plugin.mjs' }, + configSchema: './config.schema.json', + }) + ); + await writeFile( + path.join(pluginRoot, 'plugin.mjs'), + `export default { id: 'web/hot-plugin', setup(context) { + const label = typeof context.config.label === 'string' ? context.config.label : 'unset'; + if (label === 'reject') throw new Error('REJECT_CONFIG_CANDIDATE'); + const secretConfigured = typeof context.config.apiKey === 'string'; + context.registerCommand({ id: 'hot-command', title: 'Hot command', expand: (args) => 'HOT:' + label + ':' + secretConfigured + ':' + args }); + } };\n` + ); + await writeFile( + path.join(pluginRoot, 'config.schema.json'), + JSON.stringify({ + type: 'object', + properties: { + label: { type: 'string' }, + apiKey: { type: 'string', writeOnly: true }, + }, + }) + ); + const registry = new InstalledPluginRegistry({ configDir }); + await registry.add(pluginRoot); + const instance = agent(); + const web = await startMossWebServer(instance, { port: 0, configDir }); + try { + const before = (await json(`${web.url}/api/plugins`)).generation; + const enabled = await json( + `${web.url}/api/plugins/${encodeURIComponent('web/hot-plugin')}/enable`, + { + method: 'POST', + } + ); + assert.equal(enabled.restartRequired, false); + assert.equal(enabled.generation, before + 1); + assert.equal(await instance.plugins.expandCommand('hot-command', 'ok'), 'HOT:unset:false:ok'); + + const configured = await json( + `${web.url}/api/plugins/${encodeURIComponent('web/hot-plugin')}/config`, + { + method: 'PUT', + body: JSON.stringify({ values: { label: 'safe' } }), + } + ); + assert.equal(configured.restartRequired, false); + assert.equal(await instance.plugins.expandCommand('hot-command', 'ok'), 'HOT:safe:false:ok'); + await assert.rejects( + json(`${web.url}/api/plugins/${encodeURIComponent('web/hot-plugin')}/config`, { + method: 'PUT', + body: JSON.stringify({ values: { label: 'reject' } }), + }), + /REJECT_CONFIG_CANDIDATE/ + ); + assert.equal(await instance.plugins.expandCommand('hot-command', 'ok'), 'HOT:safe:false:ok'); + assert.deepEqual( + (await json(`${web.url}/api/plugins/${encodeURIComponent('web/hot-plugin')}/config`)).config + .values, + { label: 'safe' } + ); + const secretUrl = `${web.url}/api/plugins/${encodeURIComponent('web/hot-plugin')}/config/secrets/apiKey`; + const secret = await json(secretUrl, { + method: 'PUT', + body: JSON.stringify({ value: 'SECRET_MUST_NOT_RETURN' }), + }); + assert.deepEqual(secret.config, { + values: { label: 'safe' }, + secrets: { apiKey: { configured: true } }, + }); + assert.doesNotMatch(JSON.stringify(secret), /SECRET_MUST_NOT_RETURN/); + assert.equal(await instance.plugins.expandCommand('hot-command', 'ok'), 'HOT:safe:true:ok'); + + const disabled = await json( + `${web.url}/api/plugins/${encodeURIComponent('web/hot-plugin')}/disable`, + { + method: 'POST', + } + ); + assert.equal(disabled.restartRequired, false); + assert.equal(disabled.generation, secret.generation + 1); + assert.equal(instance.plugins.getCommand('hot-command'), undefined); + } finally { + await web.close(); + await instance.close(); + } +}); diff --git a/packages/moss-agent/test/web-plugin-lifecycle.spec.mjs b/packages/moss-agent/test/web-plugin-lifecycle.spec.mjs new file mode 100644 index 00000000..55bcc3e8 --- /dev/null +++ b/packages/moss-agent/test/web-plugin-lifecycle.spec.mjs @@ -0,0 +1,108 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { MossAgent } from '../dist/core/agent/moss-agent.js'; +import { InMemorySessionStore } from '../dist/core/session/session.js'; +import { + activateWebPluginCandidate, + disableWebPlugin, +} from '../dist/web-ui/web-plugin-lifecycle.js'; + +function plugin() { + return { + id: 'fixture/transaction', + setup(context) { + context.registerCommand({ + id: 'transaction-command', + title: 'Transaction command', + expand: () => 'LAST_GOOD', + }); + }, + }; +} + +function agent() { + return new MossAgent({ + llmProvider: { + id: 'plugin-transaction-test', + displayName: 'Plugin transaction test', + capabilities: { streaming: false }, + async complete() { + return { stopReason: 'end_turn', content: [] }; + }, + async stream() { + throw new Error('streaming disabled'); + }, + }, + sessionStore: new InMemorySessionStore(), + domainPrompt: false, + includeLanguagePolicyPrompt: false, + includeAgentBehaviorPrompt: false, + }); +} + +test('failed registry disable restores the last-good runtime generation', async () => { + const instance = agent(); + await instance.plugins.install(plugin()); + const generation = instance.plugins.inspect().generation; + const registry = { + async disable() { + throw new Error('REGISTRY_COMMIT_FAILED'); + }, + async loadInstalled() { + return plugin(); + }, + }; + try { + await assert.rejects( + disableWebPlugin(instance, registry, 'fixture/transaction'), + /REGISTRY_COMMIT_FAILED/ + ); + assert.equal(await instance.plugins.expandCommand('transaction-command', ''), 'LAST_GOOD'); + assert.equal(instance.plugins.inspect().generation, generation + 2); + } finally { + await instance.close(); + } +}); + +test('failed same-id candidate setup rolls the registry back without draining last-good', async () => { + const instance = agent(); + await instance.plugins.install(plugin()); + let rolledBack = false; + const registry = { + async loadInstalled() { + throw new Error('CANDIDATE_SETUP_FAILED'); + }, + async rollback() { + rolledBack = true; + }, + }; + try { + await assert.rejects( + activateWebPluginCandidate( + instance, + registry, + { + id: 'fixture/transaction', + version: '2.0.0', + source: 'fixture@2.0.0', + root: '/immutable/candidate', + enabled: true, + installedAt: new Date(0).toISOString(), + lastGood: { + version: '1.0.0', + source: 'fixture@1.0.0', + root: '/immutable/last-good', + }, + }, + true + ), + /CANDIDATE_SETUP_FAILED/ + ); + assert.equal(rolledBack, true); + assert.equal(await instance.plugins.expandCommand('transaction-command', ''), 'LAST_GOOD'); + } finally { + await instance.close(); + } +}); diff --git a/packages/moss-agent/test/web-public-api.spec.mjs b/packages/moss-agent/test/web-public-api.spec.mjs new file mode 100644 index 00000000..5300b5c2 --- /dev/null +++ b/packages/moss-agent/test/web-public-api.spec.mjs @@ -0,0 +1,21 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import test from 'node:test'; + +test('published Web subpath exposes stable slots and design tokens', async () => { + const manifest = JSON.parse( + await fs.readFile(new URL('../package.json', import.meta.url), 'utf8') + ); + assert.ok(manifest.exports['./web']); + + const web = await import('../dist/web-ui/index.js'); + assert.ok(web.MOSS_WEB_SLOTS.includes('conversation.message')); + assert.ok(web.MOSS_WEB_SLOTS.includes('settings.plugin')); + assert.ok(web.MOSS_WEB_THEME_TOKENS.includes('--moss-color-surface')); + assert.ok(web.MOSS_WEB_THEME_TOKENS.includes('--moss-radius-panel')); + + const source = await fs.readFile(new URL('../src/web-ui/index.ts', import.meta.url), 'utf8'); + assert.match(source, /MossWebPluginMountContext/); + assert.match(source, /MossWebPluginModule/); +}); diff --git a/packages/moss-agent/test/web-runtime-service.spec.mjs b/packages/moss-agent/test/web-runtime-service.spec.mjs new file mode 100644 index 00000000..bda2211e --- /dev/null +++ b/packages/moss-agent/test/web-runtime-service.spec.mjs @@ -0,0 +1,115 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { InMemorySessionStore } from '../dist/core/session/session.js'; +import { TaskRunLedger } from '../dist/core/task-run/task-run-ledger.js'; +import { MossAgent } from '../dist/core/agent/moss-agent.js'; +import { MossWebRuntimeService } from '../dist/web-ui/web-runtime-service.js'; + +function createAgent() { + return new MossAgent({ + llmProvider: { + id: 'web-runtime-test', + capabilities: { streaming: false }, + async complete() { + return { stopReason: 'end_turn', content: [] }; + }, + }, + sessionStore: new InMemorySessionStore(), + model: 'runtime-model', + domainPrompt: false, + includeLanguagePolicyPrompt: false, + includeAgentBehaviorPrompt: false, + subagentExperts: [ + { + id: 'reviewer', + displayName: 'Reviewer', + description: 'Reviews code', + instructions: 'Review only.', + scope: 'read-only', + }, + ], + }); +} + +test('Web runtime service reuses inbox, goal, async task, and run evidence owners', async () => { + const agent = createAgent(); + const ledger = new TaskRunLedger(); + const service = new MossWebRuntimeService(agent, ledger); + try { + const queued = service.admit('session-a', 'Do this next', 'queue'); + assert.equal(queued.delivery, 'queue'); + assert.equal(service.inbox('session-a')[0].prompt, 'Do this next'); + assert.equal( + service.steer('session-a', 'Change direction'), + null, + 'steer needs one active run' + ); + + const goal = await service.setGoal('session-a', 'Ship the Web runtime'); + assert.equal(goal.status, 'active'); + assert.equal((await service.goal('session-a')).objective, 'Ship the Web runtime'); + + agent.asyncTasks.start( + { taskId: 'job-a', kind: 'host_task', label: 'Background check', payload: {} }, + async (_request, signal) => { + await new Promise((resolve) => signal.addEventListener('abort', resolve, { once: true })); + return { success: false, summary: 'stopped' }; + } + ); + assert.equal(service.jobs()[0].taskId, 'job-a'); + assert.equal(service.stopJob('job-a'), true); + + ledger.create({ id: 'run-a', sessionId: 'session-a', title: 'Evidence run' }); + ledger.append('run-a', { type: 'run.started' }); + ledger.append('run-a', { type: 'tool.started', data: { name: 'read_file' } }); + ledger.append('run-a', { type: 'tool.succeeded', data: { name: 'read_file' } }); + ledger.append('run-a', { type: 'run.completed' }); + ledger.append('run-a', { type: 'run.verified' }); + const trajectory = service.trajectory('run-a'); + assert.equal(trajectory.run.verification, 'verified'); + assert.equal(trajectory.evidence.length, 2); + assert.equal(service.completionVerdict('run-a').verdict, 'verified'); + } finally { + await agent.close(); + } +}); + +test('Web runtime service dispatches the real slash registry and exposes mention inventory', async () => { + const agent = createAgent(); + const service = new MossWebRuntimeService(agent, new TaskRunLedger()); + try { + const result = await service.dispatchSlash('session-b', '/mode plan'); + assert.equal(result.handled, true); + assert.equal(result.mode, 'plan'); + assert.match(result.messages[0].text, /plan/i); + + const inventory = service.mentionInventory(); + assert.ok(inventory.commands.includes('/mode')); + assert.deepEqual( + inventory.experts.map(({ id }) => id), + ['reviewer'] + ); + } finally { + await agent.close(); + } +}); + +test('Web interaction mode is isolated between runtime service instances', async () => { + const firstAgent = createAgent(); + const secondAgent = createAgent(); + const first = new MossWebRuntimeService(firstAgent, new TaskRunLedger()); + const second = new MossWebRuntimeService(secondAgent, new TaskRunLedger()); + try { + first.setMode('plan'); + second.setMode('accept-edits'); + assert.equal(first.mode(), 'plan'); + assert.equal(second.mode(), 'acceptEdits'); + await first.dispatchSlash('first', '/mode default'); + assert.equal(first.mode(), 'default'); + assert.equal(second.mode(), 'acceptEdits'); + } finally { + await Promise.all([firstAgent.close(), secondAgent.close()]); + } +}); diff --git a/packages/moss-agent/test/web-session-service.spec.mjs b/packages/moss-agent/test/web-session-service.spec.mjs new file mode 100644 index 00000000..25520a59 --- /dev/null +++ b/packages/moss-agent/test/web-session-service.spec.mjs @@ -0,0 +1,177 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { authorizedWebFetch as fetch } from './web-authorized-fetch.mjs'; + +import { InMemorySessionStore } from '../dist/core/session/session.js'; +import { JsonlSessionStore } from '../dist/core/session/jsonl-session-store.js'; +import { TaskRunLedger } from '../dist/core/task-run/task-run-ledger.js'; +import { MossAgent } from '../dist/core/agent/moss-agent.js'; +import { MossWebSessionService } from '../dist/web-ui/web-session-service.js'; +import { startMossWebServer } from '../dist/web-ui/web-server.js'; + +function makeAgent(store, workspaceDir) { + return new MossAgent({ + llmProvider: { + id: 'web-session-service-test', + capabilities: { streaming: false }, + async complete() { + return { stopReason: 'end_turn', content: [] }; + }, + }, + sessionStore: store, + workspaceDir, + domainPrompt: false, + includeLanguagePolicyPrompt: false, + includeAgentBehaviorPrompt: false, + }); +} + +test('Web session service preserves source history across fork and rewind', async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'moss web sessions 空格 ')); + const metadataFile = path.join(tempDir, '.moss', 'web session metadata.json'); + const store = new JsonlSessionStore({ dir: path.join(tempDir, '会话 files') }); + const agent = makeAgent(store, tempDir); + const taskRuns = new TaskRunLedger(); + await store.replaceMessages('source/session', [ + { role: 'user', content: 'Find the alpha regression' }, + { role: 'assistant', content: [{ type: 'text', text: 'Alpha evidence' }] }, + { role: 'user', content: 'Continue with beta' }, + ]); + const service = new MossWebSessionService(agent, taskRuns, { metadataFile }); + try { + assert.deepEqual(await service.listWorkspaces(), [ + { id: 'current', name: path.basename(tempDir), current: true }, + ]); + assert.equal((await service.search('alpha'))[0].sessionId, 'source/session'); + + const renamed = await service.rename('source/session', 'Renamed investigation'); + assert.equal(renamed.title, 'Renamed investigation'); + const reloaded = new MossWebSessionService(agent, taskRuns, { metadataFile }); + assert.equal((await reloaded.listSessions())[0].title, 'Renamed investigation'); + + const markdown = await service.exportMarkdown('source/session'); + assert.match(markdown, /^# Session source\/session/m); + assert.match(markdown, /Alpha evidence/); + + const fork = await service.fork('source/session'); + assert.equal(fork.messageCount, 3); + assert.deepEqual( + await store.loadMessages(fork.sessionId), + await store.loadMessages('source/session') + ); + + const rewind = await service.rewind('source/session', 1); + assert.equal(rewind.sourceSessionId, 'source/session'); + assert.equal(rewind.truncated, 2); + assert.equal((await store.loadMessages(rewind.session.sessionId)).length, 1); + assert.equal((await store.loadMessages('source/session')).length, 3, 'source remains intact'); + + const created = await service.create(); + assert.equal(created.messageCount, 0); + assert.ok( + (await service.listSessions()).some(({ sessionId }) => sessionId === created.sessionId) + ); + + await assert.rejects( + service.delete('source/session', 'wrong confirmation'), + /delete confirmation must exactly match/ + ); + assert.equal(await store.exists('source/session'), true); + await service.delete('source/session', 'source/session'); + assert.equal(await store.exists('source/session'), false); + } finally { + await agent.close(); + await fs.rm(tempDir, { recursive: true, force: true }); + } +}); + +test('Web session HTTP protocol exposes mutations with exact delete confirmation', async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'moss-web-session-http-')); + const store = new InMemorySessionStore(); + const agent = makeAgent(store, tempDir); + const web = await startMossWebServer(agent, { port: 0 }); + try { + const workspaces = await fetch(`${web.url}/api/workspaces`).then((response) => response.json()); + assert.equal(workspaces.workspaces[0].id, 'current'); + + const created = await fetch(`${web.url}/api/sessions`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ workspaceId: 'current' }), + }).then((response) => response.json()); + await store.replaceMessages(created.sessionId, [ + { role: 'user', content: 'Protocol needle' }, + { role: 'assistant', content: 'Protocol result' }, + ]); + + const renamedResponse = await fetch( + `${web.url}/api/sessions/${encodeURIComponent(created.sessionId)}`, + { + method: 'PATCH', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ title: 'Protocol session' }), + } + ); + assert.equal(renamedResponse.status, 200); + assert.equal((await renamedResponse.json()).session.title, 'Protocol session'); + + const search = await fetch(`${web.url}/api/sessions/search?q=needle`).then((response) => + response.json() + ); + assert.equal(search.hits[0].sessionId, created.sessionId); + + const exported = await fetch( + `${web.url}/api/sessions/${encodeURIComponent(created.sessionId)}/export` + ); + assert.match(exported.headers.get('content-type') ?? '', /text\/markdown/); + assert.match(await exported.text(), /Protocol result/); + + const forked = await fetch( + `${web.url}/api/sessions/${encodeURIComponent(created.sessionId)}/fork`, + { method: 'POST' } + ).then((response) => response.json()); + assert.equal(forked.session.messageCount, 2); + + const rewound = await fetch( + `${web.url}/api/sessions/${encodeURIComponent(created.sessionId)}/rewind`, + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ messageCount: 1 }), + } + ).then((response) => response.json()); + assert.equal(rewound.session.messageCount, 1); + assert.equal((await store.loadMessages(created.sessionId)).length, 2); + + const deniedDelete = await fetch( + `${web.url}/api/sessions/${encodeURIComponent(created.sessionId)}`, + { + method: 'DELETE', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ confirmation: 'not-the-session' }), + } + ); + assert.equal(deniedDelete.status, 400); + assert.equal(await store.exists(created.sessionId), true); + + const deleted = await fetch( + `${web.url}/api/sessions/${encodeURIComponent(created.sessionId)}`, + { + method: 'DELETE', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ confirmation: created.sessionId }), + } + ); + assert.equal(deleted.status, 200); + assert.equal(await store.exists(created.sessionId), false); + } finally { + await web.close(); + await agent.close(); + await fs.rm(tempDir, { recursive: true, force: true }); + } +}); diff --git a/packages/moss-agent/test/web-settings-service.spec.mjs b/packages/moss-agent/test/web-settings-service.spec.mjs new file mode 100644 index 00000000..c69c6cd5 --- /dev/null +++ b/packages/moss-agent/test/web-settings-service.spec.mjs @@ -0,0 +1,112 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { InMemorySessionStore } from '../dist/core/session/session.js'; +import { MossAgent } from '../dist/core/agent/moss-agent.js'; +import { ConfigManager } from '../dist/cli/config-manager.js'; +import { CliServices } from '../dist/cli/cli-services.js'; +import { MossWebSettingsService } from '../dist/web-ui/web-settings-service.js'; + +function createAgent(workspaceDir) { + return new MossAgent({ + llmProvider: { + id: 'settings-test', + capabilities: { streaming: false }, + async complete() { + return { stopReason: 'end_turn', content: [] }; + }, + }, + sessionStore: new InMemorySessionStore(), + workspaceDir, + model: 'current-model', + domainPrompt: false, + includeLanguagePolicyPrompt: false, + includeAgentBehaviorPrompt: false, + }); +} + +test('Web settings validates and persists supported sections without returning credentials', async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'moss web settings 空格 ')); + const configPath = path.join(tempDir, 'config', 'config.json'); + const env = { ...process.env, MOSS_CONFIG_DIR: path.dirname(configPath) }; + const config = new ConfigManager(env); + const services = new CliServices(config); + const agent = createAgent(tempDir); + const settings = new MossWebSettingsService(agent, services, { configPath }); + try { + const invalid = settings.validate('permissions', { + safetyMode: 'anything-goes', + approvalPolicy: 'prompt', + }); + assert.equal(invalid.valid, false); + assert.match(invalid.errors.safetyMode, /read-only/); + + const saved = settings.save('permissions', { + safetyMode: 'workspace-write', + approvalPolicy: 'prompt', + trustedTools: ['read_file'], + deniedTools: ['exec'], + }); + assert.equal(saved.valid, true); + assert.equal(saved.dirty, false); + + settings.writeCredential('apiKey', 'secret-value-for-test'); + const models = await settings.section('models'); + assert.equal(models.values.apiKey, undefined); + assert.equal(models.credentials.apiKey.configured, true); + assert.equal( + JSON.stringify(await settings.snapshot()).includes('secret-value-for-test'), + false + ); + + const raw = JSON.parse(await fs.readFile(configPath, 'utf8')); + assert.notEqual(raw.apiKey, 'secret-value-for-test', 'credential is encrypted at rest'); + settings.deleteCredential('apiKey'); + assert.equal((await settings.section('models')).credentials.apiKey.configured, false); + } finally { + await agent.close(); + await fs.rm(tempDir, { recursive: true, force: true }); + } +}); + +test('Web settings model catalog delegates to CliServices and selection is persisted', async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'moss-web-model-settings-')); + const configPath = path.join(tempDir, 'config.json'); + const agent = createAgent(tempDir); + const fakeServices = { + config: new ConfigManager({ ...process.env, MOSS_CONFIG_FILE: configPath }), + models: { + async loadModelChoicesForRuntime() { + return { + choices: [ + { id: 'model-a', label: 'Model A', provider: 'openai' }, + { id: 'model-b', label: 'Model B', provider: 'openai' }, + ], + source: 'test catalog', + }; + }, + resolveModelSelection(input, choices) { + return choices.find(({ id }) => id === input) ?? null; + }, + }, + }; + const settings = new MossWebSettingsService(agent, fakeServices, { configPath }); + try { + const catalog = await settings.modelCatalog(); + assert.deepEqual( + catalog.choices.map(({ id }) => id), + ['model-a', 'model-b'] + ); + const selected = await settings.selectModel('model-b'); + assert.equal(selected.model, 'model-b'); + assert.equal(JSON.parse(await fs.readFile(configPath, 'utf8')).model, 'model-b'); + await assert.rejects(settings.selectModel('missing'), /unknown model/); + } finally { + await agent.close(); + await fs.rm(tempDir, { recursive: true, force: true }); + } +}); diff --git a/packages/moss-agent/test/web-ui-browser.spec.mjs b/packages/moss-agent/test/web-ui-browser.spec.mjs new file mode 100644 index 00000000..03336ebc --- /dev/null +++ b/packages/moss-agent/test/web-ui-browser.spec.mjs @@ -0,0 +1,579 @@ +#!/usr/bin/env node +/* global document, getComputedStyle, HTMLElement */ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import AxeBuilder from '@axe-core/playwright'; +import pixelmatch from 'pixelmatch'; +import { chromium } from 'playwright'; +import { PNG } from 'pngjs'; + +import { MossAgent } from '../dist/core/agent/moss-agent.js'; +import { InMemorySessionStore } from '../dist/core/session/session.js'; +import { createCliToolApprovalHook } from '../dist/cli/approval.js'; +import { startMossWebServer } from '../dist/web-ui/web-server.js'; +import { InstalledPluginRegistry } from '../dist/plugins/installed-plugin-registry.js'; +import { installConfiguredPlugins } from '../dist/cli/plugins-runtime.js'; + +const browserCandidates = [ + process.env.MOSS_BROWSER_EXECUTABLE, + chromium.executablePath(), + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge', + '/usr/bin/chromium', + '/usr/bin/google-chrome', +].filter(Boolean); + +let browserExecutable; +for (const candidate of browserCandidates) { + try { + await fs.access(candidate); + browserExecutable = candidate; + break; + } catch {} +} + +const visualRoot = path.join(import.meta.dirname, 'visual'); + +async function assertVisual(name, buffer) { + const baselinePath = path.join(visualRoot, 'baseline', `${name}.png`); + if (process.env.MOSS_UPDATE_VISUALS === '1') { + await fs.mkdir(path.dirname(baselinePath), { recursive: true }); + await fs.writeFile(baselinePath, buffer); + return; + } + const expected = PNG.sync.read(await fs.readFile(baselinePath)); + const actual = PNG.sync.read(buffer); + assert.equal(actual.width, expected.width, `${name} visual width changed`); + assert.equal(actual.height, expected.height, `${name} visual height changed`); + const diff = new PNG({ width: actual.width, height: actual.height }); + const changed = pixelmatch(expected.data, actual.data, diff.data, actual.width, actual.height, { + threshold: 0.18, + }); + const ratio = changed / (actual.width * actual.height); + if (ratio > 0.08) { + const diffPath = path.join(visualRoot, 'diff', `${name}.png`); + await fs.mkdir(path.dirname(diffPath), { recursive: true }); + await fs.writeFile(diffPath, PNG.sync.write(diff)); + } + assert.ok(ratio <= 0.08, `${name} visual changed by ${(ratio * 100).toFixed(2)}%`); +} + +async function waitForColumns(page, expected) { + await page.waitForFunction((widths) => { + const actual = Array.from( + document.querySelectorAll( + '.moss-sidebar-column, .moss-conversation-column, .moss-details-column' + ), + (column) => Math.round(column.getBoundingClientRect().width) + ); + return actual.every((width, index) => width === widths[index]); + }, expected); +} + +test( + 'Playwright workbench survives a complete browser message turn', + { + skip: browserExecutable ? false : 'no Chromium-compatible browser installed', + timeout: 60_000, + }, + async () => { + const agent = new MossAgent({ + llmProvider: { + id: 'web-browser-test', + displayName: 'Web browser test', + capabilities: { streaming: false }, + async complete(options) { + const transcript = JSON.stringify(options.messages); + const activeScenario = [ + ['tool', transcript.lastIndexOf('Run a browser tool.')], + ['approval', transcript.lastIndexOf('Require browser approval.')], + ['question', transcript.lastIndexOf('Ask a browser question.')], + ] + .filter(([, index]) => index >= 0) + .sort((left, right) => right[1] - left[1])[0]?.[0]; + if (activeScenario === 'tool') { + if (!transcript.includes('BROWSER_TOOL_OK')) { + return { + stopReason: 'tool_use', + content: [ + { type: 'tool_use', id: 'browser-tool', name: 'browser_delayed_tool', input: {} }, + ], + }; + } + return { + stopReason: 'end_turn', + content: [{ type: 'text', text: 'BROWSER_TOOL_COMPLETE' }], + }; + } + if (activeScenario === 'approval') { + if (!transcript.includes('APPROVAL_BROWSER_OK')) { + return { + stopReason: 'tool_use', + content: [ + { type: 'tool_use', id: 'browser-approval', name: 'browser_mutation', input: {} }, + ], + }; + } + return { + stopReason: 'end_turn', + content: [{ type: 'text', text: 'APPROVAL_FLOW_COMPLETE' }], + }; + } + if (activeScenario === 'question') { + if (!transcript.includes('QUESTION_BROWSER_OK:green')) { + return { + stopReason: 'tool_use', + content: [ + { type: 'tool_use', id: 'browser-question', name: 'browser_question', input: {} }, + ], + }; + } + return { + stopReason: 'end_turn', + content: [{ type: 'text', text: 'QUESTION_FLOW_COMPLETE' }], + }; + } + return { + stopReason: 'end_turn', + content: [{ type: 'text', text: 'BROWSER_TURN_OK' }], + }; + }, + async stream() { + throw new Error('streaming disabled'); + }, + }, + sessionStore: new InMemorySessionStore(), + hooks: { onBeforeToolExec: createCliToolApprovalHook('workspace-write', {}) }, + domainPrompt: false, + includeLanguagePolicyPrompt: false, + includeAgentBehaviorPrompt: false, + }); + agent.tools.register({ + name: 'browser_delayed_tool', + description: 'Return delayed browser fixture evidence.', + metadata: { sideEffectClass: 'readonly', planMode: 'allow' }, + inputSchema: { type: 'object', properties: {} }, + async execute() { + await new Promise((resolve) => setTimeout(resolve, 800)); + return 'BROWSER_TOOL_OK'; + }, + }); + agent.tools.register({ + name: 'browser_mutation', + description: 'Exercise the Web approval takeover.', + metadata: { sideEffectClass: 'local_write', planMode: 'deny' }, + inputSchema: { type: 'object', properties: {} }, + async execute() { + return 'APPROVAL_BROWSER_OK'; + }, + }); + agent.tools.register({ + name: 'browser_question', + description: 'Exercise the Web question takeover.', + metadata: { sideEffectClass: 'readonly', planMode: 'allow' }, + inputSchema: { type: 'object', properties: {} }, + async execute(_input, context) { + const answer = await context.askUserQuestion?.('Which fixture color should Moss use?'); + return `QUESTION_BROWSER_OK:${answer ?? 'missing'}`; + }, + }); + const configDir = await fs.mkdtemp(path.join(os.tmpdir(), 'moss-browser-plugin-')); + const pluginDir = path.join(configDir, 'browser-plugin'); + await fs.mkdir(pluginDir, { recursive: true }); + await fs.writeFile( + path.join(pluginDir, 'moss.plugin.json'), + JSON.stringify({ + schemaVersion: 1, + id: 'browser/fixture', + version: '1.0.0', + runtime: { module: './plugin.mjs' }, + web: { + contributions: [ + { id: 'shared-settings', slot: 'settings.plugin', module: './settings.js' }, + ], + }, + }) + ); + await fs.writeFile( + path.join(pluginDir, 'plugin.mjs'), + "export default { id: 'browser/fixture', setup() {} };\n" + ); + await fs.writeFile( + path.join(pluginDir, 'settings.js'), + "export default { async mount(root, context) { const ui = await import(context.componentsUrl); const dispose = ui.mountMossWebComponent(root, ui.createElement(ui.Card, null, ui.createElement('strong', null, 'BROWSER_PLUGIN_UI_OK'), ui.createElement(ui.Input, { label: 'Plugin setting', value: '', readOnly: true }), ui.createElement(ui.Button, null, 'Plugin action'))); return () => { dispose(); globalThis.__MOSS_FIRST_PLUGIN_DISPOSED__ = true; }; } };\n" + ); + const pluginRegistry = new InstalledPluginRegistry({ configDir }); + await pluginRegistry.add(pluginDir); + await pluginRegistry.enable('browser/fixture'); + const secondPluginDir = path.join(configDir, 'browser-plugin-two'); + await fs.mkdir(secondPluginDir, { recursive: true }); + await fs.writeFile( + path.join(secondPluginDir, 'moss.plugin.json'), + JSON.stringify({ + schemaVersion: 1, + id: 'browser/fixture-two', + version: '1.0.0', + runtime: { module: './plugin.mjs' }, + web: { + contributions: [ + { id: 'shared-settings', slot: 'settings.plugin', module: './settings.js' }, + ], + }, + }) + ); + await fs.writeFile( + path.join(secondPluginDir, 'plugin.mjs'), + "export default { id: 'browser/fixture-two', setup() {} };\n" + ); + await fs.writeFile( + path.join(secondPluginDir, 'settings.js'), + "export default { async mount(root) { root.textContent = 'BROWSER_PLUGIN_UI_TWO_OK'; await new Promise((resolve) => setTimeout(resolve, 250)); return () => { throw new Error('EXPECTED_DISPOSER_FAILURE'); }; } };\n" + ); + await pluginRegistry.add(secondPluginDir); + await pluginRegistry.enable('browser/fixture-two'); + await installConfiguredPlugins(agent, configDir); + const web = await startMossWebServer(agent, { port: 0, configDir }); + let browser; + let browserContext; + try { + browser = await chromium.launch({ + executablePath: browserExecutable, + headless: true, + args: process.platform === 'linux' ? ['--no-sandbox', '--disable-setuid-sandbox'] : [], + }); + browserContext = await browser.newContext({ viewport: { width: 1440, height: 960 } }); + const page = await browserContext.newPage(); + const pageErrors = []; + page.on('pageerror', (error) => pageErrors.push(error.message)); + await page.goto(web.url, { waitUntil: 'domcontentloaded', timeout: 30_000 }); + await page.waitForSelector('.app-frame', { timeout: 10_000 }); + await page.waitForFunction( + 'document.querySelector(".workbench-state[aria-busy=true]") === null', + undefined, + { timeout: 10_000 } + ); + await waitForColumns(page, [252, 868, 320]); + await page.keyboard.press('Tab'); + assert.equal( + await page.evaluate(() => globalThis.document.activeElement?.className), + 'moss-skip-link', + 'keyboard users reach the skip link first' + ); + await page.evaluate(() => { + if (document.activeElement instanceof HTMLElement) document.activeElement.blur(); + }); + const accessibility = await new AxeBuilder({ page }).analyze(); + assert.deepEqual( + accessibility.violations.filter( + ({ impact }) => impact === 'critical' || impact === 'serious' + ), + [], + 'workbench has no serious accessibility violations' + ); + await assertVisual('desktop-home', await page.screenshot({ type: 'png' })); + await page.setViewportSize({ width: 1120, height: 800 }); + await waitForColumns(page, [252, 560, 308]); + await assertVisual('narrow-home', await page.screenshot({ type: 'png' })); + await page.setViewportSize({ width: 820, height: 1180 }); + await waitForColumns(page, [64, 756, 0]); + await assertVisual('tablet-home', await page.screenshot({ type: 'png' })); + await page.setViewportSize({ width: 1440, height: 960 }); + await waitForColumns(page, [252, 868, 320]); + assert.equal( + await page.$eval('.moss-skip-link', (link) => link.getAttribute('href')), + '#moss-main-content' + ); + const desktopColumns = await page.$$eval( + '.moss-sidebar-column, .moss-conversation-column, .moss-details-column', + (columns) => columns.map((column) => Math.round(column.getBoundingClientRect().width)) + ); + assert.deepEqual( + desktopColumns, + [252, 868, 320], + 'desktop keeps the stable three-column frame' + ); + assert.equal(await page.$$eval('[data-resize-handle]', (handles) => handles.length), 2); + const sidebarHandle = await page.$('[data-resize-handle="sidebar"]'); + const sidebarHandleBox = await sidebarHandle.boundingBox(); + assert.ok(sidebarHandleBox); + await page.mouse.move(sidebarHandleBox.x + sidebarHandleBox.width / 2, 200); + await page.mouse.down(); + await page.mouse.move(sidebarHandleBox.x + 38, 200, { steps: 4 }); + await page.mouse.up(); + const resizedSidebar = await page.$eval('.moss-sidebar-column', (sidebar) => + Math.round(sidebar.getBoundingClientRect().width) + ); + assert.ok(resizedSidebar > 252, 'sidebar drag increases the live column width'); + assert.match( + await page.evaluate(() => localStorage.getItem('moss-layout-v1') ?? ''), + /sidebarWidth/, + 'layout preference is durable' + ); + await page.click('[aria-label="Toggle navigation panel"]'); + await page.waitForFunction( + () => + Math.round( + document.querySelector('.moss-sidebar-column').getBoundingClientRect().width + ) === 64 + ); + assert.equal( + await page.$eval('.moss-sidebar-column', (sidebar) => + Math.round(sidebar.getBoundingClientRect().width) + ), + 64, + 'sidebar toggle keeps a compact mounted rail' + ); + await page.click('[aria-label="Toggle navigation panel"]'); + await page.waitForFunction( + (expected) => + Math.round( + globalThis.document.querySelector('.moss-sidebar-column').getBoundingClientRect().width + ) === expected, + resizedSidebar + ); + assert.equal( + await page.$eval('.moss-sidebar-column', (sidebar) => + Math.round(sidebar.getBoundingClientRect().width) + ), + resizedSidebar, + 'expanding restores the persisted width' + ); + const screenshot = await page.screenshot({ type: 'png' }); + assert.ok( + screenshot.byteLength > 20_000, + 'fixed viewport produces a non-empty visual sample' + ); + await page.click('.sidebar-footer > button'); + assert.equal( + await page.$$eval('.settings-view > .moss-tabs button', (tabs) => tabs.length), + 7 + ); + for (const section of [ + 'General', + 'Models', + 'Permissions', + 'Skills', + 'MCP', + 'Plugins', + 'Runtime', + ]) { + await page.getByRole('tab', { name: section }).click(); + await page.waitForFunction( + (title) => document.querySelector('.settings-section > h3')?.textContent === title, + section + ); + } + await page.getByRole('tab', { name: 'General' }).click(); + await assertVisual('desktop-settings', await page.screenshot({ type: 'png' })); + await page.getByRole('tab', { name: 'Plugins' }).click(); + await page.waitForFunction( + () => { + const content = Array.from( + document.querySelectorAll('[data-moss-slot="settings.plugin"] > div'), + (node) => node.shadowRoot?.textContent ?? '' + ); + return ( + content.some((text) => text.includes('BROWSER_PLUGIN_UI_OK')) && + content.some((text) => text.includes('BROWSER_PLUGIN_UI_TWO_OK')) + ); + }, + undefined, + { timeout: 10_000 } + ); + const pluginSlotState = await page.evaluate(() => + Array.from( + document.querySelectorAll('[data-moss-slot="settings.plugin"] > div'), + (node) => ({ + generation: node.getAttribute('data-moss-plugin-generation'), + text: node.shadowRoot?.textContent ?? '', + }) + ) + ); + assert.equal( + pluginSlotState.length, + 2, + `each plugin owns a distinct slot host; page errors: ${pageErrors.join(' | ')}` + ); + await assertVisual('desktop-plugins', await page.screenshot({ type: 'png' })); + await page.getByRole('button', { name: /Run doctor/ }).click(); + await page.waitForFunction(() => document.body.textContent.includes('Doctor · generation')); + await page.click('.new-task'); + await page.waitForSelector('.composer-shell textarea'); + await page.waitForFunction(() => globalThis.__MOSS_FIRST_PLUGIN_DISPOSED__ === true); + assert.equal(await page.evaluate(() => globalThis.__MOSS_FIRST_PLUGIN_DISPOSED__), true); + const notePath = path.join(configDir, 'browser-note.txt'); + await fs.writeFile(notePath, 'ATTACHMENT_BROWSER_OK'); + await page.locator('input[type="file"]').setInputFiles(notePath); + await page.waitForFunction(() => document.body.textContent.includes('ready')); + await page.type('textarea', 'Complete the browser turn.'); + assert.match( + await page.evaluate(() => localStorage.getItem('moss-workbench-v2') ?? ''), + /Complete the browser turn/ + ); + await page.keyboard.press('Enter'); + await page.waitForFunction( + () => document.body.textContent.includes('BROWSER_TURN_OK'), + undefined, + { timeout: 10_000 } + ); + assert.equal(await page.inputValue('textarea'), '', 'send clears the active session draft'); + assert.equal(await page.$$eval('.message', (messages) => messages.length), 2); + await page.getByRole('tab', { name: 'Plan' }).click(); + await assertVisual('desktop-plan', await page.screenshot({ type: 'png' })); + await page.type('textarea', 'Run a browser tool.'); + await page.keyboard.press('Enter'); + await page.waitForFunction(() => document.body.textContent.includes('Moss is working')); + await assertVisual('desktop-running', await page.screenshot({ type: 'png' })); + await page.waitForSelector('.tool-row.tool-running'); + await page.waitForSelector('.tool-row.tool-complete', { timeout: 10_000 }); + assert.equal( + await page.inputValue('textarea'), + '', + 'tool turns do not restore a stale draft' + ); + await page.click('.tool-row.tool-complete'); + await page.waitForFunction(() => document.body.textContent.includes('BROWSER_TOOL_OK')); + await assertVisual('desktop-tool', await page.screenshot({ type: 'png' })); + await page.type('textarea', 'Require browser approval.'); + await page.keyboard.press('Enter'); + await page.waitForSelector('[role="alertdialog"][aria-label="Approval required"]'); + await assertVisual('desktop-approval', await page.screenshot({ type: 'png' })); + await page.getByRole('button', { name: 'Allow once' }).click(); + await page.waitForFunction(() => + document.body.textContent.includes('APPROVAL_FLOW_COMPLETE') + ); + await page.type('textarea', 'Ask a browser question.'); + await page.keyboard.press('Enter'); + await page.waitForSelector('[role="alertdialog"][aria-label="Moss has a question"]'); + await page.getByRole('textbox', { name: 'Answer' }).fill('green'); + await page.getByRole('button', { name: 'Answer' }).click(); + await page.waitForFunction(() => + document.body.textContent.includes('QUESTION_FLOW_COMPLETE') + ); + await page.setViewportSize({ width: 980, height: 900 }); + await page.waitForFunction( + () => + Math.round( + document.querySelector('.moss-sidebar-column').getBoundingClientRect().width + ) === 64 + ); + await page.click('[aria-label="Toggle navigation panel"]'); + await page.waitForFunction( + (expected) => + Math.round( + globalThis.document.querySelector('.moss-sidebar-column').getBoundingClientRect().width + ) === expected, + resizedSidebar + ); + assert.equal( + await page.$eval('.moss-sidebar-column', (sidebar) => + Math.round(sidebar.getBoundingClientRect().width) + ), + resizedSidebar, + 'narrow desktop allows an explicit sidebar expansion' + ); + assert.equal( + await page.$eval('.moss-details-column', (details) => + Math.round(details.getBoundingClientRect().width) + ), + 0, + 'details concedes before the center column when the narrow sidebar expands' + ); + await page.click('[aria-label="Toggle navigation panel"]'); + await page.waitForFunction( + () => + Math.round( + document.querySelector('.moss-sidebar-column').getBoundingClientRect().width + ) === 64 + ); + await assertVisual('narrow-conversation', await page.screenshot({ type: 'png' })); + await page.setViewportSize({ width: 740, height: 900 }); + await page.waitForFunction( + () => + getComputedStyle(document.querySelector('[data-mobile-drawer="sidebar"]')).visibility === + 'hidden' + ); + assert.equal( + await page.$eval( + '[data-mobile-drawer="sidebar"]', + (sidebar) => globalThis.getComputedStyle(sidebar).visibility + ), + 'hidden' + ); + await page.click('[aria-label="Toggle navigation panel"]'); + await page.waitForFunction( + () => + getComputedStyle(document.querySelector('[data-mobile-drawer="sidebar"]')).visibility === + 'visible' + ); + assert.equal( + await page.$eval( + '[data-mobile-drawer="sidebar"]', + (sidebar) => globalThis.getComputedStyle(sidebar).visibility + ), + 'visible', + 'mobile navigation opens as a drawer' + ); + await page.click('.moss-drawer-backdrop'); + await page.waitForFunction( + () => + getComputedStyle(document.querySelector('[data-mobile-drawer="sidebar"]')).visibility === + 'hidden' + ); + await page.click('[aria-label="Toggle details panel"]'); + await page.waitForFunction( + () => + getComputedStyle(document.querySelector('[data-mobile-drawer="details"]')).visibility === + 'visible' + ); + assert.equal( + await page.$eval( + '[data-mobile-drawer="details"]', + (details) => globalThis.getComputedStyle(details).visibility + ), + 'visible', + 'mobile details opens independently as a drawer' + ); + assert.ok( + (await page.$eval( + '[data-mobile-drawer="details"]', + (panel) => panel.getBoundingClientRect().width + )) <= 330, + 'tablet details panel becomes a bounded drawer' + ); + await page.goto(`${web.url}/#gallery`, { waitUntil: 'domcontentloaded', timeout: 30_000 }); + await page.waitForSelector('[data-moss-component-gallery]'); + for (const primitive of [ + 'button', + 'input', + 'tabs', + 'toast', + 'tooltip', + 'card', + 'disclosure', + 'code', + 'diff', + 'terminal', + 'menu', + ]) { + assert.ok(await page.$(`[data-moss-ui="${primitive}"]`), `gallery renders ${primitive}`); + } + await page.click('[data-gallery-action="dialog"]'); + await page.waitForSelector('[data-moss-ui="dialog"] [role="dialog"]'); + await assertVisual('tablet-gallery-dialog', await page.screenshot({ type: 'png' })); + await page.keyboard.press('Escape'); + await page.waitForFunction(() => document.querySelector('[data-moss-ui="dialog"]') === null); + await assertVisual('tablet-gallery', await page.screenshot({ type: 'png' })); + assert.deepEqual(pageErrors, []); + } finally { + if (browserContext) await browserContext.close(); + if (browser) await browser.close(); + await web.close(); + await agent.close(); + } + } +); diff --git a/packages/moss-agent/test/web-ui.spec.mjs b/packages/moss-agent/test/web-ui.spec.mjs index d2f76f5d..60a77fe9 100644 --- a/packages/moss-agent/test/web-ui.spec.mjs +++ b/packages/moss-agent/test/web-ui.spec.mjs @@ -1,18 +1,26 @@ #!/usr/bin/env node import assert from 'node:assert/strict'; +import http from 'node:http'; import test from 'node:test'; +import { authorizedWebFetch as fetch } from './web-authorized-fetch.mjs'; +import { mkdtemp, mkdir, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + import { MossAgent } from '../dist/core/agent/moss-agent.js'; import { InMemorySessionStore } from '../dist/core/session/session.js'; import { startMossWebServer } from '../dist/web-ui/web-server.js'; -import { WEB_HTML, WEB_JS } from '../dist/web-ui/web-assets.js'; - -test('browser asset is valid JavaScript', () => { - assert.doesNotThrow(() => new Function(WEB_JS)); - assert.match(WEB_HTML, /role="status" aria-live="polite"/); - assert.match(WEB_HTML, /role="alert" hidden/); - assert.match(WEB_JS, /window\.addEventListener\('online',bootstrap\)/); - assert.match(WEB_JS, /data-run/); +import { WEB_HTML } from '../dist/web-ui/web-assets.js'; +import { InstalledPluginRegistry } from '../dist/plugins/installed-plugin-registry.js'; +import { installConfiguredPlugins } from '../dist/cli/plugins-runtime.js'; + +test('browser shell mounts the bundled React workbench and stable design system', () => { + assert.match(WEB_HTML, /id="moss-web-root"/); + assert.match(WEB_HTML, /data-moss-surface="workbench"/); + assert.match(WEB_HTML, /href="\/assets\/workbench\.css"/); + assert.match(WEB_HTML, /type="module" src="\/assets\/workbench\.js"/); + assert.doesNotMatch(WEB_HTML, /class="rail"/); }); function makeAgent(provider) { @@ -35,6 +43,16 @@ function makeAgent(provider) { return agent; } +function requestWithHost(url, host) { + return new Promise((resolve, reject) => { + const request = http.get(url, { headers: { host } }, (response) => { + response.resume(); + response.once('end', () => resolve(response.statusCode)); + }); + request.once('error', reject); + }); +} + test('web host boots on loopback and streams real tool evidence', async () => { let turn = 0; const agent = makeAgent({ @@ -58,13 +76,35 @@ test('web host boots on loopback and streams real tool evidence', async () => { try { assert.equal(web.host, '127.0.0.1'); const page = await fetch(web.url).then((response) => response.text()); - assert.match(page, /Build with an agent that shows its work/); - assert.match(page, /Capabilities/); + assert.match(page, /id="moss-web-root"/); + const legacy = await fetch(`${web.url}/?legacy=1`).then((response) => response.text()); + assert.match(legacy, /Legacy workspace · temporary rollback/); + const clientScript = await fetch(`${web.url}/assets/workbench.js`); + assert.equal(clientScript.status, 200); + assert.match(clientScript.headers.get('content-type') ?? '', /javascript/); + assert.doesNotMatch(await clientScript.text(), /\bprocess\.env\b/); + assert.equal((await fetch(`${web.url}/assets/moss-web-components.js`)).status, 200); + assert.equal((await fetch(`${web.url}/assets/moss-web-components.css`)).status, 200); + const clientStyles = await fetch(`${web.url}/assets/workbench.css`).then((response) => + response.text() + ); + assert.match(clientStyles, /--moss-color-surface:/); + assert.match(clientStyles, /--moss-space-4:/); + assert.match(clientStyles, /--moss-radius-panel:/); + const componentRules = clientStyles.replace(/:root[^{]*{[^}]*}/gs, ''); + assert.doesNotMatch( + componentRules, + /#[\da-f]{3,8}\b/i, + 'component rules consume design tokens instead of parallel hard-coded colors' + ); const bootstrap = await fetch(`${web.url}/api/bootstrap`).then((response) => response.json()); assert.ok(bootstrap.tools.includes('web_fixture')); assert.equal(bootstrap.model, 'Configured model'); assert.doesNotMatch(JSON.stringify(bootstrap), /apiKey|Authorization|secret/i); + const sessions = await fetch(`${web.url}/api/sessions`).then((response) => response.json()); + assert.equal(sessions.sessions.length, 0); + const created = await fetch(`${web.url}/api/sessions`, { method: 'POST' }).then((response) => response.json() ); @@ -84,6 +124,29 @@ test('web host boots on loopback and streams real tool evidence', async () => { assert.match(stream, /"status":"completed"/); assert.match(stream, /"verification":"unverified"/); + const resumedSessions = await fetch(`${web.url}/api/sessions`).then((response) => + response.json() + ); + assert.equal(resumedSessions.sessions.length, 1); + assert.equal(resumedSessions.sessions[0].sessionId, created.sessionId); + assert.equal(resumedSessions.sessions[0].title, 'Use the fixture.'); + + const timeline = await fetch( + `${web.url}/api/sessions/${encodeURIComponent(created.sessionId)}/messages` + ).then((response) => response.json()); + assert.ok( + timeline.items.some((item) => item.kind === 'user' && item.text === 'Use the fixture.') + ); + assert.ok( + timeline.items.some( + (item) => + item.kind === 'tool' && item.name === 'web_fixture' && item.result === 'WEB_FIXTURE_OK' + ) + ); + assert.ok( + timeline.items.some((item) => item.kind === 'assistant' && item.text === 'WEB_FIXTURE_OK') + ); + const terminal = stream .trim() .split('\n') @@ -110,6 +173,102 @@ test('web host boots on loopback and streams real tool evidence', async () => { headers: { origin: 'https://example.invalid' }, }); assert.equal(denied.status, 403); + + const otherLocalPort = await fetch(`${web.url}/api/sessions`, { + method: 'POST', + headers: { origin: 'http://127.0.0.1:65535' }, + }); + assert.equal(otherLocalPort.status, 403); + + const wrongScheme = await fetch(`${web.url}/api/sessions`, { + method: 'POST', + headers: { origin: web.url.replace('http:', 'https:') }, + }); + assert.equal(wrongScheme.status, 403); + + const secondStream = await fetch( + `${web.url}/api/sessions/${encodeURIComponent(created.sessionId)}/messages`, + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ prompt: 'Run the newer turn.' }), + } + ).then((response) => response.text()); + const secondTerminal = secondStream + .trim() + .split('\n') + .map((line) => JSON.parse(line)) + .find((event) => event.type === 'done'); + const sessionsAfterSecondTurn = await fetch(`${web.url}/api/sessions`).then((response) => + response.json() + ); + assert.equal(sessionsAfterSecondTurn.sessions[0].runId, secondTerminal.run.id); + assert.equal(sessionsAfterSecondTurn.sessions[0].title, 'Run the newer turn.'); + } finally { + await web.close(); + await agent.close(); + } +}); + +test('web host formats an IPv6 loopback origin when the platform supports it', async (context) => { + const agent = makeAgent({ + id: 'web-ipv6-test', + displayName: 'Web IPv6 test', + capabilities: { streaming: false }, + async complete() { + return { stopReason: 'end_turn', content: [] }; + }, + async stream() { + throw new Error('streaming disabled'); + }, + }); + let web; + try { + web = await startMossWebServer(agent, { port: 0, host: '::1' }); + } catch (error) { + await agent.close(); + if (error?.code === 'EADDRNOTAVAIL' || error?.code === 'EAFNOSUPPORT') { + context.skip('IPv6 loopback is unavailable'); + return; + } + throw error; + } + try { + assert.match(web.url, /^http:\/\/\[::1\]:\d+$/); + assert.equal(await requestWithHost(`${web.url}/api/bootstrap`, `[::1]:${web.port}`), 200); + } finally { + await web.close(); + await agent.close(); + } +}); + +test('web host projects provider failures and records a failed run', async () => { + const agent = makeAgent({ + id: 'web-error-test', + displayName: 'Web error test', + capabilities: { streaming: false }, + async complete() { + throw new Error('WEB_PROVIDER_FAILURE'); + }, + async stream() { + throw new Error('streaming disabled'); + }, + }); + const web = await startMossWebServer(agent, { port: 0 }); + try { + const { sessionId } = await fetch(`${web.url}/api/sessions`, { method: 'POST' }).then( + (response) => response.json() + ); + const stream = await fetch(`${web.url}/api/sessions/${sessionId}/messages`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ prompt: 'Fail this turn.' }), + }).then((response) => response.text()); + assert.match(stream, /"type":"error".*WEB_PROVIDER_FAILURE/); + assert.match(stream, /"type":"done","stopReason":"error"/); + const runs = await fetch(`${web.url}/api/runs`).then((response) => response.json()); + assert.equal(runs.runs[0].status, 'failed'); + assert.equal(runs.runs[0].title, 'Fail this turn.'); } finally { await web.close(); await agent.close(); @@ -148,6 +307,12 @@ test('web cancellation aborts an active model turn', async () => { body: JSON.stringify({ prompt: 'Wait.' }), }).then((response) => response.text()); await startedGate; + const concurrent = await fetch(`${web.url}/api/sessions/${sessionId}/messages`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ prompt: 'Do not overlap.' }), + }); + assert.equal(concurrent.status, 409); const cancelled = await fetch(`${web.url}/api/sessions/${sessionId}/cancel`, { method: 'POST', }).then((response) => response.json()); @@ -158,3 +323,123 @@ test('web cancellation aborts an active model turn', async () => { await agent.close(); } }); + +test('web plugin settings expose redacted inventory and require local-origin mutation', async () => { + const configDir = await mkdtemp(path.join(os.tmpdir(), 'moss-web-plugins-')); + const pluginDir = path.join(configDir, 'fixture-plugin'); + await mkdir(pluginDir, { recursive: true }); + await writeFile( + path.join(pluginDir, 'moss.plugin.json'), + JSON.stringify({ + schemaVersion: 1, + id: 'fixture/plugin', + version: '1.0.0', + runtime: { module: './plugin.mjs' }, + web: { + contributions: [ + { id: 'fixture-settings', slot: 'settings.plugin', module: './settings.js' }, + ], + }, + }) + ); + await writeFile( + path.join(pluginDir, 'plugin.mjs'), + "export default { id: 'fixture/plugin', setup() {} };\n" + ); + await writeFile( + path.join(pluginDir, 'settings.js'), + "export default { mount(root) { root.textContent = 'FIXTURE_PLUGIN_UI'; } };\n" + ); + const pluginRegistry = new InstalledPluginRegistry({ configDir }); + await pluginRegistry.add(pluginDir); + await pluginRegistry.enable('fixture/plugin'); + const brokenPluginDir = path.join(configDir, 'broken-web-plugin'); + await mkdir(brokenPluginDir, { recursive: true }); + await writeFile( + path.join(brokenPluginDir, 'moss.plugin.json'), + JSON.stringify({ + schemaVersion: 1, + id: 'zz/broken-web', + version: '1.0.0', + runtime: { module: './plugin.mjs' }, + web: { + contributions: [ + { id: 'broken-settings', slot: 'settings.plugin', module: './settings.js' }, + ], + }, + }) + ); + await writeFile( + path.join(brokenPluginDir, 'plugin.mjs'), + "export default { id: 'zz/broken-web', setup() { throw new Error('BROKEN_WEB_SETUP'); } };\n" + ); + await writeFile( + path.join(brokenPluginDir, 'settings.js'), + "export default { mount(root) { root.textContent = 'MUST_NOT_LOAD'; } };\n" + ); + await pluginRegistry.add(brokenPluginDir); + await pluginRegistry.enable('zz/broken-web'); + const agent = makeAgent({ + id: 'web-plugin-test', + displayName: 'Web plugin test', + capabilities: { streaming: false }, + async complete() { + return { stopReason: 'end_turn', content: [] }; + }, + async stream() { + throw new Error('streaming disabled'); + }, + }); + const originalError = console.error; + console.error = () => {}; + try { + await installConfiguredPlugins(agent, configDir); + } finally { + console.error = originalError; + } + const web = await startMossWebServer(agent, { port: 0, configDir }); + try { + const inventory = await fetch(`${web.url}/api/plugins`).then((response) => response.json()); + assert.equal(inventory.installed[0].id, 'fixture/plugin'); + assert.equal(inventory.contributions[0].slot, 'settings.plugin'); + assert.equal( + inventory.contributions.some(({ pluginId }) => pluginId === 'zz/broken-web'), + false + ); + assert.doesNotMatch(JSON.stringify(inventory), /secret|apiKey|Authorization/i); + assert.equal('root' in inventory.installed[0], false); + assert.equal('source' in inventory.installed[0], false); + const pluginAsset = await fetch(`${web.url}${inventory.contributions[0].moduleUrl}`); + assert.equal(pluginAsset.status, 200); + assert.match(await pluginAsset.text(), /FIXTURE_PLUGIN_UI/); + await writeFile( + path.join(pluginDir, 'settings.js'), + "export default { mount(root) { root.textContent = 'CHANGED_AFTER_START'; } };\n" + ); + const brokenAsset = await fetch(`${web.url}/plugin-assets/zz%2Fbroken-web/broken-settings.js`); + assert.equal(brokenAsset.status, 404); + + assert.equal(await requestWithHost(`${web.url}/api/bootstrap`, 'attacker.example'), 403); + + const denied = await fetch(`${web.url}/api/plugins/fixture%2Fplugin/disable`, { + method: 'POST', + headers: { origin: 'https://example.invalid' }, + }); + assert.equal(denied.status, 403); + + const disabled = await fetch(`${web.url}/api/plugins/fixture%2Fplugin/disable`, { + method: 'POST', + }).then((response) => response.json()); + assert.equal(disabled.restartRequired, false); + const refreshed = await fetch(`${web.url}/api/plugins`).then((response) => response.json()); + assert.equal(refreshed.installed[0].enabled, false); + assert.equal( + refreshed.contributions.some(({ pluginId }) => pluginId === 'fixture/plugin'), + false + ); + assert.equal((await fetch(`${web.url}${inventory.contributions[0].moduleUrl}`)).status, 404); + } finally { + await web.close(); + await agent.close(); + } +}); diff --git a/packages/moss-agent/test/web-workbench-capabilities.spec.mjs b/packages/moss-agent/test/web-workbench-capabilities.spec.mjs new file mode 100644 index 00000000..24e7b1a9 --- /dev/null +++ b/packages/moss-agent/test/web-workbench-capabilities.spec.mjs @@ -0,0 +1,120 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const client = path.resolve(here, '../src/web-ui/client'); + +test('workbench source keeps durable session and resumable stream contracts visible', async () => { + const [api, preferences, workbench] = await Promise.all([ + fs.readFile(path.join(client, 'api-client.ts'), 'utf8'), + fs.readFile(path.join(client, 'workbench-preferences.ts'), 'utf8'), + fs.readFile(path.join(client, 'workbench.tsx'), 'utf8'), + ]); + assert.match(api, /EventSource/); + assert.match(api, /after/); + assert.match(api, /lastEventId/); + assert.match(api, /\/api\/plugins\/events/); + assert.match(api, /connectPluginComposition/); + assert.match(preferences, /moss-workbench-v2/); + assert.match(preferences, /draft/); + assert.match(preferences, /scrollTop/); + assert.match(workbench, /SessionSidebar/); + assert.match( + await fs.readFile(path.join(client, 'session-sidebar.tsx'), 'utf8'), + /WorkspacePicker/ + ); +}); + +test('plugin UI generations clear owned ShadowRoot content before remount', async () => { + const source = await fs.readFile(path.join(client, 'plugin-slot.tsx'), 'utf8'); + assert.match(source, /mossPluginGeneration/); + assert.match(source, /replaceChildren\(\)/); + assert.match(source, /dispose/); +}); + +test('workbench exposes every planned control and settings surface', async () => { + const files = await Promise.all( + ['composer.tsx', 'session-sidebar.tsx', 'settings-center.tsx', 'details-panel.tsx'].map( + (file) => fs.readFile(path.join(client, file), 'utf8') + ) + ); + const source = files.join('\n'); + for (const label of [ + 'plan', + 'acceptEdits', + 'queue', + 'steer', + 'General', + 'Models', + 'Permissions', + 'Skills', + 'MCP', + 'Plugins', + 'Runtime', + 'Trajectory', + 'Evidence', + 'Completion verdict', + ]) { + assert.ok(source.includes(label), `missing planned UI surface: ${label}`); + } +}); + +test('timeline includes rich tool renderers and complete stream states', async () => { + const source = `${await fs.readFile(path.join(client, 'conversation-timeline.tsx'), 'utf8')}\n${await fs.readFile(path.join(client, 'workbench.tsx'), 'utf8')}`; + for (const token of [ + 'retry', + 'compaction', + 'usage', + 'context', + 'interrupted', + 'diff', + 'terminal', + 'read', + 'edit', + 'search', + 'web', + ]) { + assert.ok(source.toLowerCase().includes(token), `missing timeline capability: ${token}`); + } +}); + +test('commands, attachments, CSRF and plugin lifecycle use real server contracts', async () => { + const [api, composer, settings] = await Promise.all([ + fs.readFile(path.join(client, 'api-client.ts'), 'utf8'), + fs.readFile(path.join(client, 'composer.tsx'), 'utf8'), + fs.readFile(path.join(client, 'settings-center.tsx'), 'utf8'), + ]); + assert.match(api, /x-moss-csrf/); + assert.match(api, /contentBase64/); + assert.match(api, /attachmentIds/); + assert.match(api, /workspaceRelativePath/); + assert.match(composer, /if \(text\.startsWith\('\/'\)\) \{/); + assert.match(composer, /onCommand\(text\)/); + assert.doesNotMatch(composer, /text\.slice\(1\)/); + for (const capability of ['addPlugin', 'doctorPlugins', 'pluginConfig', 'putPluginSecret']) { + assert.ok(api.includes(capability), `missing plugin API: ${capability}`); + } + assert.match( + settings, + /Local path, exact npm package, official source, or compatible DSH package/ + ); + assert.match(settings, /Configured — write-only/); +}); + +test('plugins receive a self-contained controlled component browser entry', async () => { + const source = await fs.readFile(path.join(client, 'moss-web-components.tsx'), 'utf8'); + assert.match(source, /mountMossWebComponent/); + for (const component of ['Button', 'Input', 'Card', 'Menu', 'Modal']) { + assert.ok(source.includes(component), `missing controlled component export: ${component}`); + } + const config = await fs.readFile( + path.resolve(client, '../../../web-components-vite.config.mjs'), + 'utf8' + ); + assert.match(config, /moss-web-components\.js/); + assert.match(config, /emptyOutDir: false/); +}); diff --git a/packages/moss-agent/tsconfig.json b/packages/moss-agent/tsconfig.json index 0d7100cb..07fe1031 100644 --- a/packages/moss-agent/tsconfig.json +++ b/packages/moss-agent/tsconfig.json @@ -1,7 +1,9 @@ { "extends": "../../tsconfig.base.json", "compilerOptions": { - "noEmit": true + "noEmit": true, + "jsx": "react-jsx", + "lib": ["ES2022", "DOM", "DOM.Iterable"] }, - "include": ["src/**/*.ts"] + "include": ["src/**/*.ts", "src/**/*.tsx"] } diff --git a/packages/moss-agent/web-components-vite.config.mjs b/packages/moss-agent/web-components-vite.config.mjs new file mode 100644 index 00000000..1903c54a --- /dev/null +++ b/packages/moss-agent/web-components-vite.config.mjs @@ -0,0 +1,28 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { defineConfig } from 'vite'; + +const packageRoot = path.dirname(fileURLToPath(import.meta.url)); + +export default defineConfig({ + define: { 'process.env.NODE_ENV': JSON.stringify('production') }, + build: { + outDir: path.join(packageRoot, 'dist', 'web-ui', 'client'), + emptyOutDir: false, + cssCodeSplit: false, + lib: { + entry: path.join(packageRoot, 'src', 'web-ui', 'client', 'moss-web-components.tsx'), + formats: ['es'], + fileName: () => 'moss-web-components.js', + }, + rollupOptions: { + output: { + assetFileNames: (assetInfo) => + assetInfo.names.some((name) => name.endsWith('.css')) + ? 'moss-web-components.css' + : '[name][extname]', + }, + }, + }, +}); diff --git a/packages/moss-agent/web-vite.config.mjs b/packages/moss-agent/web-vite.config.mjs new file mode 100644 index 00000000..081f5f8e --- /dev/null +++ b/packages/moss-agent/web-vite.config.mjs @@ -0,0 +1,30 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { defineConfig } from 'vite'; + +const packageRoot = path.dirname(fileURLToPath(import.meta.url)); + +export default defineConfig({ + define: { + 'process.env.NODE_ENV': JSON.stringify('production'), + }, + build: { + outDir: path.join(packageRoot, 'dist', 'web-ui', 'client'), + emptyOutDir: true, + cssCodeSplit: false, + lib: { + entry: path.join(packageRoot, 'src', 'web-ui', 'client', 'workbench.tsx'), + formats: ['es'], + fileName: () => 'workbench.js', + }, + rollupOptions: { + output: { + assetFileNames: (assetInfo) => + assetInfo.names.some((name) => name.endsWith('.css')) + ? 'workbench.css' + : '[name][extname]', + }, + }, + }, +}); diff --git a/scripts/check-moss-web-tokens.mjs b/scripts/check-moss-web-tokens.mjs new file mode 100644 index 00000000..09311416 --- /dev/null +++ b/scripts/check-moss-web-tokens.mjs @@ -0,0 +1,60 @@ +import { readFile, readdir } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const workspace = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const tokenDefinition = path.join( + workspace, + 'packages/moss-agent/src/web-ui/client/design-system.css' +); +const roots = [ + path.join(workspace, 'packages/moss-agent/src/web-ui/client'), + path.join(workspace, 'packages/moss-agent/assets/plugins'), + path.join(workspace, 'packages/create-moss-app'), +]; +const sourceExtensions = new Set(['.css', '.html', '.js', '.mjs', '.ts', '.tsx']); +const rawVisualValue = + /(?:color|background(?:-color)?|border(?:-[a-z-]+)?|box-shadow|fill|stroke)\s*:\s*(?:#[0-9a-f]{3,8}\b|rgba?\()/gi; + +async function filesBelow(root) { + const files = []; + let entries; + try { + entries = await readdir(root, { withFileTypes: true }); + } catch (error) { + if (error?.code === 'ENOENT') return files; + throw error; + } + for (const entry of entries) { + if (entry.name === 'node_modules' || entry.name === 'dist') continue; + const target = path.join(root, entry.name); + if (entry.isDirectory()) files.push(...(await filesBelow(target))); + else if (sourceExtensions.has(path.extname(entry.name))) files.push(target); + } + return files; +} + +const violations = []; +for (const root of roots) { + for (const file of await filesBelow(root)) { + if (file === tokenDefinition) continue; + const source = await readFile(file, 'utf8'); + for (const match of source.matchAll(rawVisualValue)) { + const line = source.slice(0, match.index).split('\n').length; + violations.push(`${path.relative(workspace, file)}:${line}: ${match[0]}`); + } + } +} + +if (violations.length > 0) { + console.error( + [ + 'Moss Web visual values must come from --moss-* design tokens.', + 'Define palette values in design-system.css and consume var(--moss-...) elsewhere.', + ...violations, + ].join('\n') + ); + process.exitCode = 1; +} else { + console.log('[moss-web-tokens] built-in UI, official plugins, and templates use design tokens'); +} diff --git a/scripts/config/api-entrypoints.json b/scripts/config/api-entrypoints.json index 44e89dc5..88e5d923 100644 --- a/scripts/config/api-entrypoints.json +++ b/scripts/config/api-entrypoints.json @@ -172,6 +172,11 @@ "types": "./dist/runtime/index.d.ts", "report": "runtime", "stability": "public" + }, + "./web": { + "types": "./dist/web-ui/index.d.ts", + "report": "web", + "stability": "public" } } } diff --git a/scripts/config/maintainability-baseline.json b/scripts/config/maintainability-baseline.json index 13c8977e..39fd0ecd 100644 --- a/scripts/config/maintainability-baseline.json +++ b/scripts/config/maintainability-baseline.json @@ -11,7 +11,7 @@ "reason": "Legacy hotspot recorded at initial maintainability-ratchet adoption." }, "packages/moss-agent/src/cli/approval.ts": { - "maxLines": 958, + "maxLines": 898, "reason": "Legacy hotspot recorded at initial maintainability-ratchet adoption." }, "packages/moss-agent/src/cli/coding-completion-gate.ts": { @@ -31,7 +31,7 @@ "reason": "Legacy hotspot recorded at initial maintainability-ratchet adoption." }, "packages/moss-agent/src/cli/tui.ts": { - "maxLines": 6137, + "maxLines": 6135, "reason": "Legacy hotspot recorded at initial maintainability-ratchet adoption." }, "packages/moss-agent/src/cli/tui-utils.ts": { @@ -39,7 +39,7 @@ "reason": "Legacy hotspot recorded at initial maintainability-ratchet adoption." }, "packages/moss-agent/src/cli-main.ts": { - "maxLines": 1528, + "maxLines": 1527, "reason": "Legacy hotspot recorded at initial maintainability-ratchet adoption." }, "packages/moss-agent/src/context/compaction.ts": { diff --git a/scripts/lib/cloud-local-scenario.mjs b/scripts/lib/cloud-local-scenario.mjs index 4c57adde..d7dfbcea 100644 --- a/scripts/lib/cloud-local-scenario.mjs +++ b/scripts/lib/cloud-local-scenario.mjs @@ -238,12 +238,17 @@ export async function runCloudLocalScenario(options = {}) { taskRunFile: path.join(root, '.moss', 'task-runs.jsonl'), }); try { - const session = await fetch(`${web.url}/api/sessions`, { method: 'POST' }).then((response) => + const { csrfToken } = await fetch(`${web.url}/api/bootstrap`).then((response) => response.json() ); + const mutationHeaders = { origin: web.url, 'x-moss-csrf': csrfToken }; + const session = await fetch(`${web.url}/api/sessions`, { + method: 'POST', + headers: mutationHeaders, + }).then((response) => response.json()); const wire = await fetch(`${web.url}/api/sessions/${session.sessionId}/messages`, { method: 'POST', - headers: { 'content-type': 'application/json' }, + headers: { ...mutationHeaders, 'content-type': 'application/json' }, body: JSON.stringify({ prompt: 'Reconcile the local release manifest with cloud attestation and recover failures.', }),