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
This commit is contained in:
Mohamed Boudra
2026-09-30 20:09:10 +01:00
committed by GitHub
parent 7a64eef491
commit 695ecb2812
11 changed files with 156 additions and 229 deletions
+1 -1
View File
@@ -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
@@ -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();
-103
View File
@@ -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<SettingsState<typeof museSettings.schema>, { 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 (
<SettingsSection title="Muse Code">
<SettingsCard>
<SettingsSwitch
label="Sandbox"
value={settings.values.sandbox}
disabled={settings.saving}
onValueChange={changeSandbox}
/>
<SettingsSelect
label="Network"
value={settings.values.network}
options={networks}
disabled={settings.saving || !settings.values.sandbox}
onValueChange={changeNetwork}
/>
<SettingsSwitch
label="Trust workspace"
value={settings.values.trustWorkspace}
disabled={settings.saving}
onValueChange={changeTrust}
/>
</SettingsCard>
<Text style={style}>
Changes apply to new Muse sessions. Disabling the sandbox enables full network access. Trust
workspace lets Muse load project skills and configuration.
</Text>
{settings.saveError ? (
<Text accessibilityRole="alert" style={style}>
{settings.saveError}
</Text>
) : null}
</SettingsSection>
);
}
export function MuseSettings({ theme }: PluginScreenProps) {
const settings = useSettings(museSettings);
const style = useMemo(() => ({ color: theme.colors.foreground }), [theme]);
if (settings.status === "loading") return <Text style={style}>Loading settings…</Text>;
if (settings.status !== "ready")
return (
<SettingsSection title="Muse Code">
<Text accessibilityRole="alert" style={style}>
{settings.error}
</Text>
<SettingsAction label="Try again" actionLabel="Reload" onPress={settings.reload} />
{settings.status === "invalid" ? (
<SettingsAction
label="Restore default settings"
actionLabel="Reset"
onPress={settings.reset}
/>
) : null}
</SettingsSection>
);
return <Controls settings={settings} theme={theme} />;
}
-20
View File
@@ -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 () => {};
}
+2 -7
View File
@@ -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<PluginServerContext, "registerProvider" | "registerUsageSource">,
) {
const settings = server.registerSettings(museSettings);
const usage = new Usage();
server.registerUsageSource(usage.registration());
server.registerProvider(createMuseProvider(settings, usage));
server.registerProvider(createMuseProvider(usage));
return () => {};
}
+32
View File
@@ -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"] : []),
];
}
+3 -10
View File
@@ -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<typeof settingsSchema>,
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<ProviderStatus> {
function connect(
launch: ProviderLaunch,
negotiated: readonly string[],
settings: PluginSettings<typeof settingsSchema>,
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 {
-17
View File
@@ -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<typeof settingsSchema>,
): Promise<string[]> {
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"] : []),
];
}
-24
View File
@@ -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,
});
+78 -34
View File
@@ -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<string, string> = {}) {
async function harness(
scenario = "text-reasoning",
env: Record<string, string> = {},
providerOptions?: Record<string, unknown>,
) {
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<string, string>
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<string, string>
sessionId: "paseo-session",
config: {
cwd: root,
providerOptions,
env: {},
settings: {},
mcpServers: {},
@@ -155,9 +144,6 @@ async function harness(scenario = "text-reasoning", env: Record<string, string>
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([
+37 -10
View File
@@ -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