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";
2727import {
2828 connectMuseTransport,
2929 layer as museReplayLayer,
30 materializeMuseReplayWorkspace,
3031 MUSE_MSP_REPLAY_PROTOCOL,
3132 MUSE_PROVIDER_KIND,
3233 museRecordLabel,
......@@ -35,14 +36,15 @@ import {
3536import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
3637import {
3738 makeMuseEnvironment,
39 museLaunch,
3840 museServeArgs,
41 museWorkspaceRoot,
3942 parseMuseVersion,
4043} from "@t3tools/provider-muse/testing";
4144import { provideDeterministicTestRuntime } from "../src/orchestration-v2/testkit/DeterministicRuntime.ts";
4245import { ORCHESTRATOR_REPLAY_FIXTURES } from "../src/orchestration-v2/testkit/fixtures/index.ts";
4346import { materializeFixtureInput } from "../src/orchestration-v2/testkit/fixtures/shared.ts";
4447import { runOrchestratorV2ProviderReplayScenario } from "../src/orchestration-v2/testkit/ProviderReplayHarness.ts";
45import { materializeReplayTranscriptWorkspace } from "@t3tools/provider-testing/replayTranscript";
4648import {
4749 checkpointWorkspace,
4850 makeCheckpointWorkspace,
......@@ -66,6 +68,8 @@ if (fixture === undefined || variant === undefined) {
6668}
6769
6870const 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));
6973const home = process.env.HOME ?? "";
7074
7175function isRecord(value: unknown): value is Record<string, unknown> {
......@@ -147,7 +151,7 @@ function makeRecordingCreateHost(
147151 };
148152 record("expect_outbound", { type: "host_start", args });
149153 // 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], {
151155 cwd: options.cwd,
152156 env: options.environment ?? makeMuseEnvironment(),
153157 stdio: ["pipe", "pipe", "inherit"],
......@@ -208,7 +212,11 @@ function makeRecordingCreateHost(
208212}
209213
210214const 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 );
212220 return parseMuseVersion(output) ?? output.trim();
213221});
214222
......@@ -224,6 +232,7 @@ const record = Effect.gen(function* () {
224232 yield* Effect.addFinalizer(() =>
225233 fs.remove(workspace, { recursive: true, force: true }).pipe(Effect.ignore),
226234 );
235 const canonicalWorkspace = yield* fs.realPath(workspace);
227236
228237 const entries: Array<ProviderReplayEntry> = [];
229238 const commandIds: Array<Array<string>> = [];
......@@ -276,7 +285,12 @@ const record = Effect.gen(function* () {
276285 model: variant.modelSelection.model,
277286 commandIds,
278287 },
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 ]),
280294 } satisfies ProviderReplayTranscript;
281295
282296 yield* Effect.gen(function* () {
......@@ -286,7 +300,7 @@ const record = Effect.gen(function* () {
286300 {
287301 name: `${fixture.name}/muse:verify`,
288302 transcript: yield* MuseOrchestratorReplayHarness.decodeTranscript(
289 materializeReplayTranscriptWorkspace(transcript, yield* fs.realPath(replayWorkspace)),
303 yield* materializeMuseReplayWorkspace(transcript, replayWorkspace),
290304 ),
291305 commands: materialized.commands,
292306 steps: materialized.steps,
apps/server/src/orchestration-v2/Adapters/MuseAdapterV2.testkit.ts+32
......@@ -24,6 +24,7 @@ import {
2424} from "@t3tools/contracts";
2525import { MuseSettings } from "@t3tools/provider-muse/settings";
2626import * as Effect from "effect/Effect";
27import * as FileSystem from "effect/FileSystem";
2728import * as Layer from "effect/Layer";
2829import * as Schema from "effect/Schema";
2930
......@@ -31,6 +32,7 @@ import * as TestProviderHost from "@t3tools/provider-testing/TestProviderHost";
3132import {
3233 museInitializeParams,
3334 museServeArgs,
35 museWorkspaceRoot,
3436 type MuseSdkHost,
3537 type MuseSdkHostOptions,
3638} from "@t3tools/provider-muse/testing";
......@@ -38,6 +40,7 @@ import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
3840import * as ProviderAdapterRegistry from "../ProviderAdapterRegistry.ts";
3941import * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests";
4042import type { OrchestratorV2ProviderReplayHarness } from "../testkit/ProviderReplayHarness.ts";
43import { materializeReplayTranscriptWorkspace } from "@t3tools/provider-testing/replayTranscript";
4144import { makeMuseAdapterV2 } from "@t3tools/provider-muse/server";
4245
4346export const MUSE_PROVIDER_KIND = "muse";
......@@ -468,6 +471,35 @@ export function layer(input: {
468471 );
469472}
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
471503export const MuseOrchestratorReplayHarness: OrchestratorV2ProviderReplayHarness<
472504 MuseReplayTranscript,
473505 MuseReplayTranscriptDecodeError
apps/server/src/orchestration-v2/testkit/OrchestratorReplayFixtures.integration.test.ts+5-9
......@@ -19,7 +19,10 @@ import {
1919 OPENCODE2_HTTP_PROTOCOL,
2020 OpenCode2OrchestratorReplayHarness,
2121} from "../Adapters/OpenCode2AdapterV2.testkit.ts";
22import { MuseOrchestratorReplayHarness } from "../Adapters/MuseAdapterV2.testkit.ts";
22import {
23 materializeMuseReplayWorkspace,
24 MuseOrchestratorReplayHarness,
25} from "../Adapters/MuseAdapterV2.testkit.ts";
2326import { PiOrchestratorReplayHarness } from "../Adapters/PiAdapterV2.testkit.ts";
2427import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
2528import { provideDeterministicTestRuntime } from "./DeterministicRuntime.ts";
......@@ -101,18 +104,11 @@ const runFixtureProvider = Effect.fn("runOrchestratorReplayFixture")(function* <
101104 );
102105 const fixtureInput = input.buildInput();
103106 const workspace = yield* checkpointWorkspace(input.fixtureName, fixtureInput.workspaceFiles);
104 // Muse canonicalizes its workspace path (macOS /var -> /private/var) before sending it.
105107 const transcript = yield* input.harness.decodeTranscript(
106108 input.driver.driver === "codex"
107109 ? materializeReplayTranscriptWorkspace(replayTranscript, workspace)
108110 : input.driver.driver === "muse"
109 ? materializeReplayTranscriptWorkspace(
110 replayTranscript,
111 yield* FileSystem.FileSystem.pipe(
112 Effect.flatMap((fs) => fs.realPath(workspace)),
113 Effect.provide(NodeServices.layer),
114 ),
115 )
111 ? yield* materializeMuseReplayWorkspace(replayTranscript, workspace)
116112 : replayTranscript,
117113 );
118114 const materialized = yield* materializeFixtureInput({
packages/provider-muse/src/server/adapter.test.ts+36
......@@ -18,6 +18,7 @@ import {
1818 type OrchestrationV2AppThread,
1919 type OrchestrationV2ProviderThread,
2020} from "@t3tools/contracts";
21import * as HostProcess from "@t3tools/shared/HostProcess";
2122import { MuseSettings } from "../settings.ts";
2223import * as DateTime from "effect/DateTime";
2324import * as Deferred from "effect/Deferred";
......@@ -499,6 +500,41 @@ describe("MuseAdapterV2", () => {
499500 }).pipe(Effect.scoped, Effect.provide(testLayer)),
500501 );
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
502538 it.effect("skips an unreadable usage notification without ending the turn", () =>
503539 Effect.gen(function* () {
504540 const fake = yield* makeFakeMuse();
packages/provider-muse/src/server/adapter.ts+3-1
......@@ -62,6 +62,7 @@ import {
6262import {
6363 createMuseSdkHostEffect,
6464 museApprovalMode,
65 museWorkspaceRoot,
6566 type createMuseSdkHost,
6667 type MuseSdkHost,
6768} from "./sdk.ts";
......@@ -277,6 +278,7 @@ export const makeMuseAdapterV2 = Effect.fn("makeMuseAdapterV2")(function* (
277278 const cwd = yield* fileSystem
278279 .realPath(requestedCwd)
279280 .pipe(Effect.orElseSucceed(() => requestedCwd));
281 const workspaceRoot = yield* museWorkspaceRoot(cwd);
280282 const now = yield* DateTime.now;
281283 let session: OrchestrationV2ProviderSession = {
282284 id: input.providerSessionId,
......@@ -1751,7 +1753,7 @@ export const makeMuseAdapterV2 = Effect.fn("makeMuseAdapterV2")(function* (
17511753 displayText: turnInput.message.text || "Image attachment",
17521754 // A resumed session keeps the root it was created with; pin it to
17531755 // this thread's current checkout so edits land where T3 tracks them.
1754 workspaceRoots: [cwd],
1756 workspaceRoots: [workspaceRoot],
17551757 ifBusy: "queue",
17561758 ...(effort ? { reasoningEffort: effort } : {}),
17571759 },
packages/provider-muse/src/server/sdk.test.ts+194
......@@ -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";
14import { spawnMspConnection, type MspHandshake, type SpawnedMspConnection } from "@muse-code/sdk";
25import { it } from "@effect/vitest";
6import * as HostProcess from "@t3tools/shared/HostProcess";
7import { SpawnExecutableResolution } from "@t3tools/shared/shell";
38import * as Effect from "effect/Effect";
49import * as Fiber from "effect/Fiber";
10import * as FileSystem from "effect/FileSystem";
11import * as Path from "effect/Path";
512import { afterEach, describe, expect, vi } from "vite-plus/test";
613import {
714 createMuseSdkHost,
815 createMuseSdkHostEffect,
916 makeMuseEnvironment,
1017 museApprovalMode,
18 museLaunch,
19 museVerbatimPath,
20 museWorkspaceRoot,
1121 type MuseSdkHost,
1222} from "./sdk.ts";
1323
......@@ -264,3 +274,187 @@ describe("Muse SDK host", () => {
264274 }),
265275 );
266276});
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 @@
11// @effect-diagnostics globalTimers:off
22// 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
36import {
47 spawnMspConnection,
58 type Connection,
......@@ -7,6 +10,8 @@ import {
710 type SpawnedMspConnection,
811} from "@muse-code/sdk";
912import type { RuntimeMode } from "@t3tools/contracts";
13import * as HostProcess from "@t3tools/shared/HostProcess";
14import { SpawnExecutableResolution } from "@t3tools/shared/shell";
1015import * as Effect from "effect/Effect";
1116
1217export interface MuseSdkHost {
......@@ -27,10 +32,18 @@ export interface MuseSdkHost {
2732 readonly close: () => Promise<void>;
2833}
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
3041export interface MuseSdkHostOptions {
3142 readonly binaryPath: string;
3243 /** Arguments before the serve arguments, for a launcher in front of the binary. */
3344 readonly launchArgs?: ReadonlyArray<string>;
45 /** How to start `binaryPath`, from `museLaunch`; without it the binary path is run as is. */
46 readonly launch?: MuseLaunch;
3447 readonly cwd?: string;
3548 readonly environment?: NodeJS.ProcessEnv;
3649 readonly runtimeMode?: RuntimeMode;
......@@ -56,6 +69,71 @@ export function makeMuseEnvironment(
5669 };
5770}
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
59137export function museApprovalMode(runtimeMode: RuntimeMode) {
60138 return runtimeMode === "full-access" ? "allowAll" : "promptUnmatched";
61139}
......@@ -89,9 +167,10 @@ export async function createMuseSdkHost(
89167 spawn: typeof spawnMspConnection = spawnMspConnection,
90168): Promise<MuseSdkHost> {
91169 options.signal?.throwIfAborted();
170 const launch = options.launch ?? { command: options.binaryPath, args: [] };
92171 const handshake = spawn({
93 command: options.binaryPath,
94 args: [...(options.launchArgs ?? []), ...museServeArgs(options)],
172 command: launch.command,
173 args: [...launch.args, ...(options.launchArgs ?? []), ...museServeArgs(options)],
95174 ...(options.cwd ? { cwd: options.cwd } : {}),
96175 // Callers pass an environment already built with makeMuseEnvironment.
97176 env: options.environment ?? makeMuseEnvironment(),
......@@ -148,9 +227,10 @@ export const createMuseSdkHostEffect = Effect.fn("createMuseSdkHostEffect")(func
148227 options: Omit<MuseSdkHostOptions, "signal">,
149228 createHost: typeof createMuseSdkHost = createMuseSdkHost,
150229) {
230 const launch = options.launch ?? (yield* museLaunch(options.binaryPath, options.environment));
151231 let startup: Promise<MuseSdkHost> | undefined;
152232 return yield* Effect.tryPromise((signal) => {
153 startup = createHost({ ...options, signal });
233 startup = createHost({ ...options, launch, signal });
154234 return startup;
155235 }).pipe(
156236 Effect.onInterrupt(() =>
packages/provider-muse/src/testing.ts+5-2
......@@ -1,13 +1,16 @@
11/**
2 * Muse Code internals for replay tests and the fixture recorder: the MSP
3 * launch arguments, initialize parameters, environment, and version parser.
2 * Muse Code internals for replay tests and the fixture recorder: how Muse is
3 * started, the MSP launch arguments, initialize parameters, environment, the
4 * workspace root `turn/start` sends, and version parser.
45 *
56 * @module provider-muse/testing
67 */
78export {
89 makeMuseEnvironment,
910 museInitializeParams,
11 museLaunch,
1012 museServeArgs,
13 museWorkspaceRoot,
1114 type MuseSdkHost,
1215 type MuseSdkHostOptions,
1316} from "./server/sdk.ts";