leduo-patrol 2.0.0 → 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 时展示输入页
@@ -97,6 +97,7 @@
97
97
  - Node.js 22+
98
98
  - 已能正常运行 Claude Code
99
99
  - 服务器环境里已配置 `ANTHROPIC_API_KEY`
100
+ - 若 `claude` 不在默认 `PATH` 中,请设置 `LEDUO_PATROL_CLAUDE_BIN=/absolute/path/to/claude`
100
101
 
101
102
  ## 启动
102
103
 
@@ -116,6 +117,7 @@ npm run dev:local
116
117
  - `local`:监听 `127.0.0.1`,仅本机可访问
117
118
  - 可通过命令行参数 `--mode=local|server` 或环境变量 `LEDUO_PATROL_BIND_MODE` 指定,命令行参数优先级更高
118
119
  - 若未显式指定,启动时会在终端提示选择模式;并可选择记住到 `~/.leduo-patrol/launch-preferences.json`,后续自动复用
120
+ - 若未设置 `LEDUO_PATROL_ACCESS_KEY`,启动时会提示选择“手动输入自定义 key”或“随机生成 key”,并可选择是否记住到 `~/.leduo-patrol/launch-preferences.json`
119
121
 
120
122
  - 前端开发服务运行在自动探测到的可访问内网 IP(优先 `bond* / eth* / ens* / enp*` 网卡)上
121
123
  - 后端服务运行在 `PORT`(默认 `3001`,端口冲突时会自动递增寻找可用端口)
@@ -148,15 +150,17 @@ LEDUO_PATROL_BIND_MODE=server
148
150
  LEDUO_PATROL_APP_NAME=乐多汪汪队
149
151
  LEDUO_PATROL_WORKSPACE_PATH=/absolute/workspace/path
150
152
  LEDUO_PATROL_ALLOWED_ROOTS=/absolute/workspace/path,/another/allowed/root
153
+ LEDUO_PATROL_CLAUDE_BIN=/absolute/path/to/claude
154
+ LEDUO_PATROL_SHELL=/absolute/path/to/zsh
151
155
  ANTHROPIC_API_KEY=sk-...
152
156
  LEDUO_PATROL_ACCESS_KEY=your-fixed-key
153
- LEDUO_PATROL_AGENT_BIN=/absolute/path/to/claude-code-acp
154
- LEDUO_ENABLE_SHELL=true
157
+ LEDUO_ENABLE_SHELL=false
155
158
  ```
156
159
 
157
160
  如果设置了 `LEDUO_PATROL_ALLOWED_ROOTS`,网页中只能连接这些根目录之下的路径;未设置时默认只允许启动命令所在目录。
158
161
  如果未设置 `LEDUO_PATROL_WORKSPACE_PATH`,默认工作目录为启动命令所在目录(`process.cwd()`),并在启动日志中提示如何通过环境变量修改。
159
162
  如果未设置 `LEDUO_PATROL_ALLOWED_ROOTS`,默认允许根目录同样为启动命令所在目录,并会在启动日志中提示可配置项。
163
+ 如果发布安装后的内嵌终端无法启动,可通过 `LEDUO_PATROL_SHELL` 显式指定 shell 路径;例如 macOS 上常见的 `/bin/zsh`。
160
164
 
161
165
  ## 状态持久化
162
166
 
@@ -174,7 +178,12 @@ LEDUO_ENABLE_SHELL=true
174
178
 
175
179
  ## 访问校验 Key
176
180
 
177
- 服务启动时会自动生成一次性访问 key,并在控制台打印可直接打开的地址。
181
+ 服务启动时会优先按以下顺序确定访问 key,并在控制台打印可直接打开的地址:
182
+
183
+ - `--access-key your-key`
184
+ - `LEDUO_PATROL_ACCESS_KEY`
185
+ - 已记住在 `~/.leduo-patrol/launch-preferences.json` 的 key
186
+ - 若以上都没有:启动时交互选择“手动输入”或“随机生成”
178
187
 
179
188
  - 开发模式(`npm run dev`)下,`Access URL` 默认指向 Web 端口(默认 `5173`)。
180
189
  - 生产模式(`npm start`)下,Web 由同一个 Express 服务静态托管,因此不会出现独立的 Web 监听端口;`Access URL` 会指向 server 端口。若未找到打包后的 `dist/web` 资源,服务会给出错误提示页与启动日志提示。
@@ -187,7 +196,15 @@ LEDUO_ENABLE_SHELL=true
187
196
 
188
197
  ```bash
189
198
  LEDUO_PATROL_ACCESS_KEY=your-fixed-key
190
- LEDUO_PATROL_AGENT_BIN=/absolute/path/to/claude-code-acp
199
+ LEDUO_PATROL_CLAUDE_BIN=/absolute/path/to/claude
200
+ ```
201
+
202
+ 也可以直接通过命令行传入:
203
+
204
+ ```bash
205
+ npm run dev:server -- --access-key your-fixed-key
206
+ # 或生产模式
207
+ npm start -- --access-key your-fixed-key
191
208
  ```
192
209
 
193
210
  ## 已知限制
@@ -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
+ });
@@ -0,0 +1,41 @@
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, writeFileSync } from "node:fs";
6
+ import { claudeCliSessionTestables } from "../claude-cli-session.js";
7
+ test("claudeCliSessionTestables.findExecutableOnPath resolves commands from PATH entries", () => {
8
+ const tempDir = mkdtempSync(path.join(os.tmpdir(), "leduo-patrol-claude-bin-"));
9
+ const binaryName = process.platform === "win32" ? "claude.cmd" : "claude";
10
+ const binaryPath = path.join(tempDir, binaryName);
11
+ writeFileSync(binaryPath, process.platform === "win32" ? "@echo off\r\n" : "#!/bin/sh\n");
12
+ const resolved = claudeCliSessionTestables.findExecutableOnPath("claude", tempDir);
13
+ assert.equal(resolved, binaryPath);
14
+ });
15
+ test("claudeCliSessionTestables.resolveClaudeBin accepts explicit executable paths", () => {
16
+ const tempDir = mkdtempSync(path.join(os.tmpdir(), "leduo-patrol-claude-explicit-"));
17
+ const binaryPath = path.join(tempDir, "claude");
18
+ writeFileSync(binaryPath, "#!/bin/sh\n");
19
+ const resolved = claudeCliSessionTestables.resolveClaudeBin(binaryPath, { PATH: "" });
20
+ assert.equal(resolved, binaryPath);
21
+ });
22
+ test("claudeCliSessionTestables.resolveClaudeBin throws actionable error when claude is missing", () => {
23
+ assert.throws(() => claudeCliSessionTestables.resolveClaudeBin(undefined, { PATH: "" }), /LEDUO_PATROL_CLAUDE_BIN/);
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
+ });
@@ -1,7 +1,9 @@
1
1
  import test from "node:test";
2
2
  import assert from "node:assert/strict";
3
3
  import path from "node:path";
4
- import { formatError, resolveAllowedPath } from "../server-helpers.js";
4
+ import os from "node:os";
5
+ import { mkdtempSync, writeFileSync } from "node:fs";
6
+ import { buildSpawnFailureMessage, ensureDirectoryExistsSync, formatError, resolveAllowedPath, } from "../server-helpers.js";
5
7
  test("server helpers formatError handles Error and primitives", () => {
6
8
  assert.equal(formatError(new Error("boom")), "boom");
7
9
  assert.equal(formatError("plain"), '"plain"');
@@ -16,3 +18,17 @@ test("server helpers resolveAllowedPath rejects outside roots", () => {
16
18
  const root = path.resolve("/tmp/repo");
17
19
  assert.throws(() => resolveAllowedPath("/etc", [root]), /outside allowed roots/);
18
20
  });
21
+ test("server helpers ensureDirectoryExistsSync accepts existing directories", () => {
22
+ const tempDir = mkdtempSync(path.join(os.tmpdir(), "leduo-patrol-dir-"));
23
+ assert.equal(ensureDirectoryExistsSync(tempDir, "Workspace"), tempDir);
24
+ });
25
+ test("server helpers ensureDirectoryExistsSync rejects files and missing paths", () => {
26
+ const tempDir = mkdtempSync(path.join(os.tmpdir(), "leduo-patrol-file-"));
27
+ const filePath = path.join(tempDir, "file.txt");
28
+ writeFileSync(filePath, "hello");
29
+ assert.throws(() => ensureDirectoryExistsSync(filePath, "Workspace"), /is not a directory/);
30
+ assert.throws(() => ensureDirectoryExistsSync(path.join(tempDir, "missing"), "Workspace"), /does not exist/);
31
+ });
32
+ test("server helpers buildSpawnFailureMessage includes command cwd and hint", () => {
33
+ assert.equal(buildSpawnFailureMessage("shell", "/bin/zsh", "/repo", new Error("posix_spawnp failed"), "Try another shell."), 'Failed to start shell "/bin/zsh" in "/repo": posix_spawnp failed. Try another shell.');
34
+ });
@@ -0,0 +1,35 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { shellSessionTestables } from "../shell-session.js";
4
+ test("shellSessionTestables.resolveShellLaunch prefers configured shell over defaults", () => {
5
+ const resolved = shellSessionTestables.resolveShellLaunch({
6
+ LEDUO_PATROL_SHELL: "/custom/bin/zsh",
7
+ SHELL: "/bin/bash",
8
+ }, (candidate) => candidate === "/custom/bin/zsh" || candidate === "/bin/bash");
9
+ assert.deepEqual(resolved, {
10
+ command: "/custom/bin/zsh",
11
+ args: ["-l"],
12
+ });
13
+ });
14
+ test("shellSessionTestables.resolveShellLaunch falls back to /bin/sh when bash and zsh are unavailable", () => {
15
+ const resolved = shellSessionTestables.resolveShellLaunch({
16
+ SHELL: "/missing/shell",
17
+ }, (candidate) => candidate === "/bin/sh");
18
+ assert.deepEqual(resolved, {
19
+ command: "/bin/sh",
20
+ args: [],
21
+ });
22
+ });
23
+ test("shellSessionTestables.resolveShellLaunch accepts plain configured shell names as a last resort", () => {
24
+ const resolved = shellSessionTestables.resolveShellLaunch({
25
+ LEDUO_PATROL_SHELL: "fish",
26
+ SHELL: "",
27
+ }, () => false);
28
+ assert.deepEqual(resolved, {
29
+ command: "fish",
30
+ args: ["-l"],
31
+ });
32
+ });
33
+ test("shellSessionTestables.resolveShellLaunch throws actionable error when no shell is available", () => {
34
+ assert.throws(() => shellSessionTestables.resolveShellLaunch({ SHELL: "" }, () => false), /LEDUO_PATROL_SHELL/);
35
+ });
@@ -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
+ };
@@ -1,5 +1,9 @@
1
1
  import { spawn } from "node-pty";
2
2
  import { EventEmitter } from "node:events";
3
+ import { existsSync } from "node:fs";
4
+ import path from "node:path";
5
+ import { buildSpawnFailureMessage, ensureDirectoryExistsSync } from "./server-helpers.js";
6
+ import { ensureNodePtySpawnHelperExecutable } from "./pty-runtime.js";
3
7
  /**
4
8
  * A PTY-backed session that runs the native Claude Code CLI.
5
9
  *
@@ -15,24 +19,34 @@ export class ClaudeCliSession extends EventEmitter {
15
19
  constructor(opts) {
16
20
  super();
17
21
  this.sessionId = opts.sessionId;
18
- const bin = opts.claudeBin ?? "claude";
22
+ ensureDirectoryExistsSync(opts.workspacePath, "Session workspace");
23
+ ensureNodePtySpawnHelperExecutable();
24
+ const bin = resolveClaudeBin(opts.claudeBin);
19
25
  const args = opts.resume
20
26
  ? ["--resume", opts.sessionId]
21
27
  : ["--session-id", opts.sessionId];
22
28
  if (opts.allowSkipPermissions) {
23
29
  args.push("--allow-dangerously-skip-permissions");
24
30
  }
25
- this.pty = spawn(bin, args, {
26
- name: "xterm-256color",
27
- cols: opts.cols ?? 80,
28
- rows: opts.rows ?? 24,
29
- cwd: opts.workspacePath,
30
- env: {
31
- ...process.env,
32
- TERM: "xterm-256color",
33
- COLORTERM: "truecolor",
34
- },
35
- });
31
+ const env = {
32
+ ...process.env,
33
+ TERM: "xterm-256color",
34
+ COLORTERM: "truecolor",
35
+ };
36
+ try {
37
+ this.pty = spawnClaudePty(buildDirectClaudeLaunch(bin, args), opts.workspacePath, opts.cols ?? 80, opts.rows ?? 24, env);
38
+ }
39
+ catch (error) {
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
+ }
49
+ }
36
50
  this.pty.onData((data) => {
37
51
  this.emit("output", data);
38
52
  });
@@ -61,3 +75,78 @@ export class ClaudeCliSession extends EventEmitter {
61
75
  }
62
76
  }
63
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
+ }
114
+ function findExecutableOnPath(command, envPath = process.env.PATH) {
115
+ if (!envPath)
116
+ return null;
117
+ const extensions = process.platform === "win32"
118
+ ? (process.env.PATHEXT?.split(";").filter(Boolean) ?? [".exe", ".cmd", ".bat"])
119
+ : [""];
120
+ for (const entry of envPath.split(path.delimiter).filter(Boolean)) {
121
+ for (const extension of extensions) {
122
+ const candidate = path.join(entry, `${command}${extension}`);
123
+ if (existsSync(candidate)) {
124
+ return candidate;
125
+ }
126
+ }
127
+ }
128
+ return null;
129
+ }
130
+ function resolveClaudeBin(configuredBin, env = process.env) {
131
+ const candidate = configuredBin?.trim() || "claude";
132
+ const hasExplicitPath = candidate.includes("/") || candidate.includes("\\");
133
+ if (hasExplicitPath) {
134
+ if (existsSync(candidate)) {
135
+ return candidate;
136
+ }
137
+ throw new Error(`Claude CLI not found at "${candidate}". Set LEDUO_PATROL_CLAUDE_BIN to a valid Claude executable path.`);
138
+ }
139
+ const resolved = findExecutableOnPath(candidate, env.PATH);
140
+ if (resolved) {
141
+ return resolved;
142
+ }
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.`);
144
+ }
145
+ export const claudeCliSessionTestables = {
146
+ buildDirectClaudeLaunch,
147
+ buildShellWrappedClaudeLaunch,
148
+ findExecutableOnPath,
149
+ resolveClaudeBin,
150
+ resolveClaudeWrapperShell,
151
+ shouldRetryClaudeSpawnWithShell,
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;
@@ -27,14 +28,15 @@ const vscodeRemoteUri = process.env.LEDUO_PATROL_VSCODE_URI ??
27
28
  (sshHost ? `vscode://vscode-remote/ssh-remote+${encodeURIComponent(sshHost)}${sshPath}` : "");
28
29
  const requestedPort = Number(process.env.PORT ?? 3001);
29
30
  const devWebPort = Number(process.env.LEDUO_PATROL_WEB_PORT ?? 5173);
30
- const isDevServer = process.env.npm_lifecycle_event === "dev:server";
31
+ const npmLifecycleEvent = process.env.npm_lifecycle_event ?? "";
32
+ const isDevServer = npmLifecycleEvent === "dev:server" || npmLifecycleEvent === "dev:server:local";
31
33
  const bindMode = await resolveBindMode();
32
34
  const listenHost = bindMode === "local" ? "127.0.0.1" : "0.0.0.0";
33
35
  const launchHost = bindMode === "local" ? "127.0.0.1" : pickPreferredLanIp();
34
36
  const launchUser = userInfo().username;
35
37
  const claudeBin = process.env.LEDUO_PATROL_CLAUDE_BIN?.trim() || undefined;
36
- const accessKey = process.env.LEDUO_PATROL_ACCESS_KEY?.trim() || createAccessKey();
37
- const enableShell = process.env.LEDUO_ENABLE_SHELL === "true";
38
+ const accessKey = await resolveAccessKey();
39
+ const enableShell = parseBooleanFlag(process.env.LEDUO_ENABLE_SHELL, true);
38
40
  const allowSkipPermissions = process.env.LEDUO_PATROL_ALLOW_SKIP_PERMISSIONS === "true";
39
41
  const app = express();
40
42
  const server = createServer(app);
@@ -173,8 +175,17 @@ wss.on("connection", (socket, request) => {
173
175
  socket.close(1008, "Unauthorized");
174
176
  return;
175
177
  }
176
- const unsubscribe = sessionManager.subscribe((event) => sendEvent(socket, event));
177
- 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
+ });
178
189
  socket.on("message", async (raw) => {
179
190
  try {
180
191
  const message = JSON.parse(String(raw));
@@ -214,40 +225,44 @@ wss.on("connection", (socket, request) => {
214
225
  if (!enableShell) {
215
226
  throw new Error("Shell feature is disabled. Set LEDUO_ENABLE_SHELL=true to enable it.");
216
227
  }
217
- shellSession?.kill();
218
- shellSession = null;
228
+ const clientSessionId = message.payload.clientSessionId;
229
+ const existingShell = shellSessions.get(clientSessionId);
219
230
  const cols = Math.max(2, message.payload.cols);
220
231
  const rows = Math.max(2, message.payload.rows);
221
- 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);
222
237
  const newShell = new ShellSession(shellWorkspacePath, cols, rows);
223
- shellSession = newShell;
238
+ shellSessions.set(clientSessionId, newShell);
224
239
  newShell.on("output", (data) => {
225
240
  if (socket.readyState === WebSocket.OPEN) {
226
- socket.send(JSON.stringify({ type: "shell_output", payload: { data } }));
241
+ socket.send(JSON.stringify({ type: "shell_output", payload: { clientSessionId, data } }));
227
242
  }
228
243
  });
229
244
  newShell.on("exit", (exitCode) => {
230
- if (shellSession === newShell) {
231
- shellSession = null;
245
+ if (shellSessions.get(clientSessionId) === newShell) {
246
+ shellSessions.delete(clientSessionId);
232
247
  }
233
248
  if (socket.readyState === WebSocket.OPEN) {
234
- socket.send(JSON.stringify({ type: "shell_exited", payload: { exitCode } }));
249
+ socket.send(JSON.stringify({ type: "shell_exited", payload: { clientSessionId, exitCode } }));
235
250
  }
236
251
  });
237
252
  break;
238
253
  }
239
254
  case "shell_input":
240
- if (!shellSession?.alive) {
255
+ if (!shellSessions.get(message.payload.clientSessionId)?.alive) {
241
256
  throw new Error("Shell is not running");
242
257
  }
243
- shellSession.write(message.payload.data);
258
+ shellSessions.get(message.payload.clientSessionId)?.write(message.payload.data);
244
259
  break;
245
260
  case "shell_resize":
246
- shellSession?.resize(message.payload.cols, message.payload.rows);
261
+ shellSessions.get(message.payload.clientSessionId)?.resize(message.payload.cols, message.payload.rows);
247
262
  break;
248
263
  case "shell_stop":
249
- shellSession?.kill();
250
- shellSession = null;
264
+ shellSessions.get(message.payload.clientSessionId)?.kill();
265
+ shellSessions.delete(message.payload.clientSessionId);
251
266
  break;
252
267
  }
253
268
  }
@@ -260,8 +275,10 @@ wss.on("connection", (socket, request) => {
260
275
  });
261
276
  socket.on("close", () => {
262
277
  unsubscribe();
263
- shellSession?.kill();
264
- shellSession = null;
278
+ for (const shellSession of shellSessions.values()) {
279
+ shellSession.kill();
280
+ }
281
+ shellSessions.clear();
265
282
  });
266
283
  });
267
284
  const listenPort = await findAvailablePort(requestedPort, listenHost);
@@ -285,6 +302,7 @@ else {
285
302
  console.log("Web UI is unavailable on this start because bundled assets are missing.");
286
303
  }
287
304
  console.log(`Access URL: http://${displayHost}:${accessPort}/?key=${accessKey}`);
305
+ console.log(`Shell feature: ${enableShell ? "enabled" : "disabled"}`);
288
306
  function sendEvent(socket, event) {
289
307
  if (socket.readyState !== WebSocket.OPEN) {
290
308
  return;
@@ -300,3 +318,16 @@ async function hasReadableFile(filePath) {
300
318
  return false;
301
319
  }
302
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
+ }