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 +17 -3
- package/dist/server/__tests__/access-key-prompt.test.js +80 -0
- package/dist/server/__tests__/claude-cli-session.test.js +17 -0
- package/dist/server/__tests__/pty-runtime.test.js +28 -0
- package/dist/server/access-key-prompt.js +84 -0
- package/dist/server/claude-cli-session.js +57 -12
- package/dist/server/index.js +50 -20
- package/dist/server/launch-mode.js +4 -22
- package/dist/server/pty-runtime.js +28 -0
- package/dist/server/shell-session.js +2 -0
- 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 时展示输入页
|
|
@@ -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=
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
};
|
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;
|
|
@@ -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 =
|
|
38
|
-
const enableShell = process.env.LEDUO_ENABLE_SHELL
|
|
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
|
|
178
|
-
|
|
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
|
-
|
|
219
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
232
|
-
|
|
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 (!
|
|
255
|
+
if (!shellSessions.get(message.payload.clientSessionId)?.alive) {
|
|
242
256
|
throw new Error("Shell is not running");
|
|
243
257
|
}
|
|
244
|
-
|
|
258
|
+
shellSessions.get(message.payload.clientSessionId)?.write(message.payload.data);
|
|
245
259
|
break;
|
|
246
260
|
case "shell_resize":
|
|
247
|
-
|
|
261
|
+
shellSessions.get(message.payload.clientSessionId)?.resize(message.payload.cols, message.payload.rows);
|
|
248
262
|
break;
|
|
249
263
|
case "shell_stop":
|
|
250
|
-
|
|
251
|
-
|
|
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
|
|
265
|
-
|
|
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
|
|
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
|
|
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
|
+
};
|