authorgravatar for nicktindle@outlook.comNicholas Tindle <nicktindle@outlook.com> 2026-10-10 18:17:13-05:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-10 16:17:13-07:00
log953edeec9dcddd7afc582926eb1b65cfd82dc784
tree742ae43c13f40cb5f81402b2ab55eb87401937d4
parent74dfbb697235b0db87355d3f8575dc2ba45dddf7
signature Signed by PGP key B5690EEEBB952194

fix(server): Muse turns no longer fail on Windows (#17163)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Theo Browne <me@t3.gg>

8 files changed, 377 insertions(+), 20 deletions(-)

apps/server/scripts/record-muse-msp-replay-fixture.ts+19-5
...@@ -27,6 +27,7 @@ import { TestClock } from "effect/testing";...@@ -27,6 +27,7 @@ import { TestClock } from "effect/testing";
27import {27import {
28 connectMuseTransport,28 connectMuseTransport,
29 layer as museReplayLayer,29 layer as museReplayLayer,
30 materializeMuseReplayWorkspace,
30 MUSE_MSP_REPLAY_PROTOCOL,31 MUSE_MSP_REPLAY_PROTOCOL,
31 MUSE_PROVIDER_KIND,32 MUSE_PROVIDER_KIND,
32 museRecordLabel,33 museRecordLabel,
...@@ -35,14 +36,15 @@ import {...@@ -35,14 +36,15 @@ import {
35import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";36import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
36import {37import {
37 makeMuseEnvironment,38 makeMuseEnvironment,
39 museLaunch,
38 museServeArgs,40 museServeArgs,
41 museWorkspaceRoot,
39 parseMuseVersion,42 parseMuseVersion,
40} from "@t3tools/provider-muse/testing";43} from "@t3tools/provider-muse/testing";
41import { provideDeterministicTestRuntime } from "../src/orchestration-v2/testkit/DeterministicRuntime.ts";44import { provideDeterministicTestRuntime } from "../src/orchestration-v2/testkit/DeterministicRuntime.ts";
42import { ORCHESTRATOR_REPLAY_FIXTURES } from "../src/orchestration-v2/testkit/fixtures/index.ts";45import { ORCHESTRATOR_REPLAY_FIXTURES } from "../src/orchestration-v2/testkit/fixtures/index.ts";
43import { materializeFixtureInput } from "../src/orchestration-v2/testkit/fixtures/shared.ts";46import { materializeFixtureInput } from "../src/orchestration-v2/testkit/fixtures/shared.ts";
44import { runOrchestratorV2ProviderReplayScenario } from "../src/orchestration-v2/testkit/ProviderReplayHarness.ts";47import { runOrchestratorV2ProviderReplayScenario } from "../src/orchestration-v2/testkit/ProviderReplayHarness.ts";
45import { materializeReplayTranscriptWorkspace } from "@t3tools/provider-testing/replayTranscript";
46import {48import {
47 checkpointWorkspace,49 checkpointWorkspace,
48 makeCheckpointWorkspace,50 makeCheckpointWorkspace,
...@@ -66,6 +68,8 @@ if (fixture === undefined || variant === undefined) {...@@ -66,6 +68,8 @@ if (fixture === undefined || variant === undefined) {
66}68}
6769
68const museBinary = process.env.T3_MUSE_BIN ?? "muse";70const museBinary = process.env.T3_MUSE_BIN ?? "muse";
71// Started as T3 starts it, so Windows needs no T3_MUSE_BIN for Muse's `muse.cmd` launcher.
72const museStart = Effect.runSync(museLaunch(museBinary));
69const home = process.env.HOME ?? "";73const home = process.env.HOME ?? "";
7074
71function isRecord(value: unknown): value is Record<string, unknown> {75function isRecord(value: unknown): value is Record<string, unknown> {
...@@ -147,7 +151,7 @@ function makeRecordingCreateHost(...@@ -147,7 +151,7 @@ function makeRecordingCreateHost(
147 };151 };
148 record("expect_outbound", { type: "host_start", args });152 record("expect_outbound", { type: "host_start", args });
149 // The binary named by T3_MUSE_BIN, so the recorded version matches what ran.153 // The binary named by T3_MUSE_BIN, so the recorded version matches what ran.
150 const child = NodeChildProcess.spawn(museBinary, args, {154 const child = NodeChildProcess.spawn(museStart.command, [...museStart.args, ...args], {
151 cwd: options.cwd,155 cwd: options.cwd,
152 env: options.environment ?? makeMuseEnvironment(),156 env: options.environment ?? makeMuseEnvironment(),
153 stdio: ["pipe", "pipe", "inherit"],157 stdio: ["pipe", "pipe", "inherit"],
...@@ -208,7 +212,11 @@ function makeRecordingCreateHost(...@@ -208,7 +212,11 @@ function makeRecordingCreateHost(
208}212}
209213
210const readMuseVersion = Effect.sync(() => {214const readMuseVersion = Effect.sync(() => {
211 const output = NodeChildProcess.execFileSync(museBinary, ["--version"], { encoding: "utf8" });215 const output = NodeChildProcess.execFileSync(
216 museStart.command,
217 [...museStart.args, "--version"],
218 { encoding: "utf8" },
219 );
212 return parseMuseVersion(output) ?? output.trim();220 return parseMuseVersion(output) ?? output.trim();
213});221});
214222
...@@ -224,6 +232,7 @@ const record = Effect.gen(function* () {...@@ -224,6 +232,7 @@ const record = Effect.gen(function* () {
224 yield* Effect.addFinalizer(() =>232 yield* Effect.addFinalizer(() =>
225 fs.remove(workspace, { recursive: true, force: true }).pipe(Effect.ignore),233 fs.remove(workspace, { recursive: true, force: true }).pipe(Effect.ignore),
226 );234 );
235 const canonicalWorkspace = yield* fs.realPath(workspace);
227236
228 const entries: Array<ProviderReplayEntry> = [];237 const entries: Array<ProviderReplayEntry> = [];
229 const commandIds: Array<Array<string>> = [];238 const commandIds: Array<Array<string>> = [];
...@@ -276,7 +285,12 @@ const record = Effect.gen(function* () {...@@ -276,7 +285,12 @@ const record = Effect.gen(function* () {
276 model: variant.modelSelection.model,285 model: variant.modelSelection.model,
277 commandIds,286 commandIds,
278 },287 },
279 entries: normalizeEntries(entries, [yield* fs.realPath(workspace), workspace]),288 // The verbatim root first: it contains the canonical path.
289 entries: normalizeEntries(entries, [
290 yield* museWorkspaceRoot(canonicalWorkspace),
291 canonicalWorkspace,
292 workspace,
293 ]),
280 } satisfies ProviderReplayTranscript;294 } satisfies ProviderReplayTranscript;
281295
282 yield* Effect.gen(function* () {296 yield* Effect.gen(function* () {
...@@ -286,7 +300,7 @@ const record = Effect.gen(function* () {...@@ -286,7 +300,7 @@ const record = Effect.gen(function* () {
286 {300 {
287 name: `${fixture.name}/muse:verify`,301 name: `${fixture.name}/muse:verify`,
288 transcript: yield* MuseOrchestratorReplayHarness.decodeTranscript(302 transcript: yield* MuseOrchestratorReplayHarness.decodeTranscript(
289 materializeReplayTranscriptWorkspace(transcript, yield* fs.realPath(replayWorkspace)),303 yield* materializeMuseReplayWorkspace(transcript, replayWorkspace),
290 ),304 ),
291 commands: materialized.commands,305 commands: materialized.commands,
292 steps: materialized.steps,306 steps: materialized.steps,
apps/server/src/orchestration-v2/Adapters/MuseAdapterV2.testkit.ts+32
...@@ -24,6 +24,7 @@ import {...@@ -24,6 +24,7 @@ import {
24} from "@t3tools/contracts";24} from "@t3tools/contracts";
25import { MuseSettings } from "@t3tools/provider-muse/settings";25import { MuseSettings } from "@t3tools/provider-muse/settings";
26import * as Effect from "effect/Effect";26import * as Effect from "effect/Effect";
27import * as FileSystem from "effect/FileSystem";
27import * as Layer from "effect/Layer";28import * as Layer from "effect/Layer";
28import * as Schema from "effect/Schema";29import * as Schema from "effect/Schema";
2930
...@@ -31,6 +32,7 @@ import * as TestProviderHost from "@t3tools/provider-testing/TestProviderHost";...@@ -31,6 +32,7 @@ import * as TestProviderHost from "@t3tools/provider-testing/TestProviderHost";
31import {32import {
32 museInitializeParams,33 museInitializeParams,
33 museServeArgs,34 museServeArgs,
35 museWorkspaceRoot,
34 type MuseSdkHost,36 type MuseSdkHost,
35 type MuseSdkHostOptions,37 type MuseSdkHostOptions,
36} from "@t3tools/provider-muse/testing";38} from "@t3tools/provider-muse/testing";
...@@ -38,6 +40,7 @@ import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";...@@ -38,6 +40,7 @@ import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
38import * as ProviderAdapterRegistry from "../ProviderAdapterRegistry.ts";40import * as ProviderAdapterRegistry from "../ProviderAdapterRegistry.ts";
39import * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests";41import * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests";
40import type { OrchestratorV2ProviderReplayHarness } from "../testkit/ProviderReplayHarness.ts";42import type { OrchestratorV2ProviderReplayHarness } from "../testkit/ProviderReplayHarness.ts";
43import { materializeReplayTranscriptWorkspace } from "@t3tools/provider-testing/replayTranscript";
41import { makeMuseAdapterV2 } from "@t3tools/provider-muse/server";44import { makeMuseAdapterV2 } from "@t3tools/provider-muse/server";
4245
43export const MUSE_PROVIDER_KIND = "muse";46export const MUSE_PROVIDER_KIND = "muse";
...@@ -468,6 +471,35 @@ export function layer(input: {...@@ -468,6 +471,35 @@ export function layer(input: {
468 );471 );
469}472}
470473
474/**
475 * Fills a recording's `<workspace>` with what the adapter sends on this host:
476 * the canonical path (macOS /var -> /private/var), which `turn/start` names in
477 * museWorkspaceRoot's form (verbatim on Windows).
478 */
479export const materializeMuseReplayWorkspace = Effect.fn("materializeMuseReplayWorkspace")(
480 function* (transcript: ProviderReplayTranscript, workspace: string) {
481 const canonical = yield* FileSystem.FileSystem.pipe(
482 Effect.flatMap((fs) => fs.realPath(workspace)),
483 Effect.provide(NodeServices.layer),
484 );
485 const plain = materializeReplayTranscriptWorkspace(transcript, canonical);
486 const turnStarts = materializeReplayTranscriptWorkspace(
487 transcript,
488 yield* museWorkspaceRoot(canonical),
489 );
490 return {
491 ...plain,
492 entries: plain.entries.map((entry, index) =>
493 entry.type === "expect_outbound" &&
494 isRecord(entry.frame) &&
495 entry.frame.method === "turn/start"
496 ? turnStarts.entries[index]!
497 : entry,
498 ),
499 };
500 },
501);
502
471export const MuseOrchestratorReplayHarness: OrchestratorV2ProviderReplayHarness<503export const MuseOrchestratorReplayHarness: OrchestratorV2ProviderReplayHarness<
472 MuseReplayTranscript,504 MuseReplayTranscript,
473 MuseReplayTranscriptDecodeError505 MuseReplayTranscriptDecodeError
apps/server/src/orchestration-v2/testkit/OrchestratorReplayFixtures.integration.test.ts+5-9
...@@ -19,7 +19,10 @@ import {...@@ -19,7 +19,10 @@ import {
19 OPENCODE2_HTTP_PROTOCOL,19 OPENCODE2_HTTP_PROTOCOL,
20 OpenCode2OrchestratorReplayHarness,20 OpenCode2OrchestratorReplayHarness,
21} from "../Adapters/OpenCode2AdapterV2.testkit.ts";21} from "../Adapters/OpenCode2AdapterV2.testkit.ts";
22import { MuseOrchestratorReplayHarness } from "../Adapters/MuseAdapterV2.testkit.ts";22import {
23 materializeMuseReplayWorkspace,
24 MuseOrchestratorReplayHarness,
25} from "../Adapters/MuseAdapterV2.testkit.ts";
23import { PiOrchestratorReplayHarness } from "../Adapters/PiAdapterV2.testkit.ts";26import { PiOrchestratorReplayHarness } from "../Adapters/PiAdapterV2.testkit.ts";
24import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";27import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
25import { provideDeterministicTestRuntime } from "./DeterministicRuntime.ts";28import { provideDeterministicTestRuntime } from "./DeterministicRuntime.ts";
...@@ -101,18 +104,11 @@ const runFixtureProvider = Effect.fn("runOrchestratorReplayFixture")(function* <...@@ -101,18 +104,11 @@ const runFixtureProvider = Effect.fn("runOrchestratorReplayFixture")(function* <
101 );104 );
102 const fixtureInput = input.buildInput();105 const fixtureInput = input.buildInput();
103 const workspace = yield* checkpointWorkspace(input.fixtureName, fixtureInput.workspaceFiles);106 const workspace = yield* checkpointWorkspace(input.fixtureName, fixtureInput.workspaceFiles);
104 // Muse canonicalizes its workspace path (macOS /var -> /private/var) before sending it.
105 const transcript = yield* input.harness.decodeTranscript(107 const transcript = yield* input.harness.decodeTranscript(
106 input.driver.driver === "codex"108 input.driver.driver === "codex"
107 ? materializeReplayTranscriptWorkspace(replayTranscript, workspace)109 ? materializeReplayTranscriptWorkspace(replayTranscript, workspace)
108 : input.driver.driver === "muse"110 : input.driver.driver === "muse"
109 ? materializeReplayTranscriptWorkspace(111 ? yield* materializeMuseReplayWorkspace(replayTranscript, workspace)
110 replayTranscript,
111 yield* FileSystem.FileSystem.pipe(
112 Effect.flatMap((fs) => fs.realPath(workspace)),
113 Effect.provide(NodeServices.layer),
114 ),
115 )
116 : replayTranscript,112 : replayTranscript,
117 );113 );
118 const materialized = yield* materializeFixtureInput({114 const materialized = yield* materializeFixtureInput({
packages/provider-muse/src/server/adapter.test.ts+36
...@@ -18,6 +18,7 @@ import {...@@ -18,6 +18,7 @@ import {
18 type OrchestrationV2AppThread,18 type OrchestrationV2AppThread,
19 type OrchestrationV2ProviderThread,19 type OrchestrationV2ProviderThread,
20} from "@t3tools/contracts";20} from "@t3tools/contracts";
21import * as HostProcess from "@t3tools/shared/HostProcess";
21import { MuseSettings } from "../settings.ts";22import { MuseSettings } from "../settings.ts";
22import * as DateTime from "effect/DateTime";23import * as DateTime from "effect/DateTime";
23import * as Deferred from "effect/Deferred";24import * as Deferred from "effect/Deferred";
...@@ -499,6 +500,41 @@ describe("MuseAdapterV2", () => {...@@ -499,6 +500,41 @@ describe("MuseAdapterV2", () => {
499 }).pipe(Effect.scoped, Effect.provide(testLayer)),500 }).pipe(Effect.scoped, Effect.provide(testLayer)),
500 );501 );
501502
503 it.effect("pins Windows turns to Muse's verbatim root and starts Muse in the plain path", () =>
504 Effect.gen(function* () {
505 const fake = yield* makeFakeMuse();
506 let hostCwd: string | undefined;
507 const policy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
508 ...runtimePolicy,
509 cwd: "C:\\work\\repo",
510 });
511 const harness = yield* makeHarness(
512 fake,
513 INSTANCE_ID,
514 undefined,
515 undefined,
516 undefined,
517 policy,
518 {
519 createHost: async (options) => {
520 hostCwd = options.cwd;
521 return fake.host;
522 },
523 },
524 );
525 yield* startConversation(harness, fake);
526 const turnStart = fake.calls.find((call) => call.method === "turn/start");
527 // Muse 1.4.3 rejects any other form: "expected a canonical path (resolves to \\?\C:\…)".
528 assert.deepStrictEqual(turnStart?.params.workspaceRoots, ["\\\\?\\C:\\work\\repo"]);
529 // The muse.cmd launcher runs under cmd.exe, which cannot start in a verbatim directory.
530 assert.strictEqual(hostCwd, "C:\\work\\repo");
531 }).pipe(
532 Effect.provideService(HostProcess.Platform, "win32"),
533 Effect.scoped,
534 Effect.provide(testLayer),
535 ),
536 );
537
502 it.effect("skips an unreadable usage notification without ending the turn", () =>538 it.effect("skips an unreadable usage notification without ending the turn", () =>
503 Effect.gen(function* () {539 Effect.gen(function* () {
504 const fake = yield* makeFakeMuse();540 const fake = yield* makeFakeMuse();
packages/provider-muse/src/server/adapter.ts+3-1
...@@ -62,6 +62,7 @@ import {...@@ -62,6 +62,7 @@ import {
62import {62import {
63 createMuseSdkHostEffect,63 createMuseSdkHostEffect,
64 museApprovalMode,64 museApprovalMode,
65 museWorkspaceRoot,
65 type createMuseSdkHost,66 type createMuseSdkHost,
66 type MuseSdkHost,67 type MuseSdkHost,
67} from "./sdk.ts";68} from "./sdk.ts";
...@@ -277,6 +278,7 @@ export const makeMuseAdapterV2 = Effect.fn("makeMuseAdapterV2")(function* (...@@ -277,6 +278,7 @@ export const makeMuseAdapterV2 = Effect.fn("makeMuseAdapterV2")(function* (
277 const cwd = yield* fileSystem278 const cwd = yield* fileSystem
278 .realPath(requestedCwd)279 .realPath(requestedCwd)
279 .pipe(Effect.orElseSucceed(() => requestedCwd));280 .pipe(Effect.orElseSucceed(() => requestedCwd));
281 const workspaceRoot = yield* museWorkspaceRoot(cwd);
280 const now = yield* DateTime.now;282 const now = yield* DateTime.now;
281 let session: OrchestrationV2ProviderSession = {283 let session: OrchestrationV2ProviderSession = {
282 id: input.providerSessionId,284 id: input.providerSessionId,
...@@ -1751,7 +1753,7 @@ export const makeMuseAdapterV2 = Effect.fn("makeMuseAdapterV2")(function* (...@@ -1751,7 +1753,7 @@ export const makeMuseAdapterV2 = Effect.fn("makeMuseAdapterV2")(function* (
1751 displayText: turnInput.message.text || "Image attachment",1753 displayText: turnInput.message.text || "Image attachment",
1752 // A resumed session keeps the root it was created with; pin it to1754 // A resumed session keeps the root it was created with; pin it to
1753 // this thread's current checkout so edits land where T3 tracks them.1755 // this thread's current checkout so edits land where T3 tracks them.
1754 workspaceRoots: [cwd],1756 workspaceRoots: [workspaceRoot],
1755 ifBusy: "queue",1757 ifBusy: "queue",
1756 ...(effort ? { reasoningEffort: effort } : {}),1758 ...(effort ? { reasoningEffort: effort } : {}),
1757 },1759 },
packages/provider-muse/src/server/sdk.test.ts+194
...@@ -1,13 +1,23 @@...@@ -1,13 +1,23 @@
1// @effect-diagnostics-next-line nodeBuiltinImport:off -- The Muse SDK spawns with Node's child_process, so the launch test quotes arguments the same way.
2import * as NodeChildProcess from "node:child_process";
3import * as NodeServices from "@effect/platform-node/NodeServices";
1import { spawnMspConnection, type MspHandshake, type SpawnedMspConnection } from "@muse-code/sdk";4import { spawnMspConnection, type MspHandshake, type SpawnedMspConnection } from "@muse-code/sdk";
2import { it } from "@effect/vitest";5import { it } from "@effect/vitest";
6import * as HostProcess from "@t3tools/shared/HostProcess";
7import { SpawnExecutableResolution } from "@t3tools/shared/shell";
3import * as Effect from "effect/Effect";8import * as Effect from "effect/Effect";
4import * as Fiber from "effect/Fiber";9import * as Fiber from "effect/Fiber";
10import * as FileSystem from "effect/FileSystem";
11import * as Path from "effect/Path";
5import { afterEach, describe, expect, vi } from "vite-plus/test";12import { afterEach, describe, expect, vi } from "vite-plus/test";
6import {13import {
7 createMuseSdkHost,14 createMuseSdkHost,
8 createMuseSdkHostEffect,15 createMuseSdkHostEffect,
9 makeMuseEnvironment,16 makeMuseEnvironment,
10 museApprovalMode,17 museApprovalMode,
18 museLaunch,
19 museVerbatimPath,
20 museWorkspaceRoot,
11 type MuseSdkHost,21 type MuseSdkHost,
12} from "./sdk.ts";22} from "./sdk.ts";
1323
...@@ -264,3 +274,187 @@ describe("Muse SDK host", () => {...@@ -264,3 +274,187 @@ describe("Muse SDK host", () => {
264 }),274 }),
265 );275 );
266});276});
277
278describe("Muse launch", () => {
279 const CMD = "C:\\Windows\\system32\\cmd.exe";
280 const onWindows = (resolved: string | undefined, env: NodeJS.ProcessEnv = { ComSpec: CMD }) =>
281 museLaunch("muse", env).pipe(
282 Effect.provideService(HostProcess.Platform, "win32"),
283 Effect.provideService(SpawnExecutableResolution, () => resolved),
284 );
285
286 it.effect("starts the binary itself off Windows", () =>
287 Effect.gen(function* () {
288 expect(yield* museLaunch("muse")).toEqual({ command: "muse", args: [] });
289 }).pipe(
290 Effect.provideService(HostProcess.Platform, "linux"),
291 Effect.provideService(SpawnExecutableResolution, () => {
292 throw new Error("Nothing is resolved off Windows.");
293 }),
294 ),
295 );
296
297 // Node spawns neither a bare `muse` nor a `.cmd` file without a shell on Windows.
298 it.effect("runs an exe directly and a launcher script under cmd.exe on Windows", () =>
299 Effect.gen(function* () {
300 expect(yield* onWindows("C:\\Muse\\muse.exe")).toEqual({
301 command: "C:\\Muse\\muse.exe",
302 args: [],
303 });
304 // Node quotes a path with spaces, and the `@` before it keeps cmd from dropping the quotes.
305 const spaced = "C:\\Users\\Jane Doe\\AppData\\Local\\Programs\\muse\\muse.cmd";
306 expect(yield* onWindows(spaced)).toEqual({ command: CMD, args: ["/d", "/c", "@", spaced] });
307 const spacedAmpersand = "C:\\Users\\R&D Team\\AppData\\Local\\Programs\\muse\\muse.cmd";
308 expect(yield* onWindows(spacedAmpersand)).toEqual({
309 command: CMD,
310 args: ["/d", "/c", "@", spacedAmpersand],
311 });
312 // Unquoted, cmd would split the command at `&`.
313 expect(yield* onWindows("C:\\Users\\R&D(1)\\muse.cmd")).toEqual({
314 command: CMD,
315 args: ["/d", "/c", "C:\\Users\\R^&D^(1^)\\muse.cmd"],
316 });
317 expect((yield* onWindows("C:\\Muse\\muse.bat", { SYSTEMROOT: "C:\\Windows" })).command).toBe(
318 "C:\\Windows\\System32\\cmd.exe",
319 );
320 // A binary that cannot be found is spawned as given, so the failure reports it.
321 expect(yield* onWindows(undefined)).toEqual({ command: "muse", args: [] });
322 }),
323 );
324
325 // cmd's quote rules only show with a real cmd.exe, spawning as the SDK does.
326 it.effect("starts a real launcher from a folder with spaces and cmd metacharacters", () =>
327 Effect.gen(function* () {
328 if ((yield* HostProcess.Platform) !== "win32") return;
329 const fs = yield* FileSystem.FileSystem;
330 const path = yield* Path.Path;
331 const folder = path.join(
332 yield* fs.makeTempDirectoryScoped({ prefix: "MuseLaunch-" }),
333 "R&D Team (x) @ ^y",
334 );
335 yield* fs.makeDirectory(folder);
336 const launcher = path.join(folder, "muse.cmd");
337 // Records its arguments next to itself; reaching the file at all proves cmd found it.
338 yield* fs.writeFileString(launcher, '@echo off\r\necho %*>"%~dp0args.txt"\r\n');
339 const launch = yield* museLaunch(launcher);
340 const run = NodeChildProcess.spawnSync(launch.command, [...launch.args, "serve", "--x"], {
341 cwd: folder,
342 encoding: "utf8",
343 });
344 expect(run.status, run.stderr).toBe(0);
345 expect((yield* fs.readFileString(path.join(folder, "args.txt"))).trim()).toBe("serve --x");
346 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
347 );
348
349 it.effect("starts Muse's Windows launcher through cmd.exe", () =>
350 Effect.gen(function* () {
351 const fake = mockSpawn();
352 const starting = yield* Effect.forkChild(
353 createMuseSdkHostEffect({
354 binaryPath: "muse",
355 environment: { ComSpec: CMD },
356 readOnly: true,
357 }),
358 );
359 yield* Effect.promise(() => fake.initializing.promise);
360 const spawned = vi.mocked(spawnMspConnection).mock.lastCall?.[0];
361 fake.startup.resolve(fake.ready);
362 const host = yield* Fiber.join(starting);
363 fake.shutdown.resolve({ code: 0, signal: null });
364 yield* Effect.promise(() => host.close());
365 expect(spawned).toMatchObject({
366 command: CMD,
367 args: [
368 "/d",
369 "/c",
370 "C:\\Muse\\muse.cmd",
371 "serve",
372 "--disable-shell",
373 "--disable-write",
374 "--no-session-log",
375 ],
376 });
377 }).pipe(
378 Effect.provideService(HostProcess.Platform, "win32"),
379 Effect.provideService(SpawnExecutableResolution, () => "C:\\Muse\\muse.cmd"),
380 ),
381 );
382
383 it.effect("passes launcher arguments to the binary that cmd.exe starts", () =>
384 Effect.gen(function* () {
385 const fake = mockSpawn();
386 const starting = yield* Effect.forkChild(
387 createMuseSdkHostEffect({
388 binaryPath: "muse",
389 launchArgs: ["--launcher-arg"],
390 environment: { ComSpec: CMD },
391 readOnly: true,
392 }),
393 );
394 yield* Effect.promise(() => fake.initializing.promise);
395 const spawned = vi.mocked(spawnMspConnection).mock.lastCall?.[0];
396 fake.startup.resolve(fake.ready);
397 const host = yield* Fiber.join(starting);
398 fake.shutdown.resolve({ code: 0, signal: null });
399 yield* Effect.promise(() => host.close());
400 expect(spawned).toMatchObject({
401 command: CMD,
402 args: [
403 "/d",
404 "/c",
405 "C:\\Muse\\muse.cmd",
406 "--launcher-arg",
407 "serve",
408 "--disable-shell",
409 "--disable-write",
410 "--no-session-log",
411 ],
412 });
413 }).pipe(
414 Effect.provideService(HostProcess.Platform, "win32"),
415 Effect.provideService(SpawnExecutableResolution, () => "C:\\Muse\\muse.cmd"),
416 ),
417 );
418});
419
420describe("Muse workspace root", () => {
421 it("writes Windows paths in their verbatim form", () => {
422 expect(museVerbatimPath("C:\\Users\\Person\\repo")).toBe("\\\\?\\C:\\Users\\Person\\repo");
423 expect(museVerbatimPath("\\\\server\\share\\repo")).toBe("\\\\?\\UNC\\server\\share\\repo");
424 expect(museVerbatimPath("\\\\?\\C:\\repo")).toBe("\\\\?\\C:\\repo");
425 });
426
427 it.effect("leaves the root alone off Windows", () =>
428 Effect.gen(function* () {
429 expect(yield* museWorkspaceRoot("/workspace/repo")).toBe("/workspace/repo");
430 }).pipe(Effect.provideService(HostProcess.Platform, "linux")),
431 );
432
433 it.effect("keeps a Windows root it cannot resolve, in verbatim form", () =>
434 Effect.gen(function* () {
435 expect(yield* museWorkspaceRoot("C:\\definitely-missing\\repo")).toBe(
436 "\\\\?\\C:\\definitely-missing\\repo",
437 );
438 // `/` would be taken literally inside a verbatim path.
439 expect(yield* museWorkspaceRoot("C:/definitely-missing/repo")).toBe(
440 "\\\\?\\C:\\definitely-missing\\repo",
441 );
442 expect(yield* museWorkspaceRoot("//server/share/missing")).toBe(
443 "\\\\?\\UNC\\server\\share\\missing",
444 );
445 }).pipe(Effect.provideService(HostProcess.Platform, "win32")),
446 );
447
448 // FileSystem.realPath keeps the case it is given; Muse rejects a root whose case differs from the disk's.
449 it.effect("fixes the case of a Windows root natively", () =>
450 Effect.gen(function* () {
451 if ((yield* HostProcess.Platform) !== "win32") return;
452 const fs = yield* FileSystem.FileSystem;
453 const path = yield* Path.Path;
454 const created = yield* fs.makeTempDirectoryScoped({ prefix: "MuseRoot-" });
455 const root = yield* museWorkspaceRoot(created.toLowerCase());
456 expect(root.startsWith("\\\\?\\")).toBe(true);
457 expect(root.endsWith(`\\${path.basename(created)}`)).toBe(true);
458 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
459 );
460});
packages/provider-muse/src/server/sdk.ts+83-3
...@@ -1,5 +1,8 @@...@@ -1,5 +1,8 @@
1// @effect-diagnostics globalTimers:off1// @effect-diagnostics globalTimers:off
2// The SDK owns process shutdown; native deadlines cover initialize before an Effect resource exists.2// The SDK owns process shutdown; native deadlines cover initialize before an Effect resource exists.
3// @effect-diagnostics-next-line nodeBuiltinImport:off -- FileSystem.realPath is Node's JS realpath; museWorkspaceRoot needs the native one.
4import * as NodeFSP from "node:fs/promises";
5
3import {6import {
4 spawnMspConnection,7 spawnMspConnection,
5 type Connection,8 type Connection,
...@@ -7,6 +10,8 @@ import {...@@ -7,6 +10,8 @@ import {
7 type SpawnedMspConnection,10 type SpawnedMspConnection,
8} from "@muse-code/sdk";11} from "@muse-code/sdk";
9import type { RuntimeMode } from "@t3tools/contracts";12import type { RuntimeMode } from "@t3tools/contracts";
13import * as HostProcess from "@t3tools/shared/HostProcess";
14import { SpawnExecutableResolution } from "@t3tools/shared/shell";
10import * as Effect from "effect/Effect";15import * as Effect from "effect/Effect";
1116
12export interface MuseSdkHost {17export interface MuseSdkHost {
...@@ -27,10 +32,18 @@ export interface MuseSdkHost {...@@ -27,10 +32,18 @@ export interface MuseSdkHost {
27 readonly close: () => Promise<void>;32 readonly close: () => Promise<void>;
28}33}
2934
35/** The program that runs `binaryPath`, and the arguments that go before the binary's own. */
36export interface MuseLaunch {
37 readonly command: string;
38 readonly args: ReadonlyArray<string>;
39}
40
30export interface MuseSdkHostOptions {41export interface MuseSdkHostOptions {
31 readonly binaryPath: string;42 readonly binaryPath: string;
32 /** Arguments before the serve arguments, for a launcher in front of the binary. */43 /** Arguments before the serve arguments, for a launcher in front of the binary. */
33 readonly launchArgs?: ReadonlyArray<string>;44 readonly launchArgs?: ReadonlyArray<string>;
45 /** How to start `binaryPath`, from `museLaunch`; without it the binary path is run as is. */
46 readonly launch?: MuseLaunch;
34 readonly cwd?: string;47 readonly cwd?: string;
35 readonly environment?: NodeJS.ProcessEnv;48 readonly environment?: NodeJS.ProcessEnv;
36 readonly runtimeMode?: RuntimeMode;49 readonly runtimeMode?: RuntimeMode;
...@@ -56,6 +69,71 @@ export function makeMuseEnvironment(...@@ -56,6 +69,71 @@ export function makeMuseEnvironment(
56 };69 };
57}70}
5871
72/** Windows' verbatim form of a canonical path: `\\?\C:\…`, or `\\?\UNC\server\share\…`. */
73export function museVerbatimPath(path: string): string {
74 if (path.startsWith("\\\\?\\")) return path;
75 return path.startsWith("\\\\") ? `\\\\?\\UNC\\${path.slice(2)}` : `\\\\?\\${path}`;
76}
77
78/**
79 * A workspace root in the form `turn/start` accepts. On Windows Muse rejects
80 * anything but the verbatim canonical path, true case and long names
81 * included, so resolve with the native realpath, which unlike
82 * FileSystem.realPath fixes case and expands 8.3 names. Muse itself still
83 * starts in the plain path: the `muse.cmd` launcher runs under cmd.exe, which
84 * cannot use a verbatim directory.
85 */
86export const museWorkspaceRoot = Effect.fn("museWorkspaceRoot")(function* (path: string) {
87 if ((yield* HostProcess.Platform) !== "win32") return path;
88 const canonical = yield* Effect.tryPromise(() => NodeFSP.realpath(path)).pipe(
89 // A verbatim path takes `/` literally, so an unresolved root needs Windows separators.
90 Effect.orElseSucceed(() => path.replaceAll("/", "\\")),
91 );
92 return museVerbatimPath(canonical);
93});
94
95/** A Windows environment variable; their names are case-insensitive there. */
96function windowsEnvironmentValue(environment: NodeJS.ProcessEnv, name: string) {
97 const key = Object.keys(environment).find(
98 (candidate) => candidate.toUpperCase() === name.toUpperCase(),
99 );
100 return key === undefined ? undefined : environment[key];
101}
102
103/**
104 * How to start Muse. The SDK spawns without a shell, which finds the `muse`
105 * launcher on PATH everywhere but Windows. There Node neither looks a bare
106 * `muse` up with PATHEXT (ENOENT) nor runs a `.cmd` file such as Muse's own
107 * launcher (EINVAL) without a shell, so resolve the binary like any other
108 * command and run a `.cmd` or `.bat` under cmd.exe. Node quotes a path with
109 * spaces, and inside those quotes cmd takes every character literally. A path
110 * without spaces is not quoted, so its metacharacters are escaped instead.
111 */
112export const museLaunch = Effect.fn("museLaunch")(function* (
113 binaryPath: string,
114 environment?: NodeJS.ProcessEnv,
115): Effect.fn.Return<MuseLaunch> {
116 if ((yield* HostProcess.Platform) !== "win32") return { command: binaryPath, args: [] };
117 const env = environment ?? (yield* HostProcess.Environment);
118 const resolveExecutable = yield* SpawnExecutableResolution;
119 const resolved = resolveExecutable(binaryPath, "win32", env) ?? binaryPath;
120 if (!/\.(?:cmd|bat)$/i.test(resolved)) return { command: resolved, args: [] };
121 const systemRoot = windowsEnvironmentValue(env, "SystemRoot");
122 return {
123 command:
124 windowsEnvironmentValue(env, "ComSpec") ??
125 (systemRoot ? `${systemRoot}\\System32\\cmd.exe` : "cmd.exe"),
126 // /d skips cmd's AutoRun commands, which could write into Muse's stdout. cmd /c drops
127 // the quotes from a command that starts with one and holds `&`, `@`, `^` or a
128 // parenthesis, so a quoted path follows a bare `@` (echo off), which keeps them.
129 args: [
130 "/d",
131 "/c",
132 ...(/\s/.test(resolved) ? ["@", resolved] : [resolved.replace(/[()&<>@^|]/g, "^$&")]),
133 ],
134 };
135});
136
59export function museApprovalMode(runtimeMode: RuntimeMode) {137export function museApprovalMode(runtimeMode: RuntimeMode) {
60 return runtimeMode === "full-access" ? "allowAll" : "promptUnmatched";138 return runtimeMode === "full-access" ? "allowAll" : "promptUnmatched";
61}139}
...@@ -89,9 +167,10 @@ export async function createMuseSdkHost(...@@ -89,9 +167,10 @@ export async function createMuseSdkHost(
89 spawn: typeof spawnMspConnection = spawnMspConnection,167 spawn: typeof spawnMspConnection = spawnMspConnection,
90): Promise<MuseSdkHost> {168): Promise<MuseSdkHost> {
91 options.signal?.throwIfAborted();169 options.signal?.throwIfAborted();
170 const launch = options.launch ?? { command: options.binaryPath, args: [] };
92 const handshake = spawn({171 const handshake = spawn({
93 command: options.binaryPath,172 command: launch.command,
94 args: [...(options.launchArgs ?? []), ...museServeArgs(options)],173 args: [...launch.args, ...(options.launchArgs ?? []), ...museServeArgs(options)],
95 ...(options.cwd ? { cwd: options.cwd } : {}),174 ...(options.cwd ? { cwd: options.cwd } : {}),
96 // Callers pass an environment already built with makeMuseEnvironment.175 // Callers pass an environment already built with makeMuseEnvironment.
97 env: options.environment ?? makeMuseEnvironment(),176 env: options.environment ?? makeMuseEnvironment(),
...@@ -148,9 +227,10 @@ export const createMuseSdkHostEffect = Effect.fn("createMuseSdkHostEffect")(func...@@ -148,9 +227,10 @@ export const createMuseSdkHostEffect = Effect.fn("createMuseSdkHostEffect")(func
148 options: Omit<MuseSdkHostOptions, "signal">,227 options: Omit<MuseSdkHostOptions, "signal">,
149 createHost: typeof createMuseSdkHost = createMuseSdkHost,228 createHost: typeof createMuseSdkHost = createMuseSdkHost,
150) {229) {
230 const launch = options.launch ?? (yield* museLaunch(options.binaryPath, options.environment));
151 let startup: Promise<MuseSdkHost> | undefined;231 let startup: Promise<MuseSdkHost> | undefined;
152 return yield* Effect.tryPromise((signal) => {232 return yield* Effect.tryPromise((signal) => {
153 startup = createHost({ ...options, signal });233 startup = createHost({ ...options, launch, signal });
154 return startup;234 return startup;
155 }).pipe(235 }).pipe(
156 Effect.onInterrupt(() =>236 Effect.onInterrupt(() =>
packages/provider-muse/src/testing.ts+5-2
...@@ -1,13 +1,16 @@...@@ -1,13 +1,16 @@
1/**1/**
2 * Muse Code internals for replay tests and the fixture recorder: the MSP2 * Muse Code internals for replay tests and the fixture recorder: how Muse is
3 * launch arguments, initialize parameters, environment, and version parser.3 * started, the MSP launch arguments, initialize parameters, environment, the
4 * workspace root `turn/start` sends, and version parser.
4 *5 *
5 * @module provider-muse/testing6 * @module provider-muse/testing
6 */7 */
7export {8export {
8 makeMuseEnvironment,9 makeMuseEnvironment,
9 museInitializeParams,10 museInitializeParams,
11 museLaunch,
10 museServeArgs,12 museServeArgs,
13 museWorkspaceRoot,
11 type MuseSdkHost,14 type MuseSdkHost,
12 type MuseSdkHostOptions,15 type MuseSdkHostOptions,
13} from "./server/sdk.ts";16} from "./server/sdk.ts";