mulmoterminal 1.9.1 → 1.9.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +1 -1
- package/server/infra/cmd-escape.ts +48 -0
- package/server/infra/pty-env.ts +9 -6
- package/server/infra/resolve-bin.ts +80 -29
- package/server/session/pty-spawn.ts +6 -4
package/README.md
CHANGED
|
@@ -371,7 +371,7 @@ the server runs without one.
|
|
|
371
371
|
| ------------ | -------------- | ----------- |
|
|
372
372
|
| `PORT` | `34567` | Backend HTTP/WebSocket port (prod: the URL you open). |
|
|
373
373
|
| `CLIENT_PORT` | `6856` | Vite dev-server port (dev only: the URL you open with `yarn dev`). |
|
|
374
|
-
| `CLAUDE_BIN` | `claude` | The Claude Code binary to spawn. On Windows a bare name is resolved
|
|
374
|
+
| `CLAUDE_BIN` | `claude` | The Claude Code binary to spawn. On Windows a bare name is resolved on `PATH` before it reaches the PTY layer (which matches file names exactly): to the `.exe` when there is one, otherwise to the `.cmd` shim an npm-global install leaves, run through `cmd.exe`. |
|
|
375
375
|
| `CLAUDE_CWD` | current dir | Working directory each `claude` PTY runs in; determines which project's sessions the sidebar lists. Via `npx mulmoterminal` it defaults to the directory you ran the command from (override with `--cwd <dir>`, relative allowed); when the server is run directly it falls back to `~/mulmoclaude`. A value read from `.env` must be an absolute path (`~` is not expanded). |
|
|
376
376
|
| `CLAUDE_PERMISSION_MODE` | `auto` | Permission mode passed to each `claude` spawn. |
|
|
377
377
|
| `MT_TITLE_MODEL` | `haiku` | Model used for the cell header's AI title (a cheap/fast model summarizing the recent turns). Accepts a `--model` alias or a full model id. |
|
package/package.json
CHANGED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Building the command line that runs a Windows batch shim (`claude.cmd` from an npm-global
|
|
2
|
+
// install) under `cmd.exe`. CreateProcessW — what node-pty ultimately calls — executes PE
|
|
3
|
+
// images only, so a batch target has no other way in (#798).
|
|
4
|
+
//
|
|
5
|
+
// The command line is then parsed TWICE: by cmd.exe, and by the child's CRT. The two
|
|
6
|
+
// disagree about escaping, and cmd's rules are the ones that decide whether an argument
|
|
7
|
+
// stays an argument: `\"` (the CRT's escape, and what node-pty's own argsToCommandLine
|
|
8
|
+
// emits) does not escape a quote for cmd — it ends the quoted run, after which a `&` in the
|
|
9
|
+
// same argument is a command separator. Hence the rules below, which are cmd's.
|
|
10
|
+
|
|
11
|
+
/** An argument that cannot be represented on a Windows command line. Thrown rather than
|
|
12
|
+
* silently mangled: a truncated argument reaches the agent as a DIFFERENT instruction. */
|
|
13
|
+
export class UnsafeArgumentError extends Error {
|
|
14
|
+
constructor(reason: string) {
|
|
15
|
+
super(reason);
|
|
16
|
+
this.name = "UnsafeArgumentError";
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// A command line has no encoding for these: CR/LF end it, NUL terminates it.
|
|
21
|
+
const UNREPRESENTABLE = /[\0\r\n]/;
|
|
22
|
+
|
|
23
|
+
// Counted rather than matched: an anchored `\\+$` backtracks over a long run.
|
|
24
|
+
const trailingBackslashCount = (text: string): number => {
|
|
25
|
+
const fromEnd = [...text].reverse().findIndex((ch) => ch !== "\\");
|
|
26
|
+
return fromEnd === -1 ? text.length : fromEnd;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** One argument, quoted for cmd.exe. Always quoted — inside quotes cmd's metacharacters
|
|
30
|
+
* (`& | < > ^ ( )`) are literal, which is the whole defence. Internal quotes are doubled
|
|
31
|
+
* (cmd's escape), and a trailing backslash run is doubled so it escapes itself instead of
|
|
32
|
+
* the closing quote when the CRT parses the same text afterwards. */
|
|
33
|
+
export function escapeBatchArgument(arg: string): string {
|
|
34
|
+
if (UNREPRESENTABLE.test(arg)) throw new UnsafeArgumentError(`argument contains a NUL, CR or LF: ${JSON.stringify(arg)}`);
|
|
35
|
+
const quotesDoubled = arg.replace(/"/g, '""');
|
|
36
|
+
return `"${quotesDoubled}${"\\".repeat(trailingBackslashCount(quotesDoubled))}"`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Everything after `cmd.exe` for running `batchPath args…`.
|
|
40
|
+
*
|
|
41
|
+
* `/d` skips AutoRun, so a `HKCU\…\Command Processor\AutoRun` command cannot run inside our
|
|
42
|
+
* session first. `/s` makes cmd strip exactly the outer quote pair and take the rest
|
|
43
|
+
* verbatim, instead of applying its "is the first token a quoted path?" heuristics to a line
|
|
44
|
+
* that already contains quoted arguments. */
|
|
45
|
+
export function batchCommandLine(batchPath: string, args: readonly string[]): string {
|
|
46
|
+
const command = [escapeBatchArgument(batchPath), ...args.map(escapeBatchArgument)].join(" ");
|
|
47
|
+
return `/d /s /c "${command}"`;
|
|
48
|
+
}
|
package/server/infra/pty-env.ts
CHANGED
|
@@ -37,14 +37,17 @@ export function isPathVar(name: string): boolean {
|
|
|
37
37
|
return name.toLowerCase() === "path";
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
//
|
|
41
|
-
// `
|
|
42
|
-
//
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
40
|
+
// A variable out of a PLAIN env object, matched the way Windows matches: case-insensitively.
|
|
41
|
+
// `env.PATH` / `env.ComSpec` are only reliable on the live `process.env`, whose Windows
|
|
42
|
+
// lookups are case-insensitive; a copy (what sanitizePtyEnv returns) keeps whatever casing
|
|
43
|
+
// the system used and answers undefined to any other spelling.
|
|
44
|
+
export function envValue(env: NodeJS.ProcessEnv, name: string): string | undefined {
|
|
45
|
+
const wanted = name.toLowerCase();
|
|
46
|
+
return Object.entries(env).find(([key]) => key.toLowerCase() === wanted)?.[1];
|
|
46
47
|
}
|
|
47
48
|
|
|
49
|
+
export const pathFromEnv = (env: NodeJS.ProcessEnv): string | undefined => envValue(env, "PATH");
|
|
50
|
+
|
|
48
51
|
// yarn v1's temp dir is `yarn--` + a timestamp.
|
|
49
52
|
const YARN_SHIM_DIR = /^yarn--\d/;
|
|
50
53
|
|
|
@@ -4,28 +4,33 @@
|
|
|
4
4
|
// compares each PATH directory's file name EXACTLY — it never appends an executable
|
|
5
5
|
// extension. A bare "claude" therefore misses `…\.local\bin\claude.exe` and the spawn dies
|
|
6
6
|
// with `File not found: ` (nothing after the colon, since the path it failed to find is the
|
|
7
|
-
// empty string). Handing it an absolute path skips that lookup entirely.
|
|
7
|
+
// empty string). Handing it an absolute path skips that lookup entirely (#794).
|
|
8
8
|
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
// today (the shim satisfies the gate, CreateProcess finds the real .exe elsewhere on PATH)
|
|
14
|
-
// into `Cannot create process`.
|
|
9
|
+
// node-pty then launches via `CreateProcessW(nullptr, cmdline, …)`, which runs PE images
|
|
10
|
+
// only. A batch shim — all an npm-global install leaves on PATH — therefore has to go
|
|
11
|
+
// through `cmd.exe` instead of being named directly (#798), which is why this resolves to a
|
|
12
|
+
// launch descriptor rather than to a path.
|
|
15
13
|
import { statSync } from "node:fs";
|
|
16
14
|
import path from "node:path";
|
|
17
|
-
import { pathFromEnv } from "./pty-env.js";
|
|
15
|
+
import { envValue, pathFromEnv } from "./pty-env.js";
|
|
16
|
+
import { batchCommandLine } from "./cmd-escape.js";
|
|
18
17
|
|
|
19
18
|
// `.exe` first: with both present, a bare name would have reached the `.exe` (CreateProcess
|
|
20
19
|
// appends exactly that), so resolving must not silently switch which file runs.
|
|
21
20
|
const EXECUTABLE_EXTENSIONS = [".exe", ".com"] as const;
|
|
21
|
+
const BATCH_EXTENSIONS = [".cmd", ".bat"] as const;
|
|
22
22
|
|
|
23
|
-
const
|
|
23
|
+
const endsWithOneOf = (bin: string, extensions: readonly string[]): boolean => extensions.some((ext) => bin.toLowerCase().endsWith(ext));
|
|
24
24
|
|
|
25
25
|
// A name node-pty/CreateProcess already resolves on its own (absolute, or relative to a
|
|
26
26
|
// directory) rather than by searching PATH.
|
|
27
27
|
const namesAPath = (bin: string): boolean => bin.includes("\\") || bin.includes("/");
|
|
28
28
|
|
|
29
|
+
// What node-pty is handed. `args` as a STRING is a raw command line that node-pty passes
|
|
30
|
+
// through verbatim (its argsToCommandLine only quotes an argv ARRAY) — which is what lets
|
|
31
|
+
// the cmd.exe wrapping below own its own quoting.
|
|
32
|
+
export type PtyLaunch = { file: string; args: string[] | string };
|
|
33
|
+
|
|
29
34
|
export function isExecutableFile(candidate: string): boolean {
|
|
30
35
|
try {
|
|
31
36
|
return statSync(candidate).isFile();
|
|
@@ -34,17 +39,6 @@ export function isExecutableFile(candidate: string): boolean {
|
|
|
34
39
|
}
|
|
35
40
|
}
|
|
36
41
|
|
|
37
|
-
/** The absolute path of the executable a bare Windows command name refers to, or null when
|
|
38
|
-
* PATH holds no `.exe`/`.com` for it (the caller then leaves the name as it is). Pure —
|
|
39
|
-
* `searchPath` and the existence check are parameters, and `path.win32` + ";" are used
|
|
40
|
-
* explicitly, so the rule is checkable from a POSIX host too. */
|
|
41
|
-
export function resolveWindowsExecutable(bin: string, searchPath: string | undefined, fileExists: (candidate: string) => boolean): string | null {
|
|
42
|
-
if (bin === "" || namesAPath(bin)) return null;
|
|
43
|
-
const names = hasExecutableExtension(bin) ? [bin] : EXECUTABLE_EXTENSIONS.map((ext) => bin + ext);
|
|
44
|
-
const candidates = searchDirectories(searchPath).flatMap((dir) => names.map((name) => path.win32.join(dir, name)));
|
|
45
|
-
return candidates.find(fileExists) ?? null; // `find` is lazy: it stops at the first hit
|
|
46
|
-
}
|
|
47
|
-
|
|
48
42
|
// A PATH entry may be quoted — `"C:\Program Files\tools"` — which the shells strip and a
|
|
49
43
|
// plain join would not, leaving a path that matches nothing.
|
|
50
44
|
const searchDirectories = (searchPath: string | undefined): string[] =>
|
|
@@ -53,15 +47,72 @@ const searchDirectories = (searchPath: string | undefined): string[] =>
|
|
|
53
47
|
.map((entry) => entry.replace(/^"(.*)"$/, "$1"))
|
|
54
48
|
.filter((entry) => entry !== "");
|
|
55
49
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
return
|
|
50
|
+
// The first `<dir>\<bin><ext>` that exists, over every PATH directory for one set of
|
|
51
|
+
// extensions. `find` is lazy, so it stops at the first hit.
|
|
52
|
+
function searchPathFor(bin: string, extensions: readonly string[], searchPath: string | undefined, fileExists: (candidate: string) => boolean): string | null {
|
|
53
|
+
const names = endsWithOneOf(bin, extensions) ? [bin] : extensions.map((ext) => bin + ext);
|
|
54
|
+
return (
|
|
55
|
+
searchDirectories(searchPath)
|
|
56
|
+
.flatMap((dir) => names.map((name) => path.win32.join(dir, name)))
|
|
57
|
+
.find(fileExists) ?? null
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The absolute path of the executable a bare Windows command name refers to, or null when
|
|
62
|
+
* PATH holds no `.exe`/`.com` for it. Pure — `searchPath` and the existence check are
|
|
63
|
+
* parameters, and `path.win32` + ";" are used explicitly, so the rule is checkable from a
|
|
64
|
+
* POSIX host too. */
|
|
65
|
+
export function resolveWindowsExecutable(bin: string, searchPath: string | undefined, fileExists: (candidate: string) => boolean): string | null {
|
|
66
|
+
if (bin === "" || namesAPath(bin)) return null;
|
|
67
|
+
return searchPathFor(bin, EXECUTABLE_EXTENSIONS, searchPath, fileExists);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The absolute path of the batch shim a bare command name refers to, or null. Only ever
|
|
71
|
+
* consulted after `resolveWindowsExecutable` has come up empty ACROSS THE WHOLE PATH —
|
|
72
|
+
* deliberately not cmd.exe's per-directory order. An install whose shim sits in an earlier
|
|
73
|
+
* PATH directory than its real `.exe` runs the `.exe` today; moving it onto the cmd.exe
|
|
74
|
+
* path would add a parsing layer to a spawn that already works. */
|
|
75
|
+
export function resolveWindowsBatch(bin: string, searchPath: string | undefined, fileExists: (candidate: string) => boolean): string | null {
|
|
76
|
+
if (bin === "" || namesAPath(bin)) return null;
|
|
77
|
+
return searchPathFor(bin, BATCH_EXTENSIONS, searchPath, fileExists);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** How to launch `bin args…`. Off Windows, and whenever nothing resolves, this is the
|
|
81
|
+
* arguments unchanged — a host that spawns fine today must keep spawning fine. */
|
|
82
|
+
export function resolvePtyLaunch(
|
|
83
|
+
bin: string,
|
|
84
|
+
args: string[],
|
|
85
|
+
platform: NodeJS.Platform,
|
|
86
|
+
searchPath: string | undefined,
|
|
87
|
+
comspec: string | undefined,
|
|
88
|
+
fileExists = isExecutableFile,
|
|
89
|
+
): PtyLaunch {
|
|
90
|
+
if (platform !== "win32") return { file: bin, args };
|
|
91
|
+
// A name that already names a path needs no PATH search — but CreateProcess still cannot
|
|
92
|
+
// RUN a batch file, and `CLAUDE_BIN=C:\…\claude.cmd` is exactly what someone reaches for
|
|
93
|
+
// when working around a broken spawn. Wrapped without an existence check: a relative path
|
|
94
|
+
// is relative to the PTY's cwd, not this process's, so checking here would ask the wrong
|
|
95
|
+
// directory — and cmd.exe naming the path it cannot find beats node-pty's empty message.
|
|
96
|
+
if (namesAPath(bin)) {
|
|
97
|
+
if (!endsWithOneOf(bin, BATCH_EXTENSIONS)) return { file: bin, args };
|
|
98
|
+
return { file: commandProcessor(comspec, searchPath, fileExists), args: batchCommandLine(bin, args) };
|
|
99
|
+
}
|
|
100
|
+
const executable = resolveWindowsExecutable(bin, searchPath, fileExists);
|
|
101
|
+
if (executable) return { file: executable, args };
|
|
102
|
+
const batch = resolveWindowsBatch(bin, searchPath, fileExists);
|
|
103
|
+
if (batch) return { file: commandProcessor(comspec, searchPath, fileExists), args: batchCommandLine(batch, args) };
|
|
104
|
+
return { file: bin, args };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ComSpec is where Windows itself records the command processor; falling back to a PATH
|
|
108
|
+
// lookup (and then to the bare name) keeps a stripped environment working.
|
|
109
|
+
function commandProcessor(comspec: string | undefined, searchPath: string | undefined, fileExists: (candidate: string) => boolean): string {
|
|
110
|
+
if (comspec && namesAPath(comspec) && fileExists(comspec)) return comspec;
|
|
111
|
+
return resolveWindowsExecutable("cmd.exe", searchPath, fileExists) ?? "cmd.exe";
|
|
61
112
|
}
|
|
62
113
|
|
|
63
|
-
/** `
|
|
64
|
-
* matters is the one reachable from the child's PATH, not the server's. */
|
|
65
|
-
export function
|
|
66
|
-
return
|
|
114
|
+
/** `resolvePtyLaunch` against the environment the session itself will run with: the binary
|
|
115
|
+
* that matters is the one reachable from the child's PATH, not the server's. */
|
|
116
|
+
export function resolvePtyLaunchForEnv(bin: string, args: string[], env: NodeJS.ProcessEnv): PtyLaunch {
|
|
117
|
+
return resolvePtyLaunch(bin, args, process.platform, pathFromEnv(env), envValue(env, "ComSpec"));
|
|
67
118
|
}
|
|
@@ -7,7 +7,7 @@ import type { IPty } from "node-pty";
|
|
|
7
7
|
import path from "node:path";
|
|
8
8
|
import type { WebSocket } from "ws";
|
|
9
9
|
import { sanitizePtyEnv } from "../infra/pty-env.js";
|
|
10
|
-
import {
|
|
10
|
+
import { resolvePtyLaunchForEnv } from "../infra/resolve-bin.js";
|
|
11
11
|
import { withoutUnset } from "./provider-env.js";
|
|
12
12
|
import { tmuxAvailable, tmuxNewSessionArgs, tmuxScrubEnvNames } from "../infra/tmux.js";
|
|
13
13
|
import {
|
|
@@ -34,9 +34,11 @@ const PTY_ROWS = 30;
|
|
|
34
34
|
// the settings `env` block, which can set a variable but not remove one.
|
|
35
35
|
export function spawnPty(bin: string, args: string[], cwd: string, unset: readonly string[] = []): IPty {
|
|
36
36
|
const env = withoutUnset(sanitizePtyEnv(process.env, path.delimiter), unset);
|
|
37
|
-
// On Windows
|
|
38
|
-
// executable extensions
|
|
39
|
-
|
|
37
|
+
// On Windows neither the name nor the arguments reach node-pty as they are: its PATH
|
|
38
|
+
// lookup ignores executable extensions (so `claude` misses claude.exe, #794), and a batch
|
|
39
|
+
// shim has to be run through cmd.exe (#798). See infra/resolve-bin.ts.
|
|
40
|
+
const launch = resolvePtyLaunchForEnv(bin, args, env);
|
|
41
|
+
return pty.spawn(launch.file, launch.args, { name: "xterm-256color", cols: PTY_COLS, rows: PTY_ROWS, cwd, env });
|
|
40
42
|
}
|
|
41
43
|
|
|
42
44
|
// Would this session run in the Docker sandbox? Single-view interactive only (the caller
|