leduo-patrol 2.0.1 → 2.2.1

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 CHANGED
@@ -83,7 +83,7 @@
83
83
  - **目录浏览**:创建会话时可在允许根目录范围内浏览子目录,安全限制越权访问
84
84
 
85
85
  ### 工具与集成
86
- - **内置终端**:下方可展开终端抽屉,通过 xterm.js 提供完整 PTY 终端体验(需服务端开启 `LEDUO_ENABLE_SHELL=true`)
86
+ - **内置终端**:下方可展开终端抽屉,通过 xterm.js 提供完整 PTY 终端体验;服务端默认开启,可通过 `LEDUO_ENABLE_SHELL=false` 显式关闭
87
87
 
88
88
  ### 界面与可访问性
89
89
  - **访问 Key 认证**:所有请求(HTTP / WebSocket)均需携带 key;浏览器检测到无效 key 时展示输入页
@@ -117,6 +117,7 @@ npm run dev:local
117
117
  - `local`:监听 `127.0.0.1`,仅本机可访问
118
118
  - 可通过命令行参数 `--mode=local|server` 或环境变量 `LEDUO_PATROL_BIND_MODE` 指定,命令行参数优先级更高
119
119
  - 若未显式指定,启动时会在终端提示选择模式;并可选择记住到 `~/.leduo-patrol/launch-preferences.json`,后续自动复用
120
+ - 若未设置 `LEDUO_PATROL_ACCESS_KEY`,启动时会提示选择“手动输入自定义 key”或“随机生成 key”,并可选择是否记住到 `~/.leduo-patrol/launch-preferences.json`
120
121
 
121
122
  - 前端开发服务运行在自动探测到的可访问内网 IP(优先 `bond* / eth* / ens* / enp*` 网卡)上
122
123
  - 后端服务运行在 `PORT`(默认 `3001`,端口冲突时会自动递增寻找可用端口)
@@ -153,7 +154,7 @@ LEDUO_PATROL_CLAUDE_BIN=/absolute/path/to/claude
153
154
  LEDUO_PATROL_SHELL=/absolute/path/to/zsh
154
155
  ANTHROPIC_API_KEY=sk-...
155
156
  LEDUO_PATROL_ACCESS_KEY=your-fixed-key
156
- LEDUO_ENABLE_SHELL=true
157
+ LEDUO_ENABLE_SHELL=false
157
158
  ```
158
159
 
159
160
  如果设置了 `LEDUO_PATROL_ALLOWED_ROOTS`,网页中只能连接这些根目录之下的路径;未设置时默认只允许启动命令所在目录。
@@ -177,7 +178,12 @@ LEDUO_ENABLE_SHELL=true
177
178
 
178
179
  ## 访问校验 Key
179
180
 
180
- 服务启动时会自动生成一次性访问 key,并在控制台打印可直接打开的地址。
181
+ 服务启动时会优先按以下顺序确定访问 key,并在控制台打印可直接打开的地址:
182
+
183
+ - `--access-key your-key`
184
+ - `LEDUO_PATROL_ACCESS_KEY`
185
+ - 已记住在 `~/.leduo-patrol/launch-preferences.json` 的 key
186
+ - 若以上都没有:启动时交互选择“手动输入”或“随机生成”
181
187
 
182
188
  - 开发模式(`npm run dev`)下,`Access URL` 默认指向 Web 端口(默认 `5173`)。
183
189
  - 生产模式(`npm start`)下,Web 由同一个 Express 服务静态托管,因此不会出现独立的 Web 监听端口;`Access URL` 会指向 server 端口。若未找到打包后的 `dist/web` 资源,服务会给出错误提示页与启动日志提示。
@@ -193,6 +199,14 @@ LEDUO_PATROL_ACCESS_KEY=your-fixed-key
193
199
  LEDUO_PATROL_CLAUDE_BIN=/absolute/path/to/claude
194
200
  ```
195
201
 
202
+ 也可以直接通过命令行传入:
203
+
204
+ ```bash
205
+ npm run dev:server -- --access-key your-fixed-key
206
+ # 或生产模式
207
+ npm start -- --access-key your-fixed-key
208
+ ```
209
+
196
210
  ## 已知限制
197
211
 
198
212
  - 当前只实现了 Claude Code
@@ -0,0 +1,80 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { resolveAccessKey, accessKeyPromptTestables } from "../access-key-prompt.js";
4
+ const NON_TTY_STREAM = {
5
+ isTTY: false,
6
+ };
7
+ const SILENT_STDOUT = {
8
+ isTTY: true,
9
+ write: () => true,
10
+ };
11
+ test("access key prompt normalizes values", () => {
12
+ assert.equal(accessKeyPromptTestables.normalizeAccessKey(" abc "), "abc");
13
+ assert.equal(accessKeyPromptTestables.normalizeAccessKey(""), "");
14
+ assert.equal(accessKeyPromptTestables.normalizeAccessKey(undefined), "");
15
+ });
16
+ test("resolveAccessKey prefers argv over env and remembered key", async () => {
17
+ const result = await resolveAccessKey({
18
+ argv: ["--access-key", "cli-key"],
19
+ envKey: "env-key",
20
+ stdin: NON_TTY_STREAM,
21
+ stdout: SILENT_STDOUT,
22
+ loadRememberedKey: async () => "remembered-key",
23
+ createRandomKey: () => "random-key",
24
+ });
25
+ assert.equal(result, "cli-key");
26
+ });
27
+ test("resolveAccessKey falls back to env key", async () => {
28
+ const result = await resolveAccessKey({
29
+ argv: [],
30
+ envKey: "env-key",
31
+ stdin: NON_TTY_STREAM,
32
+ stdout: SILENT_STDOUT,
33
+ loadRememberedKey: async () => "remembered-key",
34
+ createRandomKey: () => "random-key",
35
+ });
36
+ assert.equal(result, "env-key");
37
+ });
38
+ test("resolveAccessKey falls back to remembered key", async () => {
39
+ const result = await resolveAccessKey({
40
+ argv: [],
41
+ envKey: "",
42
+ stdin: NON_TTY_STREAM,
43
+ stdout: SILENT_STDOUT,
44
+ loadRememberedKey: async () => "remembered-key",
45
+ createRandomKey: () => "random-key",
46
+ });
47
+ assert.equal(result, "remembered-key");
48
+ });
49
+ test("resolveAccessKey generates a random key when no tty prompt is available", async () => {
50
+ const result = await resolveAccessKey({
51
+ argv: [],
52
+ envKey: "",
53
+ stdin: NON_TTY_STREAM,
54
+ stdout: SILENT_STDOUT,
55
+ loadRememberedKey: async () => "",
56
+ createRandomKey: () => "random-key",
57
+ });
58
+ assert.equal(result, "random-key");
59
+ });
60
+ test("resolveAccessKey can save an interactively chosen key", async () => {
61
+ let savedKey = "";
62
+ const ttyStream = {
63
+ isTTY: true,
64
+ };
65
+ const result = await resolveAccessKey({
66
+ argv: [],
67
+ envKey: "",
68
+ stdin: ttyStream,
69
+ stdout: SILENT_STDOUT,
70
+ loadRememberedKey: async () => "",
71
+ saveRememberedKey: async (key) => {
72
+ savedKey = key;
73
+ },
74
+ createRandomKey: () => "random-key",
75
+ promptForAccessKey: async () => "custom-key",
76
+ promptShouldRememberKey: async () => true,
77
+ });
78
+ assert.equal(result, "custom-key");
79
+ assert.equal(savedKey, "custom-key");
80
+ });
@@ -22,3 +22,20 @@ test("claudeCliSessionTestables.resolveClaudeBin accepts explicit executable pat
22
22
  test("claudeCliSessionTestables.resolveClaudeBin throws actionable error when claude is missing", () => {
23
23
  assert.throws(() => claudeCliSessionTestables.resolveClaudeBin(undefined, { PATH: "" }), /LEDUO_PATROL_CLAUDE_BIN/);
24
24
  });
25
+ test("claudeCliSessionTestables.buildShellWrappedClaudeLaunch uses a shell exec wrapper", () => {
26
+ const launch = claudeCliSessionTestables.buildShellWrappedClaudeLaunch("/opt/claude/bin/claude", ["--session-id", "session-123"], (candidate) => candidate === "/bin/sh");
27
+ assert.deepEqual(launch, {
28
+ command: "/bin/sh",
29
+ args: ["-c", 'exec "$0" "$@"', "/opt/claude/bin/claude", "--session-id", "session-123"],
30
+ });
31
+ });
32
+ test("claudeCliSessionTestables.shouldRetryClaudeSpawnWithShell matches posix_spawnp failures", () => {
33
+ const shouldRetry = claudeCliSessionTestables.shouldRetryClaudeSpawnWithShell(new Error("posix_spawnp failed."));
34
+ assert.equal(typeof shouldRetry, "boolean");
35
+ if (process.platform === "win32") {
36
+ assert.equal(shouldRetry, false);
37
+ }
38
+ else {
39
+ assert.equal(shouldRetry, true);
40
+ }
41
+ });
@@ -0,0 +1,28 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { mkdtempSync, statSync, writeFileSync, chmodSync } from "node:fs";
6
+ import { ensureExecutableBit } from "../pty-runtime.js";
7
+ test("ensureExecutableBit adds execute permissions when missing", () => {
8
+ const tempDir = mkdtempSync(path.join(os.tmpdir(), "leduo-patrol-pty-runtime-"));
9
+ const helperPath = path.join(tempDir, "spawn-helper");
10
+ writeFileSync(helperPath, "#!/bin/sh\n");
11
+ chmodSync(helperPath, 0o644);
12
+ const beforeMode = statSync(helperPath).mode & 0o777;
13
+ const changed = ensureExecutableBit(helperPath);
14
+ const afterMode = statSync(helperPath).mode & 0o777;
15
+ assert.equal(beforeMode, 0o644);
16
+ assert.equal(changed, true);
17
+ assert.equal(afterMode, 0o755);
18
+ });
19
+ test("ensureExecutableBit is a no-op when execute permissions already exist", () => {
20
+ const tempDir = mkdtempSync(path.join(os.tmpdir(), "leduo-patrol-pty-runtime-"));
21
+ const helperPath = path.join(tempDir, "spawn-helper");
22
+ writeFileSync(helperPath, "#!/bin/sh\n");
23
+ chmodSync(helperPath, 0o755);
24
+ const changed = ensureExecutableBit(helperPath);
25
+ const mode = statSync(helperPath).mode & 0o777;
26
+ assert.equal(changed, false);
27
+ assert.equal(mode, 0o755);
28
+ });
@@ -0,0 +1,84 @@
1
+ import readline from "node:readline/promises";
2
+ import { createAccessKey } from "./access-key.js";
3
+ import { loadStartupPreferences, saveStartupPreferences } from "./startup-preferences.js";
4
+ import { readOptionValue } from "./launch-mode.js";
5
+ export async function resolveAccessKey(options = {}) {
6
+ const argv = options.argv ?? process.argv.slice(2);
7
+ const stdin = options.stdin ?? process.stdin;
8
+ const stdout = options.stdout ?? process.stdout;
9
+ const createRandomKey = options.createRandomKey ?? createAccessKey;
10
+ const loadRememberedKey = options.loadRememberedKey ?? loadRememberedAccessKey;
11
+ const saveRememberedKey = options.saveRememberedKey ?? saveRememberedAccessKey;
12
+ const promptForKey = options.promptForAccessKey ?? promptForAccessKey;
13
+ const promptShouldRemember = options.promptShouldRememberKey ?? promptShouldRememberKey;
14
+ const argvKey = normalizeAccessKey(readOptionValue(argv, "--access-key"));
15
+ if (argvKey) {
16
+ return argvKey;
17
+ }
18
+ const envKey = normalizeAccessKey(options.envKey ?? process.env.LEDUO_PATROL_ACCESS_KEY);
19
+ if (envKey) {
20
+ return envKey;
21
+ }
22
+ const rememberedKey = normalizeAccessKey(await loadRememberedKey());
23
+ if (rememberedKey) {
24
+ return rememberedKey;
25
+ }
26
+ const generatedKey = createRandomKey();
27
+ if (!stdin.isTTY || !stdout.isTTY) {
28
+ return generatedKey;
29
+ }
30
+ const selectedKey = await promptForKey(stdin, stdout, generatedKey);
31
+ const shouldRemember = await promptShouldRemember(stdin, stdout);
32
+ if (shouldRemember) {
33
+ await saveRememberedKey(selectedKey);
34
+ stdout.write("已记住访问 key,后续启动会自动复用。\n");
35
+ }
36
+ return selectedKey;
37
+ }
38
+ async function promptForAccessKey(stdin, stdout, generatedKey) {
39
+ const rl = readline.createInterface({ input: stdin, output: stdout });
40
+ try {
41
+ stdout.write("\n请选择访问 key 生成方式:\n");
42
+ stdout.write(" 1) 手动输入自定义 key\n");
43
+ stdout.write(" 2) 使用随机生成 key\n");
44
+ const answer = (await rl.question("输入 1/2,默认 2: ")).trim().toLowerCase();
45
+ if (answer === "1" || answer === "custom" || answer === "manual") {
46
+ const customAnswer = (await rl.question("请输入自定义访问 key: ")).trim();
47
+ const customKey = normalizeAccessKey(customAnswer);
48
+ if (customKey) {
49
+ return customKey;
50
+ }
51
+ stdout.write("未输入有效 key,已改用随机生成 key。\n");
52
+ }
53
+ stdout.write(`本次启动使用随机 key: ${generatedKey}\n`);
54
+ return generatedKey;
55
+ }
56
+ finally {
57
+ rl.close();
58
+ }
59
+ }
60
+ async function promptShouldRememberKey(stdin, stdout) {
61
+ const rl = readline.createInterface({ input: stdin, output: stdout });
62
+ try {
63
+ const answer = (await rl.question("是否记住此访问 key 用于后续启动?(y/N): ")).trim().toLowerCase();
64
+ return answer === "y" || answer === "yes";
65
+ }
66
+ finally {
67
+ rl.close();
68
+ }
69
+ }
70
+ function normalizeAccessKey(raw) {
71
+ const normalized = raw?.trim() ?? "";
72
+ return normalized || "";
73
+ }
74
+ async function loadRememberedAccessKey() {
75
+ return normalizeAccessKey((await loadStartupPreferences()).accessKey);
76
+ }
77
+ async function saveRememberedAccessKey(key) {
78
+ await saveStartupPreferences({ accessKey: key });
79
+ }
80
+ export const accessKeyPromptTestables = {
81
+ normalizeAccessKey,
82
+ loadRememberedAccessKey,
83
+ saveRememberedAccessKey,
84
+ };
@@ -3,6 +3,7 @@ import { EventEmitter } from "node:events";
3
3
  import { existsSync } from "node:fs";
4
4
  import path from "node:path";
5
5
  import { buildSpawnFailureMessage, ensureDirectoryExistsSync } from "./server-helpers.js";
6
+ import { ensureNodePtySpawnHelperExecutable } from "./pty-runtime.js";
6
7
  /**
7
8
  * A PTY-backed session that runs the native Claude Code CLI.
8
9
  *
@@ -19,6 +20,7 @@ export class ClaudeCliSession extends EventEmitter {
19
20
  super();
20
21
  this.sessionId = opts.sessionId;
21
22
  ensureDirectoryExistsSync(opts.workspacePath, "Session workspace");
23
+ ensureNodePtySpawnHelperExecutable();
22
24
  const bin = resolveClaudeBin(opts.claudeBin);
23
25
  const args = opts.resume
24
26
  ? ["--resume", opts.sessionId]
@@ -26,21 +28,24 @@ export class ClaudeCliSession extends EventEmitter {
26
28
  if (opts.allowSkipPermissions) {
27
29
  args.push("--allow-dangerously-skip-permissions");
28
30
  }
31
+ const env = {
32
+ ...process.env,
33
+ TERM: "xterm-256color",
34
+ COLORTERM: "truecolor",
35
+ };
29
36
  try {
30
- this.pty = spawn(bin, args, {
31
- name: "xterm-256color",
32
- cols: opts.cols ?? 80,
33
- rows: opts.rows ?? 24,
34
- cwd: opts.workspacePath,
35
- env: {
36
- ...process.env,
37
- TERM: "xterm-256color",
38
- COLORTERM: "truecolor",
39
- },
40
- });
37
+ this.pty = spawnClaudePty(buildDirectClaudeLaunch(bin, args), opts.workspacePath, opts.cols ?? 80, opts.rows ?? 24, env);
41
38
  }
42
39
  catch (error) {
43
- throw new Error(buildSpawnFailureMessage("Claude CLI", bin, opts.workspacePath, error, "Check that the command is installed correctly, or override it with LEDUO_PATROL_CLAUDE_BIN."));
40
+ if (!shouldRetryClaudeSpawnWithShell(error)) {
41
+ throw new Error(buildSpawnFailureMessage("Claude CLI", bin, opts.workspacePath, error, "Check that the command is installed correctly, or override it with LEDUO_PATROL_CLAUDE_BIN."));
42
+ }
43
+ try {
44
+ this.pty = spawnClaudePty(buildShellWrappedClaudeLaunch(bin, args), opts.workspacePath, opts.cols ?? 80, opts.rows ?? 24, env);
45
+ }
46
+ catch (fallbackError) {
47
+ throw new Error(buildSpawnFailureMessage("Claude CLI", bin, opts.workspacePath, fallbackError, "Direct PTY spawn also failed, even after retrying through a shell wrapper. Check that Claude Code is installed correctly, or override it with LEDUO_PATROL_CLAUDE_BIN."));
48
+ }
44
49
  }
45
50
  this.pty.onData((data) => {
46
51
  this.emit("output", data);
@@ -70,6 +75,42 @@ export class ClaudeCliSession extends EventEmitter {
70
75
  }
71
76
  }
72
77
  }
78
+ function spawnClaudePty(launch, workspacePath, cols, rows, env) {
79
+ return spawn(launch.command, launch.args, {
80
+ name: "xterm-256color",
81
+ cols,
82
+ rows,
83
+ cwd: workspacePath,
84
+ env,
85
+ });
86
+ }
87
+ function buildDirectClaudeLaunch(bin, args) {
88
+ return {
89
+ command: bin,
90
+ args,
91
+ };
92
+ }
93
+ function buildShellWrappedClaudeLaunch(bin, args, shellExists = existsSync) {
94
+ return {
95
+ command: resolveClaudeWrapperShell(shellExists),
96
+ args: ["-c", 'exec "$0" "$@"', bin, ...args],
97
+ };
98
+ }
99
+ function resolveClaudeWrapperShell(shellExists = existsSync) {
100
+ const candidates = ["/bin/sh", "/bin/bash", "/bin/zsh", "/usr/bin/sh"];
101
+ for (const candidate of candidates) {
102
+ if (shellExists(candidate)) {
103
+ return candidate;
104
+ }
105
+ }
106
+ throw new Error("No compatible shell was found for Claude CLI fallback launch.");
107
+ }
108
+ function shouldRetryClaudeSpawnWithShell(error) {
109
+ if (process.platform === "win32") {
110
+ return false;
111
+ }
112
+ return error instanceof Error && /posix_spawnp failed/i.test(error.message);
113
+ }
73
114
  function findExecutableOnPath(command, envPath = process.env.PATH) {
74
115
  if (!envPath)
75
116
  return null;
@@ -102,6 +143,10 @@ function resolveClaudeBin(configuredBin, env = process.env) {
102
143
  throw new Error(`Claude CLI "${candidate}" was not found in PATH. Install Claude Code first, or set LEDUO_PATROL_CLAUDE_BIN=/absolute/path/to/claude.`);
103
144
  }
104
145
  export const claudeCliSessionTestables = {
146
+ buildDirectClaudeLaunch,
147
+ buildShellWrappedClaudeLaunch,
105
148
  findExecutableOnPath,
106
149
  resolveClaudeBin,
150
+ resolveClaudeWrapperShell,
151
+ shouldRetryClaudeSpawnWithShell,
107
152
  };
@@ -10,9 +10,10 @@ import { SessionManager } from "./session-manager.js";
10
10
  import { formatError, resolveAllowedPath } from "./server-helpers.js";
11
11
  import { ShellSession } from "./shell-session.js";
12
12
  import { buildSingleFileDiff, buildWorkspaceDiffFilesSnapshot } from "./git-diff.js";
13
- import { buildAccessCookie, createAccessKey, hasAuthorizedAccessCookie, isAccessKeyAuthorized } from "./access-key.js";
13
+ import { buildAccessCookie, hasAuthorizedAccessCookie, isAccessKeyAuthorized } from "./access-key.js";
14
14
  import { findAvailablePort, pickPreferredLanIp } from "./network.js";
15
15
  import { resolveBindMode } from "./launch-mode.js";
16
+ import { resolveAccessKey } from "./access-key-prompt.js";
16
17
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
17
18
  const launchCwd = process.cwd();
18
19
  const defaultWorkspacePath = process.env.LEDUO_PATROL_WORKSPACE_PATH ?? launchCwd;
@@ -34,8 +35,8 @@ const listenHost = bindMode === "local" ? "127.0.0.1" : "0.0.0.0";
34
35
  const launchHost = bindMode === "local" ? "127.0.0.1" : pickPreferredLanIp();
35
36
  const launchUser = userInfo().username;
36
37
  const claudeBin = process.env.LEDUO_PATROL_CLAUDE_BIN?.trim() || undefined;
37
- const accessKey = process.env.LEDUO_PATROL_ACCESS_KEY?.trim() || createAccessKey();
38
- const enableShell = process.env.LEDUO_ENABLE_SHELL === "true";
38
+ const accessKey = await resolveAccessKey();
39
+ const enableShell = parseBooleanFlag(process.env.LEDUO_ENABLE_SHELL, true);
39
40
  const allowSkipPermissions = process.env.LEDUO_PATROL_ALLOW_SKIP_PERMISSIONS === "true";
40
41
  const app = express();
41
42
  const server = createServer(app);
@@ -174,8 +175,17 @@ wss.on("connection", (socket, request) => {
174
175
  socket.close(1008, "Unauthorized");
175
176
  return;
176
177
  }
177
- const unsubscribe = sessionManager.subscribe((event) => sendEvent(socket, event));
178
- let shellSession = null;
178
+ const shellSessions = new Map();
179
+ const unsubscribe = sessionManager.subscribe((event) => {
180
+ if (event.type === "session_closed") {
181
+ const shellSession = shellSessions.get(event.payload.clientSessionId);
182
+ if (shellSession) {
183
+ shellSession.kill();
184
+ shellSessions.delete(event.payload.clientSessionId);
185
+ }
186
+ }
187
+ sendEvent(socket, event);
188
+ });
179
189
  socket.on("message", async (raw) => {
180
190
  try {
181
191
  const message = JSON.parse(String(raw));
@@ -215,40 +225,44 @@ wss.on("connection", (socket, request) => {
215
225
  if (!enableShell) {
216
226
  throw new Error("Shell feature is disabled. Set LEDUO_ENABLE_SHELL=true to enable it.");
217
227
  }
218
- shellSession?.kill();
219
- shellSession = null;
228
+ const clientSessionId = message.payload.clientSessionId;
229
+ const existingShell = shellSessions.get(clientSessionId);
220
230
  const cols = Math.max(2, message.payload.cols);
221
231
  const rows = Math.max(2, message.payload.rows);
222
- const shellWorkspacePath = sessionManager.getSessionWorkspacePath(message.payload.clientSessionId);
232
+ if (existingShell?.alive) {
233
+ existingShell.resize(cols, rows);
234
+ break;
235
+ }
236
+ const shellWorkspacePath = sessionManager.getSessionWorkspacePath(clientSessionId);
223
237
  const newShell = new ShellSession(shellWorkspacePath, cols, rows);
224
- shellSession = newShell;
238
+ shellSessions.set(clientSessionId, newShell);
225
239
  newShell.on("output", (data) => {
226
240
  if (socket.readyState === WebSocket.OPEN) {
227
- socket.send(JSON.stringify({ type: "shell_output", payload: { data } }));
241
+ socket.send(JSON.stringify({ type: "shell_output", payload: { clientSessionId, data } }));
228
242
  }
229
243
  });
230
244
  newShell.on("exit", (exitCode) => {
231
- if (shellSession === newShell) {
232
- shellSession = null;
245
+ if (shellSessions.get(clientSessionId) === newShell) {
246
+ shellSessions.delete(clientSessionId);
233
247
  }
234
248
  if (socket.readyState === WebSocket.OPEN) {
235
- socket.send(JSON.stringify({ type: "shell_exited", payload: { exitCode } }));
249
+ socket.send(JSON.stringify({ type: "shell_exited", payload: { clientSessionId, exitCode } }));
236
250
  }
237
251
  });
238
252
  break;
239
253
  }
240
254
  case "shell_input":
241
- if (!shellSession?.alive) {
255
+ if (!shellSessions.get(message.payload.clientSessionId)?.alive) {
242
256
  throw new Error("Shell is not running");
243
257
  }
244
- shellSession.write(message.payload.data);
258
+ shellSessions.get(message.payload.clientSessionId)?.write(message.payload.data);
245
259
  break;
246
260
  case "shell_resize":
247
- shellSession?.resize(message.payload.cols, message.payload.rows);
261
+ shellSessions.get(message.payload.clientSessionId)?.resize(message.payload.cols, message.payload.rows);
248
262
  break;
249
263
  case "shell_stop":
250
- shellSession?.kill();
251
- shellSession = null;
264
+ shellSessions.get(message.payload.clientSessionId)?.kill();
265
+ shellSessions.delete(message.payload.clientSessionId);
252
266
  break;
253
267
  }
254
268
  }
@@ -261,8 +275,10 @@ wss.on("connection", (socket, request) => {
261
275
  });
262
276
  socket.on("close", () => {
263
277
  unsubscribe();
264
- shellSession?.kill();
265
- shellSession = null;
278
+ for (const shellSession of shellSessions.values()) {
279
+ shellSession.kill();
280
+ }
281
+ shellSessions.clear();
266
282
  });
267
283
  });
268
284
  const listenPort = await findAvailablePort(requestedPort, listenHost);
@@ -286,6 +302,7 @@ else {
286
302
  console.log("Web UI is unavailable on this start because bundled assets are missing.");
287
303
  }
288
304
  console.log(`Access URL: http://${displayHost}:${accessPort}/?key=${accessKey}`);
305
+ console.log(`Shell feature: ${enableShell ? "enabled" : "disabled"}`);
289
306
  function sendEvent(socket, event) {
290
307
  if (socket.readyState !== WebSocket.OPEN) {
291
308
  return;
@@ -301,3 +318,16 @@ async function hasReadableFile(filePath) {
301
318
  return false;
302
319
  }
303
320
  }
321
+ function parseBooleanFlag(rawValue, defaultValue) {
322
+ if (rawValue == null || rawValue.trim() === "") {
323
+ return defaultValue;
324
+ }
325
+ const normalized = rawValue.trim().toLowerCase();
326
+ if (["1", "true", "yes", "on"].includes(normalized)) {
327
+ return true;
328
+ }
329
+ if (["0", "false", "no", "off"].includes(normalized)) {
330
+ return false;
331
+ }
332
+ return defaultValue;
333
+ }
@@ -1,9 +1,6 @@
1
- import os from "node:os";
2
- import path from "node:path";
3
- import { access, mkdir, readFile, writeFile } from "node:fs/promises";
4
1
  import readline from "node:readline/promises";
2
+ import { loadStartupPreferences, saveStartupPreferences } from "./startup-preferences.js";
5
3
  const DEFAULT_MODE = "server";
6
- const PREFS_FILE_PATH = path.join(os.homedir(), ".leduo-patrol", "launch-preferences.json");
7
4
  export async function resolveBindMode(options = {}) {
8
5
  const argv = options.argv ?? process.argv.slice(2);
9
6
  const stdin = options.stdin ?? process.stdin;
@@ -41,7 +38,7 @@ function parseBindMode(raw) {
41
38
  }
42
39
  return null;
43
40
  }
44
- function readOptionValue(argv, optionName) {
41
+ export function readOptionValue(argv, optionName) {
45
42
  for (let i = 0; i < argv.length; i += 1) {
46
43
  const arg = argv[i];
47
44
  if (arg === optionName) {
@@ -77,12 +74,8 @@ async function promptShouldRemember(stdin, stdout) {
77
74
  }
78
75
  }
79
76
  async function loadRememberedMode() {
80
- if (!(await isReadable(PREFS_FILE_PATH))) {
81
- return null;
82
- }
83
77
  try {
84
- const raw = await readFile(PREFS_FILE_PATH, "utf8");
85
- const parsed = JSON.parse(raw);
78
+ const parsed = (await loadStartupPreferences());
86
79
  return parseBindMode(parsed.bindMode ?? "");
87
80
  }
88
81
  catch {
@@ -90,18 +83,7 @@ async function loadRememberedMode() {
90
83
  }
91
84
  }
92
85
  async function saveRememberedMode(mode) {
93
- await mkdir(path.dirname(PREFS_FILE_PATH), { recursive: true });
94
- const payload = { bindMode: mode };
95
- await writeFile(PREFS_FILE_PATH, JSON.stringify(payload, null, 2), "utf8");
96
- }
97
- async function isReadable(filePath) {
98
- try {
99
- await access(filePath);
100
- return true;
101
- }
102
- catch {
103
- return false;
104
- }
86
+ await saveStartupPreferences({ bindMode: mode });
105
87
  }
106
88
  export const launchModeTestables = {
107
89
  parseBindMode,
@@ -0,0 +1,28 @@
1
+ import { chmodSync, existsSync, statSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { createRequire } from "node:module";
4
+ const require = createRequire(import.meta.url);
5
+ export function ensureNodePtySpawnHelperExecutable() {
6
+ if (process.platform !== "darwin") {
7
+ return;
8
+ }
9
+ const helperPath = resolveNodePtySpawnHelperPath();
10
+ if (!helperPath) {
11
+ return;
12
+ }
13
+ ensureExecutableBit(helperPath);
14
+ }
15
+ export function resolveNodePtySpawnHelperPath() {
16
+ const packageJsonPath = require.resolve("node-pty/package.json");
17
+ const packageRoot = path.dirname(packageJsonPath);
18
+ const helperPath = path.join(packageRoot, "prebuilds", `${process.platform}-${process.arch}`, "spawn-helper");
19
+ return existsSync(helperPath) ? helperPath : null;
20
+ }
21
+ export function ensureExecutableBit(filePath) {
22
+ const stats = statSync(filePath);
23
+ if ((stats.mode & 0o111) === 0o111) {
24
+ return false;
25
+ }
26
+ chmodSync(filePath, stats.mode | 0o755);
27
+ return true;
28
+ }
@@ -3,6 +3,7 @@ import { EventEmitter } from "node:events";
3
3
  import { existsSync } from "node:fs";
4
4
  import path from "node:path";
5
5
  import { buildSpawnFailureMessage, ensureDirectoryExistsSync } from "./server-helpers.js";
6
+ import { ensureNodePtySpawnHelperExecutable } from "./pty-runtime.js";
6
7
  function buildShellLoginArgs(command) {
7
8
  const shellName = path.basename(command).toLowerCase();
8
9
  if (["bash", "zsh", "ksh", "fish"].includes(shellName)) {
@@ -55,6 +56,7 @@ export class ShellSession extends EventEmitter {
55
56
  constructor(workspacePath, cols = 80, rows = 24) {
56
57
  super();
57
58
  ensureDirectoryExistsSync(workspacePath, "Shell workspace");
59
+ ensureNodePtySpawnHelperExecutable();
58
60
  const shellLaunch = resolveShellLaunch();
59
61
  const env = {
60
62
  ...Object.fromEntries(Object.entries(process.env).filter((entry) => entry[1] != null)),
@@ -0,0 +1,45 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+ import { access, mkdir, readFile, writeFile } from "node:fs/promises";
4
+ const PREFS_FILE_PATH = path.join(os.homedir(), ".leduo-patrol", "launch-preferences.json");
5
+ export async function loadStartupPreferences() {
6
+ if (!(await isReadable(PREFS_FILE_PATH))) {
7
+ return {};
8
+ }
9
+ try {
10
+ const raw = await readFile(PREFS_FILE_PATH, "utf8");
11
+ const parsed = JSON.parse(raw);
12
+ return parsed && typeof parsed === "object" ? parsed : {};
13
+ }
14
+ catch {
15
+ return {};
16
+ }
17
+ }
18
+ export async function saveStartupPreferences(updates) {
19
+ const current = await loadStartupPreferences();
20
+ const next = {
21
+ ...current,
22
+ ...updates,
23
+ };
24
+ for (const key of Object.keys(next)) {
25
+ const value = next[key];
26
+ if (value == null || value === "") {
27
+ delete next[key];
28
+ }
29
+ }
30
+ await mkdir(path.dirname(PREFS_FILE_PATH), { recursive: true });
31
+ await writeFile(PREFS_FILE_PATH, JSON.stringify(next, null, 2), "utf8");
32
+ return next;
33
+ }
34
+ async function isReadable(filePath) {
35
+ try {
36
+ await access(filePath);
37
+ return true;
38
+ }
39
+ catch {
40
+ return false;
41
+ }
42
+ }
43
+ export const startupPreferencesTestables = {
44
+ PREFS_FILE_PATH,
45
+ };