From 695ecb281290173bfc7e784f548a01b7cc55bc2f Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 30 Sep 2026 20:09:10 +0100 Subject: [PATCH] fix(muse): make package installs and project rules work by default (#5775) * fix(muse): make launch defaults usable and configure each agent Disable sandboxing and trust workspaces by default so package installs and project rules work. Replace the host settings UI with validated per-agent providerOptions, preserving posture across session resume. * docs(muse): describe shared provider options configuration --- docs/plugins.md | 2 +- .../server/daemon-e2e/muse.real.e2e.test.ts | 6 +- plugins/muse-provider/client/settings.tsx | 103 ---------------- plugins/muse-provider/index.client.tsx | 20 ---- plugins/muse-provider/index.server.ts | 9 +- plugins/muse-provider/server/options.ts | 32 +++++ plugins/muse-provider/server/provider.ts | 13 +- plugins/muse-provider/server/settings.ts | 17 --- plugins/muse-provider/shared/settings.ts | 24 ---- plugins/muse-provider/test/provider.test.ts | 112 ++++++++++++------ public-docs/muse-code.md | 47 ++++++-- 11 files changed, 156 insertions(+), 229 deletions(-) delete mode 100644 plugins/muse-provider/client/settings.tsx delete mode 100644 plugins/muse-provider/index.client.tsx create mode 100644 plugins/muse-provider/server/options.ts delete mode 100644 plugins/muse-provider/server/settings.ts delete mode 100644 plugins/muse-provider/shared/settings.ts diff --git a/docs/plugins.md b/docs/plugins.md index a739d417a0..91c0841ffe 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -104,7 +104,7 @@ availability and a diagnostic after the daemon resolves the executable. Configur environment, or enablement overrides under `agents.providers.muse`. Omit `extends` to keep the bundled integration; an entry with `extends` shadows it with a custom provider. See [provider contributions](#contribute-a-provider) for the contract and -[Muse Code](../public-docs/muse-code.md) for setup, settings, and version limitations. +[Muse Code](../public-docs/muse-code.md) for setup, per-agent options, and version limitations. ## Install a Git source diff --git a/packages/server/src/server/daemon-e2e/muse.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/muse.real.e2e.test.ts index d9d473511a..f8355065f4 100644 --- a/packages/server/src/server/daemon-e2e/muse.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/muse.real.e2e.test.ts @@ -10,7 +10,7 @@ import { BuiltinPluginLoader } from "../plugins/builtin/index.js"; import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; import { DaemonClient } from "../test-utils/daemon-client.js"; -test("Muse publishes its client settings bundle while installed plugins are disabled", async () => { +test("Muse registers without a client settings bundle", async () => { const daemon = await createTestPaseoDaemon({ builtinPlugins: new BuiltinPluginLoader(undefined, ["muse-provider"]), }); @@ -21,8 +21,8 @@ test("Muse publishes its client settings bundle while installed plugins are disa try { await client.connect(); const muse = (await client.getPluginCatalog()).find((plugin) => plugin.id === "muse-provider"); - expect(muse?.clientBundle).toBeTypeOf("string"); - expect(muse?.clientBundle).not.toBe(""); + expect(muse).toBeDefined(); + expect(muse?.clientBundle).toBe(""); } finally { await client.close(); await daemon.close(); diff --git a/plugins/muse-provider/client/settings.tsx b/plugins/muse-provider/client/settings.tsx deleted file mode 100644 index b057cf2cb0..0000000000 --- a/plugins/muse-provider/client/settings.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import { useCallback, useMemo } from "react"; -import { Text } from "react-native"; -import { useSettings, type PluginScreenProps, type SettingsState } from "@getpaseo/plugin/client"; -import { - SettingsAction, - SettingsCard, - SettingsSection, - SettingsSelect, - SettingsSwitch, -} from "@getpaseo/plugin/client/ui"; -import { museSettings } from "../shared/settings.js"; - -const networks = [ - { label: "Proxy only", value: "proxy-only" }, - { label: "Restricted", value: "restricted" }, - { label: "Enabled", value: "enabled" }, -] as const; -type ReadySettings = Extract, { status: "ready" }>; - -function Controls({ - settings, - theme, -}: { - settings: ReadySettings; - theme: PluginScreenProps["theme"]; -}) { - const style = useMemo(() => ({ color: theme.colors.foreground }), [theme]); - const changeSandbox = useCallback( - (sandbox: boolean) => { - void settings.save({ ...settings.values, sandbox }, settings.revision); - }, - [settings], - ); - const changeNetwork = useCallback( - (network: ReadySettings["values"]["network"]) => { - void settings.save({ ...settings.values, network }, settings.revision); - }, - [settings], - ); - const changeTrust = useCallback( - (trustWorkspace: boolean) => { - void settings.save({ ...settings.values, trustWorkspace }, settings.revision); - }, - [settings], - ); - return ( - - - - - - - - Changes apply to new Muse sessions. Disabling the sandbox enables full network access. Trust - workspace lets Muse load project skills and configuration. - - {settings.saveError ? ( - - {settings.saveError} - - ) : null} - - ); -} - -export function MuseSettings({ theme }: PluginScreenProps) { - const settings = useSettings(museSettings); - const style = useMemo(() => ({ color: theme.colors.foreground }), [theme]); - if (settings.status === "loading") return Loading settings…; - if (settings.status !== "ready") - return ( - - - {settings.error} - - - {settings.status === "invalid" ? ( - - ) : null} - - ); - return ; -} diff --git a/plugins/muse-provider/index.client.tsx b/plugins/muse-provider/index.client.tsx deleted file mode 100644 index 98db6de7a4..0000000000 --- a/plugins/muse-provider/index.client.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import type { PluginClientContext } from "@getpaseo/plugin/client"; -import { MuseSettings } from "./client/settings.js"; - -export default function contribute(client: PluginClientContext) { - client.addScreen({ - id: "muse", - title: "Muse Code", - Component: MuseSettings, - }); - client.addCommandCenterItem({ - id: "settings", - title: "Configure Muse Code", - icon: "Settings", - context: "global", - onSelect({ openScreen }) { - openScreen({ screenId: "muse" }); - }, - }); - return () => {}; -} diff --git a/plugins/muse-provider/index.server.ts b/plugins/muse-provider/index.server.ts index b5e988c23a..3ee054adce 100644 --- a/plugins/muse-provider/index.server.ts +++ b/plugins/muse-provider/index.server.ts @@ -1,18 +1,13 @@ import type { PluginServerContext } from "@getpaseo/plugin/server"; import { createMuseProvider } from "./server/provider.js"; -import { museSettings } from "./shared/settings.js"; import { Usage } from "./server/usage.js"; export default function contribute( - server: Pick< - PluginServerContext, - "registerProvider" | "registerSettings" | "registerUsageSource" - >, + server: Pick, ) { - const settings = server.registerSettings(museSettings); const usage = new Usage(); server.registerUsageSource(usage.registration()); - server.registerProvider(createMuseProvider(settings, usage)); + server.registerProvider(createMuseProvider(usage)); return () => {}; } diff --git a/plugins/muse-provider/server/options.ts b/plugins/muse-provider/server/options.ts new file mode 100644 index 0000000000..27621dace6 --- /dev/null +++ b/plugins/muse-provider/server/options.ts @@ -0,0 +1,32 @@ +import { z } from "zod"; +import { MuseError } from "./errors.js"; + +const optionsSchema = z + .object({ + sandbox: z + .object({ + enabled: z.boolean().default(false), + network: z.enum(["proxy-only", "restricted", "enabled"]).default("proxy-only"), + }) + .strict() + .prefault({}), + trustWorkspace: z.boolean().default(true), + }) + .strict(); + +export function serveArgs(providerOptions: unknown): string[] { + const parsed = optionsSchema.safeParse(providerOptions === undefined ? {} : providerOptions); + if (!parsed.success) { + const errors = parsed.error.issues + .map((issue) => `${["providerOptions", ...issue.path].join(".")}: ${issue.message}`) + .join("; "); + throw new MuseError("invalidProviderOptions", `Invalid Muse providerOptions: ${errors}`); + } + const { sandbox, trustWorkspace } = parsed.data; + return [ + "--sandbox-network", + sandbox.network, + ...(sandbox.enabled ? [] : ["--disable-sandbox"]), + ...(trustWorkspace ? ["--trust-workspace"] : []), + ]; +} diff --git a/plugins/muse-provider/server/provider.ts b/plugins/muse-provider/server/provider.ts index 10e000b0c1..bef6693f91 100644 --- a/plugins/muse-provider/server/provider.ts +++ b/plugins/muse-provider/server/provider.ts @@ -8,9 +8,7 @@ import { type ProviderRegistration, type ProviderStatus, } from "@getpaseo/plugin/server/provider"; -import type { PluginSettings } from "@getpaseo/plugin/server"; -import { serveArgs } from "./settings.js"; -import { settingsSchema } from "../shared/settings.js"; +import { serveArgs } from "./options.js"; import { Usage } from "./usage.js"; import { execFile } from "node:child_process"; import { Catalog, launchKey } from "./catalog.js"; @@ -31,10 +29,7 @@ const capabilities = [ "session.list", ] as const; -export function createMuseProvider( - settings: PluginSettings, - usage: Usage, -): ProviderRegistration { +export function createMuseProvider(usage: Usage): ProviderRegistration { return { id: "muse", label: "Muse Code", @@ -56,7 +51,6 @@ export function createMuseProvider( return connect( requireLaunch(request.launch), negotiateProviderCapabilities(request.capabilities, capabilities), - settings, usage, ); }, @@ -101,7 +95,6 @@ async function status(launch: ProviderLaunch): Promise { function connect( launch: ProviderLaunch, negotiated: readonly string[], - settings: PluginSettings, usage: Usage, ): ProviderConnection { const listeners = new Set<(event: ProviderEvent) => void>(); @@ -134,7 +127,7 @@ function connect( launch, emit, capabilities: negotiated, - serveArgs: await serveArgs(settings), + serveArgs: serveArgs(input.config.providerOptions), }); sessions.set(input.sessionId, session); try { diff --git a/plugins/muse-provider/server/settings.ts b/plugins/muse-provider/server/settings.ts deleted file mode 100644 index 21586834ce..0000000000 --- a/plugins/muse-provider/server/settings.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { PluginSettings } from "@getpaseo/plugin/server"; -import { settingsSchema } from "../shared/settings.js"; -import { MuseError } from "./errors.js"; - -export async function serveArgs( - settings: PluginSettings, -): Promise { - const state = await settings.read(); - if (state.status === "invalid") throw new MuseError("invalidSettings", state.error); - const { sandbox, network, trustWorkspace } = state.values; - return [ - "--sandbox-network", - network, - ...(sandbox ? [] : ["--disable-sandbox"]), - ...(trustWorkspace ? ["--trust-workspace"] : []), - ]; -} diff --git a/plugins/muse-provider/shared/settings.ts b/plugins/muse-provider/shared/settings.ts deleted file mode 100644 index f1af368e8c..0000000000 --- a/plugins/muse-provider/shared/settings.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { z } from "zod"; -import { defineSettings } from "@getpaseo/plugin"; - -export const settingsSchema = z.object({ - sandbox: z - .boolean() - .default(true) - .describe("Keep Muse filesystem and network sandboxing enabled"), - network: z - .enum(["proxy-only", "restricted", "enabled"]) - .default("proxy-only") - .describe("Sandbox network access; disabling sandbox forces full network access"), - trustWorkspace: z - .boolean() - .default(false) - .describe("Let Muse load project-scoped skills and config from the workspace"), -}); - -export const museSettings = defineSettings({ - id: "muse", - scope: "host", - version: 1, - schema: settingsSchema, -}); diff --git a/plugins/muse-provider/test/provider.test.ts b/plugins/muse-provider/test/provider.test.ts index 6c060fbfdb..1d346a0c29 100644 --- a/plugins/muse-provider/test/provider.test.ts +++ b/plugins/muse-provider/test/provider.test.ts @@ -13,7 +13,6 @@ import { type ProviderLaunch, type ProviderInput, } from "@getpaseo/plugin/server/provider"; -import { settingsSchema } from "../shared/settings.js"; import type { UsageSourceRegistration } from "@getpaseo/plugin/server"; import contribute from "../index.server.js"; @@ -25,21 +24,10 @@ afterEach(async () => { await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); }); function registration( - readSettings = () => settingsSchema.parse({}), registerUsageSource = (_source: UsageSourceRegistration) => {}, ): ProviderRegistration { let provider: ProviderRegistration | undefined; const server = { - registerSettings() { - return { - read: async () => ({ - status: "ready" as const, - revision: "1", - values: readSettings(), - }), - subscribe: () => () => {}, - }; - }, registerUsageSource, registerProvider(value: ProviderRegistration) { provider = value; @@ -49,7 +37,11 @@ function registration( if (!provider) throw new Error("Provider was not registered"); return provider; } -async function harness(scenario = "text-reasoning", env: Record = {}) { +async function harness( + scenario = "text-reasoning", + env: Record = {}, + providerOptions?: Record, +) { const root = await mkdtemp(path.join(os.tmpdir(), "muse-provider-test-")); roots.push(root); const requests = path.join(root, "requests.ndjson"); @@ -59,14 +51,10 @@ async function harness(scenario = "text-reasoning", env: Record env: { MUSE_TEST_SCENARIO: scenario, MUSE_TEST_REQUESTS: requests, ...env }, }; await writeFile(requests, ""); - let settings = settingsSchema.parse({}); let usageSource!: UsageSourceRegistration; - const provider = registration( - () => settings, - (source) => { - usageSource = source; - }, - ); + const provider = registration((source) => { + usageSource = source; + }); const connection = await provider.connect({ launch, versions: [1], @@ -103,6 +91,7 @@ async function harness(scenario = "text-reasoning", env: Record sessionId: "paseo-session", config: { cwd: root, + providerOptions, env: {}, settings: {}, mcpServers: {}, @@ -155,9 +144,6 @@ async function harness(scenario = "text-reasoning", env: Record recorded, root, usageSource, - setSettings: (values: unknown) => { - settings = settingsSchema.parse(values); - }, }; } @@ -1011,22 +997,81 @@ test("silent active turns page after two minutes and stop polling on recovered t await vi.advanceTimersByTimeAsync(240000); expect((await h.recorded()).filter((f) => f.method === "view/page")).toHaveLength(1); }); -test("sandbox, network and workspace trust settings apply only on the next open", async () => { +test("default launch disables sandbox and trusts workspace", async () => { const h = await harness(); await h.open(); - h.setSettings({ sandbox: false, network: "enabled", trustWorkspace: true }); - expect((await h.recorded()).filter((f) => f.event === "hostStarted").map((f) => f.args)).toEqual([ - ["serve", "--sandbox-network", "proxy-only"], + expect((await h.recorded()).find((f) => f.event === "hostStarted").args).toEqual([ + "serve", + "--sandbox-network", + "proxy-only", + "--disable-sandbox", + "--trust-workspace", ]); - await h.send({ type: "session.close", sessionId: "paseo-session", requestId: "close-settings" }); +}); +test.each([ + [{ sandbox: { enabled: true } }, ["--sandbox-network", "proxy-only", "--trust-workspace"]], + [{ trustWorkspace: false }, ["--sandbox-network", "proxy-only", "--disable-sandbox"]], + [ + { sandbox: { network: "restricted" } }, + ["--sandbox-network", "restricted", "--disable-sandbox", "--trust-workspace"], + ], + [ + { sandbox: { network: "enabled" } }, + ["--sandbox-network", "enabled", "--disable-sandbox", "--trust-workspace"], + ], + [ + { sandbox: { enabled: true, network: "proxy-only" }, trustWorkspace: false }, + ["--sandbox-network", "proxy-only"], + ], +])("provider options %j control launch flags", async (params, args) => { + const h = await harness("text-reasoning", {}, params); + await h.open(); + expect((await h.recorded()).find((f) => f.event === "hostStarted").args).toEqual([ + "serve", + ...args, + ]); +}); +test.each([ + [{ sandbox: { enabled: "true" } }, "sandbox.enabled"], + [{ sandbox: { network: "invalid" } }, "sandbox.network"], + [{ trustWorkspace: 1 }, "trustWorkspace"], + [{ unexpected: true }, "unexpected"], +])("invalid provider options %j fail session creation clearly", async (options, field) => { + const h = await harness("text-reasoning", {}, options); + const event = await h.open(); + expect(event).toMatchObject({ + type: "request.failed", + error: { code: "invalidProviderOptions" }, + }); + if (event.type !== "request.failed") throw new Error("Expected failure"); + expect(event.error.message).toContain("Invalid Muse providerOptions"); + expect(event.error.message).toContain(field); + expect(await h.recorded()).toEqual([]); +}); +test("resuming a session reapplies its provider options to the new host", async () => { + const h = await harness( + "resume-without-cursor", + {}, + { + sandbox: { enabled: true, network: "restricted" }, + trustWorkspace: false, + }, + ); + await h.open({ version: 1, data: { sessionId: "saved-session" } }); + const saved = h.events.find((e) => e.type === "session.opened"); + if (saved?.type !== "session.opened" || !saved.persistence) + throw new Error("Expected persistence"); + await h.send({ type: "session.close", sessionId: "paseo-session", requestId: "close" }); await h.wait((e) => e.type === "session.closed"); const from = h.events.length; - await h.open(); + await h.open(saved.persistence); await h.wait((e) => e.type === "session.ready", from); - expect((await h.recorded()).filter((f) => f.event === "hostStarted").map((f) => f.args)).toEqual([ - ["serve", "--sandbox-network", "proxy-only"], - ["serve", "--sandbox-network", "enabled", "--disable-sandbox", "--trust-workspace"], + const frames = await h.recorded(); + expect(frames.filter((f) => f.event === "hostStarted").map((f) => f.args)).toEqual([ + ["serve", "--sandbox-network", "restricted"], + ["serve", "--sandbox-network", "restricted"], ]); + expect(frames.some((f) => f.method === "session/resume")).toBe(true); }); test("sessions list filters workspace and imported persistence opens via resume", async () => { const h = await harness("resume-without-cursor"); @@ -1203,8 +1248,7 @@ test("default workflow children render structured tool details from the real no- for (const source of ["user", "project"] as const) { test(`real ${source} skill catalog and command replay with explicit workspace trust`, async () => { - const h = await harness(`phase3-${source}-skill`); - h.setSettings({ trustWorkspace: source === "project" }); + const h = await harness(`phase3-${source}-skill`, {}, { trustWorkspace: source === "project" }); await h.open(); expect(await h.wait((e) => e.type === "session.commands")).toMatchObject({ commands: expect.arrayContaining([ diff --git a/public-docs/muse-code.md b/public-docs/muse-code.md index 99c3096755..83dea4d244 100644 --- a/public-docs/muse-code.md +++ b/public-docs/muse-code.md @@ -27,20 +27,47 @@ In Paseo, select **Muse Code**, then choose a model, approval mode, and reasonin Paseo runs `muse serve` and communicates over the Muse Session Protocol (MSP). Your Muse configuration determines the available models. -## Plugin settings +## Provider options -Open Command Center and select **Configure Muse Code**. Changes apply when an agent's -Muse host next opens, including a refresh of an existing agent. +Paseo launches Muse with the sandbox disabled (`--disable-sandbox`) and workspace trust +on (`--trust-workspace`). Muse can install packages using your npm cache and load project +rules, skills, and configuration. -| Setting | Default | Effect | -| --------------- | ------------ | ----------------------------------------------------------------------------------------------- | -| Sandbox | On | Keeps Muse's filesystem and network sandbox enabled. Turning it off allows full network access. | -| Network | `proxy-only` | Muse's sandbox network policy: `proxy-only`, `restricted`, or `enabled`. | -| Trust workspace | Off | Lets Muse load project-scoped skills and configuration. | +Set defaults for every Muse agent in `config.json` on the daemon machine under +`agents.providers.muse.options`: + +```json +{ + "agents": { + "providers": { + "muse": { + "options": { + "sandbox": { "enabled": true, "network": "proxy-only" }, + "trustWorkspace": false + } + } + } + } +} +``` + +You can also supply per-agent `providerOptions` when creating an agent. The daemon merges +provider defaults with the per-agent options and delivers the effective `providerOptions` +to Muse. See [provider options](/docs/sdk/provider-options) for configuration and creation. + +Per-agent options are saved with the agent and reapplied when its Muse host opens on refresh +or resume. Invalid values and unknown keys fail session creation with a `providerOptions` +error. + +| Option | Default | Effect | +| ----------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------- | +| `sandbox.enabled` | `false` | Enables Muse's filesystem and network sandbox. Its read-only home directory prevents npm from writing the default cache. | +| `sandbox.network` | `"proxy-only"` | Sandbox network policy: `"proxy-only"`, `"restricted"`, or `"enabled"`. With sandbox disabled, Muse allows full network access. | +| `trustWorkspace` | `true` | Lets Muse load project rules, skills, and configuration. | Approval modes control tool decisions separately: **Default**, **Ask**, **Strict**, and -**Full access**. Full access automatically allows escalated approval stages; it keeps the -sandbox setting you chose. Skills appear in the slash-command menu, alongside `/compact`. +**Full access**. Full access automatically allows escalated approval stages; it preserves +the effective provider options. Skills appear in the slash-command menu, alongside `/compact`. ## Limitations in Muse 1.4.1