From 953edeec9dcddd7afc582926eb1b65cfd82dc784 Mon Sep 17 00:00:00 2001 From: Nicholas Tindle Date: Sat, 10 Oct 2026 18:17:13 -0500 Subject: [PATCH] fix(server): Muse turns no longer fail on Windows (#17163) Co-authored-by: Claude Opus 5.5 Co-authored-by: Theo Browne --- .../scripts/record-muse-msp-replay-fixture.ts | 24 ++- .../Adapters/MuseAdapterV2.testkit.ts | 32 +++ ...estratorReplayFixtures.integration.test.ts | 14 +- .../provider-muse/src/server/adapter.test.ts | 36 ++++ packages/provider-muse/src/server/adapter.ts | 4 +- packages/provider-muse/src/server/sdk.test.ts | 194 ++++++++++++++++++ packages/provider-muse/src/server/sdk.ts | 86 +++++++- packages/provider-muse/src/testing.ts | 7 +- 8 files changed, 377 insertions(+), 20 deletions(-) diff --git a/apps/server/scripts/record-muse-msp-replay-fixture.ts b/apps/server/scripts/record-muse-msp-replay-fixture.ts index a3cbfc6f9d14cb2f4c060f50fd341c963d28fa2b..7a1cda34dd3c2b2ccb850f64b9e4cecc5bb950c2 100644 --- a/apps/server/scripts/record-muse-msp-replay-fixture.ts +++ b/apps/server/scripts/record-muse-msp-replay-fixture.ts @@ -27,6 +27,7 @@ import { TestClock } from "effect/testing"; import { connectMuseTransport, layer as museReplayLayer, + materializeMuseReplayWorkspace, MUSE_MSP_REPLAY_PROTOCOL, MUSE_PROVIDER_KIND, museRecordLabel, @@ -35,14 +36,15 @@ import { import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import { makeMuseEnvironment, + museLaunch, museServeArgs, + museWorkspaceRoot, parseMuseVersion, } from "@t3tools/provider-muse/testing"; import { provideDeterministicTestRuntime } from "../src/orchestration-v2/testkit/DeterministicRuntime.ts"; import { ORCHESTRATOR_REPLAY_FIXTURES } from "../src/orchestration-v2/testkit/fixtures/index.ts"; import { materializeFixtureInput } from "../src/orchestration-v2/testkit/fixtures/shared.ts"; import { runOrchestratorV2ProviderReplayScenario } from "../src/orchestration-v2/testkit/ProviderReplayHarness.ts"; -import { materializeReplayTranscriptWorkspace } from "@t3tools/provider-testing/replayTranscript"; import { checkpointWorkspace, makeCheckpointWorkspace, @@ -66,6 +68,8 @@ if (fixture === undefined || variant === undefined) { } const museBinary = process.env.T3_MUSE_BIN ?? "muse"; +// Started as T3 starts it, so Windows needs no T3_MUSE_BIN for Muse's `muse.cmd` launcher. +const museStart = Effect.runSync(museLaunch(museBinary)); const home = process.env.HOME ?? ""; function isRecord(value: unknown): value is Record { @@ -147,7 +151,7 @@ function makeRecordingCreateHost( }; record("expect_outbound", { type: "host_start", args }); // The binary named by T3_MUSE_BIN, so the recorded version matches what ran. - const child = NodeChildProcess.spawn(museBinary, args, { + const child = NodeChildProcess.spawn(museStart.command, [...museStart.args, ...args], { cwd: options.cwd, env: options.environment ?? makeMuseEnvironment(), stdio: ["pipe", "pipe", "inherit"], @@ -208,7 +212,11 @@ function makeRecordingCreateHost( } const readMuseVersion = Effect.sync(() => { - const output = NodeChildProcess.execFileSync(museBinary, ["--version"], { encoding: "utf8" }); + const output = NodeChildProcess.execFileSync( + museStart.command, + [...museStart.args, "--version"], + { encoding: "utf8" }, + ); return parseMuseVersion(output) ?? output.trim(); }); @@ -224,6 +232,7 @@ const record = Effect.gen(function* () { yield* Effect.addFinalizer(() => fs.remove(workspace, { recursive: true, force: true }).pipe(Effect.ignore), ); + const canonicalWorkspace = yield* fs.realPath(workspace); const entries: Array = []; const commandIds: Array> = []; @@ -276,7 +285,12 @@ const record = Effect.gen(function* () { model: variant.modelSelection.model, commandIds, }, - entries: normalizeEntries(entries, [yield* fs.realPath(workspace), workspace]), + // The verbatim root first: it contains the canonical path. + entries: normalizeEntries(entries, [ + yield* museWorkspaceRoot(canonicalWorkspace), + canonicalWorkspace, + workspace, + ]), } satisfies ProviderReplayTranscript; yield* Effect.gen(function* () { @@ -286,7 +300,7 @@ const record = Effect.gen(function* () { { name: `${fixture.name}/muse:verify`, transcript: yield* MuseOrchestratorReplayHarness.decodeTranscript( - materializeReplayTranscriptWorkspace(transcript, yield* fs.realPath(replayWorkspace)), + yield* materializeMuseReplayWorkspace(transcript, replayWorkspace), ), commands: materialized.commands, steps: materialized.steps, diff --git a/apps/server/src/orchestration-v2/Adapters/MuseAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/MuseAdapterV2.testkit.ts index dbe4f7068ebbeb405dba4213333e87f4d191008a..a1e39aee40a76098873b9f3d243aca42b515d625 100644 --- a/apps/server/src/orchestration-v2/Adapters/MuseAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/MuseAdapterV2.testkit.ts @@ -24,6 +24,7 @@ import { } from "@t3tools/contracts"; import { MuseSettings } from "@t3tools/provider-muse/settings"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; @@ -31,6 +32,7 @@ import * as TestProviderHost from "@t3tools/provider-testing/TestProviderHost"; import { museInitializeParams, museServeArgs, + museWorkspaceRoot, type MuseSdkHost, type MuseSdkHostOptions, } from "@t3tools/provider-muse/testing"; @@ -38,6 +40,7 @@ import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProviderAdapterRegistry from "../ProviderAdapterRegistry.ts"; import * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests"; import type { OrchestratorV2ProviderReplayHarness } from "../testkit/ProviderReplayHarness.ts"; +import { materializeReplayTranscriptWorkspace } from "@t3tools/provider-testing/replayTranscript"; import { makeMuseAdapterV2 } from "@t3tools/provider-muse/server"; export const MUSE_PROVIDER_KIND = "muse"; @@ -468,6 +471,35 @@ export function layer(input: { ); } +/** + * Fills a recording's `` with what the adapter sends on this host: + * the canonical path (macOS /var -> /private/var), which `turn/start` names in + * museWorkspaceRoot's form (verbatim on Windows). + */ +export const materializeMuseReplayWorkspace = Effect.fn("materializeMuseReplayWorkspace")( + function* (transcript: ProviderReplayTranscript, workspace: string) { + const canonical = yield* FileSystem.FileSystem.pipe( + Effect.flatMap((fs) => fs.realPath(workspace)), + Effect.provide(NodeServices.layer), + ); + const plain = materializeReplayTranscriptWorkspace(transcript, canonical); + const turnStarts = materializeReplayTranscriptWorkspace( + transcript, + yield* museWorkspaceRoot(canonical), + ); + return { + ...plain, + entries: plain.entries.map((entry, index) => + entry.type === "expect_outbound" && + isRecord(entry.frame) && + entry.frame.method === "turn/start" + ? turnStarts.entries[index]! + : entry, + ), + }; + }, +); + export const MuseOrchestratorReplayHarness: OrchestratorV2ProviderReplayHarness< MuseReplayTranscript, MuseReplayTranscriptDecodeError diff --git a/apps/server/src/orchestration-v2/testkit/OrchestratorReplayFixtures.integration.test.ts b/apps/server/src/orchestration-v2/testkit/OrchestratorReplayFixtures.integration.test.ts index db7ee893b355d9ba276bb07c0516f8bab1af66ff..4ad182401d7db7ea36138f72db95e73a64153567 100644 --- a/apps/server/src/orchestration-v2/testkit/OrchestratorReplayFixtures.integration.test.ts +++ b/apps/server/src/orchestration-v2/testkit/OrchestratorReplayFixtures.integration.test.ts @@ -19,7 +19,10 @@ import { OPENCODE2_HTTP_PROTOCOL, OpenCode2OrchestratorReplayHarness, } from "../Adapters/OpenCode2AdapterV2.testkit.ts"; -import { MuseOrchestratorReplayHarness } from "../Adapters/MuseAdapterV2.testkit.ts"; +import { + materializeMuseReplayWorkspace, + MuseOrchestratorReplayHarness, +} from "../Adapters/MuseAdapterV2.testkit.ts"; import { PiOrchestratorReplayHarness } from "../Adapters/PiAdapterV2.testkit.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import { provideDeterministicTestRuntime } from "./DeterministicRuntime.ts"; @@ -101,18 +104,11 @@ const runFixtureProvider = Effect.fn("runOrchestratorReplayFixture")(function* < ); const fixtureInput = input.buildInput(); const workspace = yield* checkpointWorkspace(input.fixtureName, fixtureInput.workspaceFiles); - // Muse canonicalizes its workspace path (macOS /var -> /private/var) before sending it. const transcript = yield* input.harness.decodeTranscript( input.driver.driver === "codex" ? materializeReplayTranscriptWorkspace(replayTranscript, workspace) : input.driver.driver === "muse" - ? materializeReplayTranscriptWorkspace( - replayTranscript, - yield* FileSystem.FileSystem.pipe( - Effect.flatMap((fs) => fs.realPath(workspace)), - Effect.provide(NodeServices.layer), - ), - ) + ? yield* materializeMuseReplayWorkspace(replayTranscript, workspace) : replayTranscript, ); const materialized = yield* materializeFixtureInput({ diff --git a/packages/provider-muse/src/server/adapter.test.ts b/packages/provider-muse/src/server/adapter.test.ts index ae9ca1d351b2724e38634e595c0b9fd0604fb97d..2a5417ba0d06aaa2334d3d99f7774ab9ecb9dd3a 100644 --- a/packages/provider-muse/src/server/adapter.test.ts +++ b/packages/provider-muse/src/server/adapter.test.ts @@ -18,6 +18,7 @@ import { type OrchestrationV2AppThread, type OrchestrationV2ProviderThread, } from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import { MuseSettings } from "../settings.ts"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; @@ -499,6 +500,41 @@ describe("MuseAdapterV2", () => { }).pipe(Effect.scoped, Effect.provide(testLayer)), ); + it.effect("pins Windows turns to Muse's verbatim root and starts Muse in the plain path", () => + Effect.gen(function* () { + const fake = yield* makeFakeMuse(); + let hostCwd: string | undefined; + const policy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + ...runtimePolicy, + cwd: "C:\\work\\repo", + }); + const harness = yield* makeHarness( + fake, + INSTANCE_ID, + undefined, + undefined, + undefined, + policy, + { + createHost: async (options) => { + hostCwd = options.cwd; + return fake.host; + }, + }, + ); + yield* startConversation(harness, fake); + const turnStart = fake.calls.find((call) => call.method === "turn/start"); + // Muse 1.4.3 rejects any other form: "expected a canonical path (resolves to \\?\C:\…)". + assert.deepStrictEqual(turnStart?.params.workspaceRoots, ["\\\\?\\C:\\work\\repo"]); + // The muse.cmd launcher runs under cmd.exe, which cannot start in a verbatim directory. + assert.strictEqual(hostCwd, "C:\\work\\repo"); + }).pipe( + Effect.provideService(HostProcess.Platform, "win32"), + Effect.scoped, + Effect.provide(testLayer), + ), + ); + it.effect("skips an unreadable usage notification without ending the turn", () => Effect.gen(function* () { const fake = yield* makeFakeMuse(); diff --git a/packages/provider-muse/src/server/adapter.ts b/packages/provider-muse/src/server/adapter.ts index ded0b2efd493ce9167112267a6826e19b0f8c5ab..4c91835969e64e095af82523947005ee9781ad1d 100644 --- a/packages/provider-muse/src/server/adapter.ts +++ b/packages/provider-muse/src/server/adapter.ts @@ -62,6 +62,7 @@ import { import { createMuseSdkHostEffect, museApprovalMode, + museWorkspaceRoot, type createMuseSdkHost, type MuseSdkHost, } from "./sdk.ts"; @@ -277,6 +278,7 @@ export const makeMuseAdapterV2 = Effect.fn("makeMuseAdapterV2")(function* ( const cwd = yield* fileSystem .realPath(requestedCwd) .pipe(Effect.orElseSucceed(() => requestedCwd)); + const workspaceRoot = yield* museWorkspaceRoot(cwd); const now = yield* DateTime.now; let session: OrchestrationV2ProviderSession = { id: input.providerSessionId, @@ -1751,7 +1753,7 @@ export const makeMuseAdapterV2 = Effect.fn("makeMuseAdapterV2")(function* ( displayText: turnInput.message.text || "Image attachment", // A resumed session keeps the root it was created with; pin it to // this thread's current checkout so edits land where T3 tracks them. - workspaceRoots: [cwd], + workspaceRoots: [workspaceRoot], ifBusy: "queue", ...(effort ? { reasoningEffort: effort } : {}), }, diff --git a/packages/provider-muse/src/server/sdk.test.ts b/packages/provider-muse/src/server/sdk.test.ts index 8c92498ca5170f10839e2e65e3e624ad75e3eb6f..676fa474a2e9750762a691cc9792b516f3f8df43 100644 --- a/packages/provider-muse/src/server/sdk.test.ts +++ b/packages/provider-muse/src/server/sdk.test.ts @@ -1,13 +1,23 @@ +// @effect-diagnostics-next-line nodeBuiltinImport:off -- The Muse SDK spawns with Node's child_process, so the launch test quotes arguments the same way. +import * as NodeChildProcess from "node:child_process"; +import * as NodeServices from "@effect/platform-node/NodeServices"; import { spawnMspConnection, type MspHandshake, type SpawnedMspConnection } from "@muse-code/sdk"; import { it } from "@effect/vitest"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import { SpawnExecutableResolution } from "@t3tools/shared/shell"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; import { afterEach, describe, expect, vi } from "vite-plus/test"; import { createMuseSdkHost, createMuseSdkHostEffect, makeMuseEnvironment, museApprovalMode, + museLaunch, + museVerbatimPath, + museWorkspaceRoot, type MuseSdkHost, } from "./sdk.ts"; @@ -264,3 +274,187 @@ describe("Muse SDK host", () => { }), ); }); + +describe("Muse launch", () => { + const CMD = "C:\\Windows\\system32\\cmd.exe"; + const onWindows = (resolved: string | undefined, env: NodeJS.ProcessEnv = { ComSpec: CMD }) => + museLaunch("muse", env).pipe( + Effect.provideService(HostProcess.Platform, "win32"), + Effect.provideService(SpawnExecutableResolution, () => resolved), + ); + + it.effect("starts the binary itself off Windows", () => + Effect.gen(function* () { + expect(yield* museLaunch("muse")).toEqual({ command: "muse", args: [] }); + }).pipe( + Effect.provideService(HostProcess.Platform, "linux"), + Effect.provideService(SpawnExecutableResolution, () => { + throw new Error("Nothing is resolved off Windows."); + }), + ), + ); + + // Node spawns neither a bare `muse` nor a `.cmd` file without a shell on Windows. + it.effect("runs an exe directly and a launcher script under cmd.exe on Windows", () => + Effect.gen(function* () { + expect(yield* onWindows("C:\\Muse\\muse.exe")).toEqual({ + command: "C:\\Muse\\muse.exe", + args: [], + }); + // Node quotes a path with spaces, and the `@` before it keeps cmd from dropping the quotes. + const spaced = "C:\\Users\\Jane Doe\\AppData\\Local\\Programs\\muse\\muse.cmd"; + expect(yield* onWindows(spaced)).toEqual({ command: CMD, args: ["/d", "/c", "@", spaced] }); + const spacedAmpersand = "C:\\Users\\R&D Team\\AppData\\Local\\Programs\\muse\\muse.cmd"; + expect(yield* onWindows(spacedAmpersand)).toEqual({ + command: CMD, + args: ["/d", "/c", "@", spacedAmpersand], + }); + // Unquoted, cmd would split the command at `&`. + expect(yield* onWindows("C:\\Users\\R&D(1)\\muse.cmd")).toEqual({ + command: CMD, + args: ["/d", "/c", "C:\\Users\\R^&D^(1^)\\muse.cmd"], + }); + expect((yield* onWindows("C:\\Muse\\muse.bat", { SYSTEMROOT: "C:\\Windows" })).command).toBe( + "C:\\Windows\\System32\\cmd.exe", + ); + // A binary that cannot be found is spawned as given, so the failure reports it. + expect(yield* onWindows(undefined)).toEqual({ command: "muse", args: [] }); + }), + ); + + // cmd's quote rules only show with a real cmd.exe, spawning as the SDK does. + it.effect("starts a real launcher from a folder with spaces and cmd metacharacters", () => + Effect.gen(function* () { + if ((yield* HostProcess.Platform) !== "win32") return; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const folder = path.join( + yield* fs.makeTempDirectoryScoped({ prefix: "MuseLaunch-" }), + "R&D Team (x) @ ^y", + ); + yield* fs.makeDirectory(folder); + const launcher = path.join(folder, "muse.cmd"); + // Records its arguments next to itself; reaching the file at all proves cmd found it. + yield* fs.writeFileString(launcher, '@echo off\r\necho %*>"%~dp0args.txt"\r\n'); + const launch = yield* museLaunch(launcher); + const run = NodeChildProcess.spawnSync(launch.command, [...launch.args, "serve", "--x"], { + cwd: folder, + encoding: "utf8", + }); + expect(run.status, run.stderr).toBe(0); + expect((yield* fs.readFileString(path.join(folder, "args.txt"))).trim()).toBe("serve --x"); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("starts Muse's Windows launcher through cmd.exe", () => + Effect.gen(function* () { + const fake = mockSpawn(); + const starting = yield* Effect.forkChild( + createMuseSdkHostEffect({ + binaryPath: "muse", + environment: { ComSpec: CMD }, + readOnly: true, + }), + ); + yield* Effect.promise(() => fake.initializing.promise); + const spawned = vi.mocked(spawnMspConnection).mock.lastCall?.[0]; + fake.startup.resolve(fake.ready); + const host = yield* Fiber.join(starting); + fake.shutdown.resolve({ code: 0, signal: null }); + yield* Effect.promise(() => host.close()); + expect(spawned).toMatchObject({ + command: CMD, + args: [ + "/d", + "/c", + "C:\\Muse\\muse.cmd", + "serve", + "--disable-shell", + "--disable-write", + "--no-session-log", + ], + }); + }).pipe( + Effect.provideService(HostProcess.Platform, "win32"), + Effect.provideService(SpawnExecutableResolution, () => "C:\\Muse\\muse.cmd"), + ), + ); + + it.effect("passes launcher arguments to the binary that cmd.exe starts", () => + Effect.gen(function* () { + const fake = mockSpawn(); + const starting = yield* Effect.forkChild( + createMuseSdkHostEffect({ + binaryPath: "muse", + launchArgs: ["--launcher-arg"], + environment: { ComSpec: CMD }, + readOnly: true, + }), + ); + yield* Effect.promise(() => fake.initializing.promise); + const spawned = vi.mocked(spawnMspConnection).mock.lastCall?.[0]; + fake.startup.resolve(fake.ready); + const host = yield* Fiber.join(starting); + fake.shutdown.resolve({ code: 0, signal: null }); + yield* Effect.promise(() => host.close()); + expect(spawned).toMatchObject({ + command: CMD, + args: [ + "/d", + "/c", + "C:\\Muse\\muse.cmd", + "--launcher-arg", + "serve", + "--disable-shell", + "--disable-write", + "--no-session-log", + ], + }); + }).pipe( + Effect.provideService(HostProcess.Platform, "win32"), + Effect.provideService(SpawnExecutableResolution, () => "C:\\Muse\\muse.cmd"), + ), + ); +}); + +describe("Muse workspace root", () => { + it("writes Windows paths in their verbatim form", () => { + expect(museVerbatimPath("C:\\Users\\Person\\repo")).toBe("\\\\?\\C:\\Users\\Person\\repo"); + expect(museVerbatimPath("\\\\server\\share\\repo")).toBe("\\\\?\\UNC\\server\\share\\repo"); + expect(museVerbatimPath("\\\\?\\C:\\repo")).toBe("\\\\?\\C:\\repo"); + }); + + it.effect("leaves the root alone off Windows", () => + Effect.gen(function* () { + expect(yield* museWorkspaceRoot("/workspace/repo")).toBe("/workspace/repo"); + }).pipe(Effect.provideService(HostProcess.Platform, "linux")), + ); + + it.effect("keeps a Windows root it cannot resolve, in verbatim form", () => + Effect.gen(function* () { + expect(yield* museWorkspaceRoot("C:\\definitely-missing\\repo")).toBe( + "\\\\?\\C:\\definitely-missing\\repo", + ); + // `/` would be taken literally inside a verbatim path. + expect(yield* museWorkspaceRoot("C:/definitely-missing/repo")).toBe( + "\\\\?\\C:\\definitely-missing\\repo", + ); + expect(yield* museWorkspaceRoot("//server/share/missing")).toBe( + "\\\\?\\UNC\\server\\share\\missing", + ); + }).pipe(Effect.provideService(HostProcess.Platform, "win32")), + ); + + // FileSystem.realPath keeps the case it is given; Muse rejects a root whose case differs from the disk's. + it.effect("fixes the case of a Windows root natively", () => + Effect.gen(function* () { + if ((yield* HostProcess.Platform) !== "win32") return; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const created = yield* fs.makeTempDirectoryScoped({ prefix: "MuseRoot-" }); + const root = yield* museWorkspaceRoot(created.toLowerCase()); + expect(root.startsWith("\\\\?\\")).toBe(true); + expect(root.endsWith(`\\${path.basename(created)}`)).toBe(true); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); +}); diff --git a/packages/provider-muse/src/server/sdk.ts b/packages/provider-muse/src/server/sdk.ts index 43c958005af137760a8edba5c4d7c956218863ee..c950e3ad1b9cc768c16ce1217d065d96b7979490 100644 --- a/packages/provider-muse/src/server/sdk.ts +++ b/packages/provider-muse/src/server/sdk.ts @@ -1,5 +1,8 @@ // @effect-diagnostics globalTimers:off // The SDK owns process shutdown; native deadlines cover initialize before an Effect resource exists. +// @effect-diagnostics-next-line nodeBuiltinImport:off -- FileSystem.realPath is Node's JS realpath; museWorkspaceRoot needs the native one. +import * as NodeFSP from "node:fs/promises"; + import { spawnMspConnection, type Connection, @@ -7,6 +10,8 @@ import { type SpawnedMspConnection, } from "@muse-code/sdk"; import type { RuntimeMode } from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import { SpawnExecutableResolution } from "@t3tools/shared/shell"; import * as Effect from "effect/Effect"; export interface MuseSdkHost { @@ -27,10 +32,18 @@ export interface MuseSdkHost { readonly close: () => Promise; } +/** The program that runs `binaryPath`, and the arguments that go before the binary's own. */ +export interface MuseLaunch { + readonly command: string; + readonly args: ReadonlyArray; +} + export interface MuseSdkHostOptions { readonly binaryPath: string; /** Arguments before the serve arguments, for a launcher in front of the binary. */ readonly launchArgs?: ReadonlyArray; + /** How to start `binaryPath`, from `museLaunch`; without it the binary path is run as is. */ + readonly launch?: MuseLaunch; readonly cwd?: string; readonly environment?: NodeJS.ProcessEnv; readonly runtimeMode?: RuntimeMode; @@ -56,6 +69,71 @@ export function makeMuseEnvironment( }; } +/** Windows' verbatim form of a canonical path: `\\?\C:\…`, or `\\?\UNC\server\share\…`. */ +export function museVerbatimPath(path: string): string { + if (path.startsWith("\\\\?\\")) return path; + return path.startsWith("\\\\") ? `\\\\?\\UNC\\${path.slice(2)}` : `\\\\?\\${path}`; +} + +/** + * A workspace root in the form `turn/start` accepts. On Windows Muse rejects + * anything but the verbatim canonical path, true case and long names + * included, so resolve with the native realpath, which unlike + * FileSystem.realPath fixes case and expands 8.3 names. Muse itself still + * starts in the plain path: the `muse.cmd` launcher runs under cmd.exe, which + * cannot use a verbatim directory. + */ +export const museWorkspaceRoot = Effect.fn("museWorkspaceRoot")(function* (path: string) { + if ((yield* HostProcess.Platform) !== "win32") return path; + const canonical = yield* Effect.tryPromise(() => NodeFSP.realpath(path)).pipe( + // A verbatim path takes `/` literally, so an unresolved root needs Windows separators. + Effect.orElseSucceed(() => path.replaceAll("/", "\\")), + ); + return museVerbatimPath(canonical); +}); + +/** A Windows environment variable; their names are case-insensitive there. */ +function windowsEnvironmentValue(environment: NodeJS.ProcessEnv, name: string) { + const key = Object.keys(environment).find( + (candidate) => candidate.toUpperCase() === name.toUpperCase(), + ); + return key === undefined ? undefined : environment[key]; +} + +/** + * How to start Muse. The SDK spawns without a shell, which finds the `muse` + * launcher on PATH everywhere but Windows. There Node neither looks a bare + * `muse` up with PATHEXT (ENOENT) nor runs a `.cmd` file such as Muse's own + * launcher (EINVAL) without a shell, so resolve the binary like any other + * command and run a `.cmd` or `.bat` under cmd.exe. Node quotes a path with + * spaces, and inside those quotes cmd takes every character literally. A path + * without spaces is not quoted, so its metacharacters are escaped instead. + */ +export const museLaunch = Effect.fn("museLaunch")(function* ( + binaryPath: string, + environment?: NodeJS.ProcessEnv, +): Effect.fn.Return { + if ((yield* HostProcess.Platform) !== "win32") return { command: binaryPath, args: [] }; + const env = environment ?? (yield* HostProcess.Environment); + const resolveExecutable = yield* SpawnExecutableResolution; + const resolved = resolveExecutable(binaryPath, "win32", env) ?? binaryPath; + if (!/\.(?:cmd|bat)$/i.test(resolved)) return { command: resolved, args: [] }; + const systemRoot = windowsEnvironmentValue(env, "SystemRoot"); + return { + command: + windowsEnvironmentValue(env, "ComSpec") ?? + (systemRoot ? `${systemRoot}\\System32\\cmd.exe` : "cmd.exe"), + // /d skips cmd's AutoRun commands, which could write into Muse's stdout. cmd /c drops + // the quotes from a command that starts with one and holds `&`, `@`, `^` or a + // parenthesis, so a quoted path follows a bare `@` (echo off), which keeps them. + args: [ + "/d", + "/c", + ...(/\s/.test(resolved) ? ["@", resolved] : [resolved.replace(/[()&<>@^|]/g, "^$&")]), + ], + }; +}); + export function museApprovalMode(runtimeMode: RuntimeMode) { return runtimeMode === "full-access" ? "allowAll" : "promptUnmatched"; } @@ -89,9 +167,10 @@ export async function createMuseSdkHost( spawn: typeof spawnMspConnection = spawnMspConnection, ): Promise { options.signal?.throwIfAborted(); + const launch = options.launch ?? { command: options.binaryPath, args: [] }; const handshake = spawn({ - command: options.binaryPath, - args: [...(options.launchArgs ?? []), ...museServeArgs(options)], + command: launch.command, + args: [...launch.args, ...(options.launchArgs ?? []), ...museServeArgs(options)], ...(options.cwd ? { cwd: options.cwd } : {}), // Callers pass an environment already built with makeMuseEnvironment. env: options.environment ?? makeMuseEnvironment(), @@ -148,9 +227,10 @@ export const createMuseSdkHostEffect = Effect.fn("createMuseSdkHostEffect")(func options: Omit, createHost: typeof createMuseSdkHost = createMuseSdkHost, ) { + const launch = options.launch ?? (yield* museLaunch(options.binaryPath, options.environment)); let startup: Promise | undefined; return yield* Effect.tryPromise((signal) => { - startup = createHost({ ...options, signal }); + startup = createHost({ ...options, launch, signal }); return startup; }).pipe( Effect.onInterrupt(() => diff --git a/packages/provider-muse/src/testing.ts b/packages/provider-muse/src/testing.ts index 810388c5afe984c7d44898409fc9878e998bae7f..56755167f366926c084be83a5e46399d4312fbf7 100644 --- a/packages/provider-muse/src/testing.ts +++ b/packages/provider-muse/src/testing.ts @@ -1,13 +1,16 @@ /** - * Muse Code internals for replay tests and the fixture recorder: the MSP - * launch arguments, initialize parameters, environment, and version parser. + * Muse Code internals for replay tests and the fixture recorder: how Muse is + * started, the MSP launch arguments, initialize parameters, environment, the + * workspace root `turn/start` sends, and version parser. * * @module provider-muse/testing */ export { makeMuseEnvironment, museInitializeParams, + museLaunch, museServeArgs, + museWorkspaceRoot, type MuseSdkHost, type MuseSdkHostOptions, } from "./server/sdk.ts"; -- 2.54.0