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 +22 -5
- package/dist/server/__tests__/access-key-prompt.test.js +80 -0
- package/dist/server/__tests__/claude-cli-session.test.js +41 -0
- package/dist/server/__tests__/pty-runtime.test.js +28 -0
- package/dist/server/__tests__/server-helpers.test.js +17 -1
- package/dist/server/__tests__/shell-session.test.js +35 -0
- package/dist/server/access-key-prompt.js +84 -0
- package/dist/server/claude-cli-session.js +101 -12
- package/dist/server/index.js +52 -21
- package/dist/server/launch-mode.js +4 -22
- package/dist/server/pty-runtime.js +28 -0
- package/dist/server/server-helpers.js +22 -0
- package/dist/server/session-manager.js +18 -0
- package/dist/server/shell-session.js +61 -20
- package/dist/server/startup-preferences.js +45 -0
- package/dist/web/assets/index-B5Dh2E8j.css +1 -0
- package/dist/web/assets/index-xPPPaEde.js +13 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-B0sSFjwT.css +0 -1
- package/dist/web/assets/index-Cdb0JMLq.js +0 -13
package/README.md
CHANGED
|
@@ -83,7 +83,7 @@
|
|
|
83
83
|
- **目录浏览**:创建会话时可在允许根目录范围内浏览子目录,安全限制越权访问
|
|
84
84
|
|
|
85
85
|
### 工具与集成
|
|
86
|
-
- **内置终端**:下方可展开终端抽屉,通过 xterm.js 提供完整 PTY
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
+
};
|
package/dist/server/index.js
CHANGED
|
@@ -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,
|
|
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
|
|
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 =
|
|
37
|
-
const enableShell = process.env.LEDUO_ENABLE_SHELL
|
|
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
|
|
177
|
-
|
|
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
|
-
|
|
218
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
231
|
-
|
|
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 (!
|
|
255
|
+
if (!shellSessions.get(message.payload.clientSessionId)?.alive) {
|
|
241
256
|
throw new Error("Shell is not running");
|
|
242
257
|
}
|
|
243
|
-
|
|
258
|
+
shellSessions.get(message.payload.clientSessionId)?.write(message.payload.data);
|
|
244
259
|
break;
|
|
245
260
|
case "shell_resize":
|
|
246
|
-
|
|
261
|
+
shellSessions.get(message.payload.clientSessionId)?.resize(message.payload.cols, message.payload.rows);
|
|
247
262
|
break;
|
|
248
263
|
case "shell_stop":
|
|
249
|
-
|
|
250
|
-
|
|
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
|
|
264
|
-
|
|
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
|
+
}
|