authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-04 19:29:46-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-04 19:29:46-07:00
logd28077e585d5123b9a562f4ebef257969c81aa31
tree608c0baa614291a3f3471f5f75d4fcd72b1617a9
parentd7fe47fd0957a34fc2e8bb05f12db19bac3403b6
signature Signed by PGP key B5690EEEBB952194

fix(server): quote copied native provider update commands (#9856)


4 files changed, 164 insertions(+), 5 deletions(-)

apps/server/src/provider/Drivers/CursorDriver.test.ts created+62
......@@ -0,0 +1,62 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { expect, it } from "@effect/vitest";
3import { ProviderInstanceId } from "@t3tools/contracts";
4import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
5import * as Effect from "effect/Effect";
6import * as Layer from "effect/Layer";
7import { HttpClient } from "effect/unstable/http";
8import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
9
10import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts";
11import { ServerConfig } from "../../config.ts";
12import { ServerSettingsService } from "../../serverSettings.ts";
13import { NoOpProviderEventLoggers, ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts";
14import { CursorDriver } from "./CursorDriver.ts";
15
16const testLayer = ServerConfig.layerTest(process.cwd(), {
17 prefix: "t3-cursor-driver-copy-command-",
18}).pipe(
19 Layer.provideMerge(NodeServices.layer),
20 Layer.provideMerge(ServerSettingsService.layerTest()),
21 Layer.provideMerge(
22 Layer.mock(BackgroundPolicy.BackgroundPolicy)({
23 shouldRunScopeWork: () => Effect.succeed(false),
24 }),
25 ),
26 Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)),
27 Layer.provideMerge(
28 Layer.succeed(
29 HttpClient.HttpClient,
30 HttpClient.make(() => Effect.die("Disabled Cursor must not make an HTTP request")),
31 ),
32 ),
33);
34
35it.layer(testLayer)("CursorDriver", (it) => {
36 it.effect("formats a copied update command for the injected Windows environment", () =>
37 Effect.gen(function* () {
38 const binaryPath = "C:\\Users\\Example User\\.local\\bin\\cursor-agent.exe";
39 const instance = yield* CursorDriver.create({
40 instanceId: ProviderInstanceId.make("cursor-copy-command"),
41 displayName: "Cursor test",
42 enabled: false,
43 environment: [],
44 config: { ...CursorDriver.defaultConfig(), binaryPath },
45 });
46
47 expect(instance.snapshot.maintenanceCapabilities.update).toMatchObject({
48 command: `& '${binaryPath}' update`,
49 executable: binaryPath,
50 args: ["update"],
51 });
52 expect((yield* instance.snapshot.refresh).status).toBe("disabled");
53 }).pipe(
54 Effect.provideService(HostProcessPlatform, "win32"),
55 Effect.provideService(
56 ChildProcessSpawner.ChildProcessSpawner,
57 ChildProcessSpawner.make(() => Effect.die("Disabled Cursor must not spawn a process")),
58 ),
59 Effect.scoped,
60 ),
61 );
62});
apps/server/src/provider/Drivers/CursorDriver.ts+1
......@@ -62,6 +62,7 @@ const UPDATE: ProviderMaintenanceCapabilitiesResolver = {
6262 updateExecutable: options?.binaryPath?.trim() || "cursor-agent",
6363 updateArgs: ["update"],
6464 updateLockKey: "cursor-agent",
65 ...(options?.platform ? { platform: options.platform } : {}),
6566 }),
6667};
6768
apps/server/src/provider/providerMaintenance.test.ts+71
......@@ -447,6 +447,77 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => {
447447 expect(nativePackageToolUpdate.resolve({ binaryPath }).update?.executable).toBe(binaryPath);
448448 });
449449
450 for (const directoryName of ["plain", "with spaces", "with 'quotes' and $dollars"]) {
451 it.effect.skipIf(windowsHost)(
452 `runs the copied native update command from a ${directoryName} path`,
453 () =>
454 Effect.gen(function* () {
455 const fs = yield* FileSystem.FileSystem;
456 const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-copy-native-update-" });
457 const binDir = NodePath.join(tempDir, directoryName, ".scoped-package-tool", "bin");
458 yield* fs.makeDirectory(binDir, { recursive: true });
459 const binaryPath = NodePath.join(binDir, "scoped-package-tool");
460 yield* fs.writeFileString(binaryPath, '#!/bin/sh\nprintf "%s" "$1"\n');
461 yield* fs.chmod(binaryPath, 0o755);
462
463 const capabilities = scopedPackageToolUpdate.resolve({ binaryPath });
464 const advisory = createProviderVersionAdvisory({
465 driver: driver("scopedPackageTool"),
466 currentVersion: "1.0.0",
467 latestVersion: "2.0.0",
468 maintenanceCapabilities: capabilities,
469 });
470 expect(advisory.updateCommand).not.toBeNull();
471 if (!advisory.updateCommand) return;
472 const result = NodeChildProcess.spawnSync("/bin/sh", ["-c", advisory.updateCommand], {
473 env: { PATH: "" },
474 encoding: "utf8",
475 });
476 expect(result.error).toBeUndefined();
477 expect(result.status, result.stderr).toBe(0);
478 expect(result.stdout).toBe("upgrade");
479 }).pipe(Effect.scoped),
480 );
481 }
482
483 it.each([
484 {
485 binaryPath: "C:\\Users\\Example\\.local\\bin\\native-package-tool.exe",
486 command: "C:\\Users\\Example\\.local\\bin\\native-package-tool.exe update",
487 },
488 {
489 binaryPath: "C:\\Users\\Example User\\.local\\bin\\native-package-tool.exe",
490 command: "& 'C:\\Users\\Example User\\.local\\bin\\native-package-tool.exe' update",
491 },
492 {
493 binaryPath: "C:\\Users\\O'Connor\\.local\\bin\\native-package-tool.exe",
494 command: "& 'C:\\Users\\O''Connor\\.local\\bin\\native-package-tool.exe' update",
495 },
496 {
497 binaryPath: "C:\\Users\\O\u2019Connor\\.local\\bin\\native-package-tool.exe",
498 command: "& 'C:\\Users\\O\u2019\u2019Connor\\.local\\bin\\native-package-tool.exe' update",
499 },
500 ])("formats a copied PowerShell native update for $binaryPath", ({ binaryPath, command }) => {
501 expect(nativePackageToolUpdate.resolve({ binaryPath, platform: "win32" }).update).toEqual({
502 command,
503 executable: binaryPath,
504 args: ["update"],
505 lockKey: "native-package-tool-native",
506 });
507 });
508
509 it.effect("uses the environment's platform for copied native update commands", () =>
510 Effect.gen(function* () {
511 const binaryPath = "C:\\Users\\Example User\\.local\\bin\\native-package-tool.exe";
512 const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(
513 nativePackageToolUpdate,
514 { binaryPath, env: { PATH: "" } },
515 );
516 expect(capabilities.update?.command).toBe(`& '${binaryPath}' update`);
517 expect(capabilities.update?.executable).toBe(binaryPath);
518 }).pipe(Effect.provideService(HostProcessPlatform, "win32")),
519 );
520
450521 it("uses the resolved launcher when its symlink target identifies a native install", () => {
451522 const launcher = "/custom tools/native-launcher";
452523 const capabilities = nativePackageToolUpdate.resolve({
apps/server/src/provider/providerMaintenance.ts+30-5
......@@ -5,6 +5,7 @@ import {
55} from "@t3tools/contracts";
66import { compareSemverVersions } from "@t3tools/shared/semver";
77import { resolveCommandPath } from "@t3tools/shared/shell";
8import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
89import * as Config from "effect/Config";
910import * as Context from "effect/Context";
1011import * as DateTime from "effect/DateTime";
......@@ -52,6 +53,7 @@ export interface ProviderMaintenanceCommandAction {
5253
5354export interface ProviderMaintenanceCapabilityResolutionOptions {
5455 readonly binaryPath?: string | null;
56 readonly platform?: NodeJS.Platform;
5557 readonly env?: NodeJS.ProcessEnv;
5658 readonly resolvedCommandPath?: string | null;
5759 readonly realCommandPath?: string | null;
......@@ -94,18 +96,34 @@ function nonEmptyString(value: unknown): string | null {
9496 return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
9597}
9698
99function quoteUpdateExecutable(executable: string, platform: NodeJS.Platform): string {
100 const safePath = platform === "win32" ? /^[\w./:\\-]+$/ : /^[\w./:-]+$/;
101 if (safePath.test(executable)) return executable;
102 // Windows terminals default to PowerShell, where a quoted executable needs &.
103 return platform === "win32"
104 ? `& '${executable.replace(/['\u2018\u2019]/g, "$&$&")}'`
105 : `'${executable.replaceAll("'", "'\\''")}'`;
106}
107
97108export function makeProviderMaintenanceCapabilities(input: {
98109 readonly provider: ProviderDriverKind;
99110 readonly packageName: string | null;
100111 readonly updateExecutable: string | null;
101112 readonly updateArgs: ReadonlyArray<string>;
102113 readonly updateLockKey: string | null;
114 readonly platform?: NodeJS.Platform;
103115}): ProviderMaintenanceCapabilities {
104116 const update =
105117 input.updateExecutable === null || input.updateLockKey === null
106118 ? null
107119 : {
108 command: [input.updateExecutable, ...input.updateArgs].join(" "),
120 command: [
121 quoteUpdateExecutable(
122 input.updateExecutable,
123 input.platform ?? HostProcessPlatform.defaultValue(),
124 ),
125 ...input.updateArgs,
126 ].join(" "),
109127 executable: input.updateExecutable,
110128 args: input.updateArgs,
111129 lockKey: input.updateLockKey,
......@@ -210,6 +228,7 @@ function makeHomebrewProviderMaintenanceCapabilities(
210228function makeNativeProviderMaintenanceCapabilities(
211229 definition: PackageManagedProviderMaintenanceDefinition,
212230 commandPath: string,
231 platform: NodeJS.Platform,
213232): ProviderMaintenanceCapabilities | null {
214233 if (!definition.nativeUpdate) {
215234 return null;
......@@ -221,6 +240,7 @@ function makeNativeProviderMaintenanceCapabilities(
221240 updateExecutable: commandPath,
222241 updateArgs: definition.nativeUpdate.args,
223242 updateLockKey: definition.nativeUpdate.lockKey,
243 platform,
224244 });
225245}
226246
......@@ -298,8 +318,11 @@ export function resolvePackageManagedProviderMaintenance(
298318 commandPaths.some((commandPath) => nativeUpdate.isCommandPath(commandPath))
299319 ) {
300320 return (
301 makeNativeProviderMaintenanceCapabilities(definition, resolvedCommandPath) ??
302 makeNpmGlobalProviderMaintenanceCapabilities(definition)
321 makeNativeProviderMaintenanceCapabilities(
322 definition,
323 resolvedCommandPath,
324 options?.platform ?? HostProcessPlatform.defaultValue(),
325 ) ?? makeNpmGlobalProviderMaintenanceCapabilities(definition)
303326 );
304327 }
305328 if (commandPaths.some(isVitePlusGlobalCommandPath)) {
......@@ -361,8 +384,9 @@ export const resolveProviderMaintenanceCapabilitiesEffect = Effect.fn(
361384 options?: Omit<ProviderMaintenanceCapabilityResolutionOptions, "realCommandPath">,
362385) {
363386 const binaryPath = nonEmptyString(options?.binaryPath);
387 const platform = options?.platform ?? (yield* HostProcessPlatform);
364388 if (!binaryPath) {
365 return resolver.resolve(options);
389 return resolver.resolve({ ...options, platform });
366390 }
367391
368392 const env = options?.env ?? (yield* readCommandLookupEnv);
......@@ -371,7 +395,7 @@ export const resolveProviderMaintenanceCapabilitiesEffect = Effect.fn(
371395 Effect.catchTag("CommandResolutionError", () => Effect.succeed(null)),
372396 )) ?? (hasPathSeparator(binaryPath) ? binaryPath : null);
373397 if (!resolvedCommandPath) {
374 return resolver.resolve(options);
398 return resolver.resolve({ ...options, platform });
375399 }
376400
377401 const fileSystem = yield* FileSystem.FileSystem;
......@@ -380,6 +404,7 @@ export const resolveProviderMaintenanceCapabilitiesEffect = Effect.fn(
380404 .pipe(Effect.orElseSucceed(() => resolvedCommandPath));
381405 return resolver.resolve({
382406 ...options,
407 platform,
383408 env,
384409 resolvedCommandPath,
385410 realCommandPath,