@znmin/claude-remote-cli 0.3.2 → 0.4.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.
@@ -1,39 +1,39 @@
1
- import { quote } from './system.js';
2
-
3
- export const remoteEnvironment = 'export PATH="$HOME/.local/bin:$PATH"; ';
4
-
5
- export function ensureRemoteClaude(command) {
6
- const missing = command === 'claude' ? `
7
- echo '服务器未安装 Claude Code,正在执行官方安装流程……' >&2
8
- (
9
- set -e
10
- installer=$(mktemp "\${TMPDIR:-/tmp}/claude-remote-install.XXXXXXXX")
11
- trap 'rm -f -- "$installer"' EXIT
12
- if command -v curl >/dev/null 2>&1; then
13
- curl -fSL </dev/null --connect-timeout 20 --max-time 120 --retry 2 \\
14
- --output "$installer" https://claude.ai/install.sh
15
- elif command -v wget >/dev/null 2>&1; then
16
- wget </dev/null --timeout=120 --tries=3 --output-document="$installer" https://claude.ai/install.sh
17
- else
18
- echo '自动安装需要服务器提供 curl 或 wget,请先安装其中一个工具。' >&2
19
- exit 127
20
- fi
21
- if ! bash "$installer" >&2 </dev/null; then
22
- echo 'Claude Code 官方安装失败,请检查上述输出、网络连接和目录权限后重试。' >&2
23
- exit 1
24
- fi
25
- )
26
- hash -r
27
- if ! command -v claude >/dev/null 2>&1; then
28
- echo '安装器已退出,但仍找不到 Claude Code。请检查 ~/.local/bin/claude 或通过 --claude 指定绝对路径。' >&2
29
- exit 127
30
- fi
31
- echo 'Claude Code 已安装,首次启动时请按提示完成账号认证。' >&2
32
- ` : `
33
- echo ${quote('找不到配置的 Claude 程序:' + command + '。请修正 --claude 路径,或配置 --claude claude 使用自动安装。')} >&2
34
- exit 127
35
- `;
36
- // A failed download/installer must stop before any project directory is created or synced.
37
- return 'set -e\n' + remoteEnvironment + '\n' +
38
- `if ! command -v ${quote(command)} >/dev/null 2>&1; then\n${missing}fi\n`;
39
- }
1
+ import { quote } from './system.js';
2
+
3
+ export const remoteEnvironment = 'export PATH="$HOME/.local/bin:$PATH"; ';
4
+
5
+ export function ensureRemoteClaude(command) {
6
+ const missing = command === 'claude' ? `
7
+ echo '服务器未安装 Claude Code,正在执行官方安装流程……' >&2
8
+ (
9
+ set -e
10
+ installer=$(mktemp "\${TMPDIR:-/tmp}/claude-remote-install.XXXXXXXX")
11
+ trap 'rm -f -- "$installer"' EXIT
12
+ if command -v curl >/dev/null 2>&1; then
13
+ curl -fSL </dev/null --connect-timeout 20 --max-time 120 --retry 2 \\
14
+ --output "$installer" https://claude.ai/install.sh
15
+ elif command -v wget >/dev/null 2>&1; then
16
+ wget </dev/null --timeout=120 --tries=3 --output-document="$installer" https://claude.ai/install.sh
17
+ else
18
+ echo '自动安装需要服务器提供 curl 或 wget,请先安装其中一个工具。' >&2
19
+ exit 127
20
+ fi
21
+ if ! bash "$installer" >&2 </dev/null; then
22
+ echo 'Claude Code 官方安装失败,请检查上述输出、网络连接和目录权限后重试。' >&2
23
+ exit 1
24
+ fi
25
+ )
26
+ hash -r
27
+ if ! command -v claude >/dev/null 2>&1; then
28
+ echo '安装器已退出,但仍找不到 Claude Code。请检查 ~/.local/bin/claude 或通过 --claude 指定绝对路径。' >&2
29
+ exit 127
30
+ fi
31
+ echo 'Claude Code 已安装,首次启动时请按提示完成账号认证。' >&2
32
+ ` : `
33
+ echo ${quote('找不到配置的 Claude 程序:' + command + '。请修正 --claude 路径,或配置 --claude claude 使用自动安装。')} >&2
34
+ exit 127
35
+ `;
36
+ // A failed download/installer must stop before any project directory is created or synced.
37
+ return 'set -e\n' + remoteEnvironment + '\n' +
38
+ `if ! command -v ${quote(command)} >/dev/null 2>&1; then\n${missing}fi\n`;
39
+ }
@@ -0,0 +1,97 @@
1
+ import { quote } from './system.js';
2
+
3
+ // Claude Code on Linux reads a pasted image by running
4
+ // xclip -selection clipboard -t TARGETS -o (is there an image?)
5
+ // xclip -selection clipboard -t image/png -o (write it to a file)
6
+ // This stand-in is put first on the remote Claude's PATH and answers those two
7
+ // reads with the image on the LOCAL clipboard, fetched through the SSH reverse
8
+ // tunnel. Every other call is passed on to the real xclip, if any.
9
+ // The token stays in a 0600 file read with `curl -H @file`, so it never
10
+ // appears in a process list on the server.
11
+ export const XCLIP_SHIM = `#!/bin/sh
12
+ # claude-remote: serve clipboard image reads from the LOCAL computer.
13
+ dir=$(cd -- "$(dirname -- "$0")" && pwd -P)
14
+ selection=primary target= output= previous=
15
+ for argument in "$@"; do
16
+ case $previous in
17
+ -selection|-sel) selection=$argument ;;
18
+ -t|-target) target=$argument ;;
19
+ esac
20
+ case $argument in -o|-out) output=1 ;; esac
21
+ previous=$argument
22
+ done
23
+
24
+ real() {
25
+ PATH=$(printf '%s' "$PATH" | tr ':' '\\n' | grep -vxF -- "$dir" | paste -s -d: -)
26
+ export PATH
27
+ command -v xclip >/dev/null 2>&1 || exit 1
28
+ exec xclip "$@"
29
+ }
30
+
31
+ # Download the LOCAL clipboard image to $1; fails when there is none (HTTP 204).
32
+ fetch() {
33
+ part=$1.part
34
+ rm -f -- "$part"
35
+ if command -v curl >/dev/null 2>&1; then
36
+ curl -sf --max-time 20 -H @"$dir/header" -o "$part" "$(cat -- "$dir/url")" 2>/dev/null
37
+ elif command -v python3 >/dev/null 2>&1; then
38
+ python3 - "$dir" "$part" <<'PYTHON' 2>/dev/null
39
+ import sys, urllib.request
40
+ directory, target = sys.argv[1], sys.argv[2]
41
+ name, value = open(directory + '/header').read().strip().split(': ', 1)
42
+ request = urllib.request.Request(open(directory + '/url').read().strip(), headers={name: value})
43
+ with urllib.request.urlopen(request, timeout=20) as response, open(target, 'wb') as output:
44
+ output.write(response.read())
45
+ PYTHON
46
+ else
47
+ return 1
48
+ fi
49
+ if [ -s "$part" ]; then mv -f -- "$part" "$1"; else rm -f -- "$part"; return 1; fi
50
+ }
51
+
52
+ if [ "$output" = 1 ] && [ "$selection" = clipboard ]; then
53
+ cache=$dir/clipboard.png
54
+ case $target in
55
+ TARGETS)
56
+ fetch "$cache" || exit 1
57
+ printf 'TARGETS\\nimage/png\\n'
58
+ exit 0 ;;
59
+ image/png)
60
+ # Reuse the image fetched by the TARGETS check a moment ago.
61
+ modified=$(stat -c %Y -- "$cache" 2>/dev/null || stat -f %m -- "$cache" 2>/dev/null || echo 0)
62
+ age=$(( $(date +%s) - modified ))
63
+ if [ ! -s "$cache" ] || [ "$age" -gt 30 ]; then fetch "$cache" || exit 1; fi
64
+ cat -- "$cache"
65
+ rm -f -- "$cache"
66
+ exit 0 ;;
67
+ esac
68
+ fi
69
+ real "$@"
70
+ `;
71
+
72
+ // Claude Code on Linux pastes images with Ctrl+V only, but Windows terminals
73
+ // keep Ctrl+V for their own paste, so Windows users press Alt+V as they would
74
+ // locally. This binding is honoured even with --restricted.
75
+ export const ALT_V_BINDING = { context: 'Chat', bindings: { 'alt+v': 'chat:imagePaste' } };
76
+ export const KEYBINDING_MISSING = 'CLAUDE_REMOTE_ALT_V_MISSING';
77
+
78
+ // Creates ~/.claude/keybindings.json with the Alt+V binding when it does not
79
+ // exist. An existing file is never edited; KEYBINDING_MISSING is printed
80
+ // instead when it has no Alt+V entry, so the user can add it.
81
+ export function ensureAltVBinding() {
82
+ const file = '"$HOME/.claude/keybindings.json"';
83
+ // Failing to write is not fatal: Ctrl+V still works where the terminal allows it.
84
+ return 'if [ ! -e ' + file + ' ]; then { mkdir -p -- "$HOME/.claude" && printf \'%s\\n\' ' +
85
+ quote(JSON.stringify({ bindings: [ALT_V_BINDING] }, null, 2)) + ' > ' + file + '; } 2>/dev/null || true\n' +
86
+ 'elif ! grep -qi \'"alt+v"\' ' + file + ' 2>/dev/null; then echo ' + KEYBINDING_MISSING + '\nfi\n';
87
+ }
88
+
89
+ // Shell lines that install the stand-in into `directory` (an already quoted
90
+ // remote path). The Authorization header is read from the next stdin line.
91
+ export function installClipboardShim(directory, url) {
92
+ return 'mkdir -m 700 -- ' + directory + '\n' +
93
+ 'IFS= read -r line; printf \'%s\\n\' "$line" > ' + directory + '/header\n' +
94
+ 'printf \'%s\\n\' ' + url + ' > ' + directory + '/url\n' +
95
+ 'cat > ' + directory + "/xclip <<'CLAUDE_REMOTE_XCLIP'\n" + XCLIP_SHIM + 'CLAUDE_REMOTE_XCLIP\n' +
96
+ 'chmod 700 -- ' + directory + '/xclip\n';
97
+ }
package/src/ssh-auth.js CHANGED
@@ -1,113 +1,113 @@
1
- import * as fs from 'node:fs/promises';
2
- import path from 'node:path';
3
- import os from 'node:os';
4
- import { createHash } from 'node:crypto';
5
- import { sshBase } from './ssh.js';
6
- import { exists, quote, which, withLock } from './system.js';
7
- import { parseSSHPolicy, sshPolicyScript } from './ssh-policy.js';
8
-
9
- // Force a new public-key connection: an existing multiplexed/password session
10
- // must never be mistaken for successful key authentication.
11
- const probeOptions = ['-o', 'BatchMode=yes', '-o', 'PreferredAuthentications=publickey',
12
- '-o', 'ControlPath=none', '-o', 'ControlMaster=no'];
13
-
14
- export function authorizeKeyScript(publicKey) {
15
- if (!/^ssh-ed25519 [A-Za-z0-9+/]+={0,3}(?: [^\r\n]*)?$/.test(publicKey))
16
- throw new Error('专用 SSH 公钥格式无效。');
17
- return 'set -eu; umask 077; mkdir -p "$HOME/.ssh"; chmod 700 "$HOME/.ssh"; ' +
18
- 'touch "$HOME/.ssh/authorized_keys"; chmod 600 "$HOME/.ssh/authorized_keys"; ' +
19
- 'key=' + quote(publicKey) + '; ' +
20
- 'if ! grep -qxF -- "$key" "$HOME/.ssh/authorized_keys"; then ' +
21
- // A pre-existing file need not end with a newline.
22
- 'printf "\\n%s\\n" "$key" >> "$HOME/.ssh/authorized_keys"; fi';
23
- }
24
-
25
- export async function ensureSSH(client, config, { enablePubkey = false } = {}) {
26
- if (!await which('ssh', client.env.PATH)) throw new Error('找不到系统 SSH 客户端。');
27
- const run = (args, options = {}) => client.execute('ssh', [...sshBase(config), ...args],
28
- { env: client.env, timeout: 30000, ...options });
29
- const probe = async (identity = []) => (await run([...probeOptions, ...identity, config.host, 'true'],
30
- { capture: true, check: false })).code === 0;
31
- if (await probe()) return;
32
- if (!client.tty) throw new Error('SSH 免密连接尚不可用。请在交互终端运行 claude-remote setup-ssh,完成首次登录及密钥授权;也请检查服务器地址和主机指纹。');
33
- if (!await which('ssh-keygen', client.env.PATH)) throw new Error('找不到 ssh-keygen,请安装 OpenSSH 客户端。');
34
-
35
- const directory = path.join(client.home, '.ssh', 'claude-remote');
36
- // Windows OpenSSH cannot share one login through a control socket, so each
37
- // step there opens its own connection and may ask for the password again.
38
- const multiplex = (client.platform ?? process.platform) !== 'win32';
39
- await withLock(path.join(directory, 'setup'), async () => {
40
- if (await probe()) return;
41
- let temporary, socket;
42
- const login = args => multiplex ? ['-S', socket, ...args] : args;
43
- // Without multiplexing, prompts belong to these commands: keep them visible and allow time to answer.
44
- const prompted = multiplex ? {} : { prompt: true, timeout: 180000 };
45
- try {
46
- if (multiplex) {
47
- const base = os.tmpdir().length > 55 ? '/tmp' : os.tmpdir();
48
- temporary = await fs.mkdtemp(path.join(base, 'ccra-'));
49
- socket = path.join(temporary, 'ssh');
50
- client.log('请按提示完成 SSH 验证。');
51
- // Authenticate once; subsequent installation reuses this private socket.
52
- // Do not capture authentication output or handle passwords in JavaScript.
53
- await run(['-M', '-S', socket, '-o', 'ControlPersist=no', '-o', 'ControlMaster=yes',
54
- '-f', '-N', config.host], { timeout: 180000, capture: false });
55
- } else client.log('请按提示完成 SSH 验证。Windows 的 OpenSSH 不支持连接复用,可能需要输入两次密码。');
56
- const inspect = await run(login([config.host, 'sh -c ' + quote(sshPolicyScript({ enable: enablePubkey }))]),
57
- { capture: true, ...prompted });
58
- const policy = parseSSHPolicy(inspect.stdout);
59
- if (policy.backup) client.log('已开启公钥登录。配置备份:' + policy.backup);
60
- if (policy.pubkeyauthentication === 'no')
61
- throw new Error('服务器关闭了公钥登录(PubkeyAuthentication no),尚未生成或添加密钥。' +
62
- (policy.uid === '0' ? '如需开启,请运行:claude-remote setup-ssh --enable-pubkey(备份配置、校验后重新加载,保留密码登录)。' :
63
- '请联系管理员开启该账号的公钥登录,再运行 claude-remote setup-ssh。'));
64
- if (policy.authenticationmethods && policy.authenticationmethods !== 'any' &&
65
- !policy.authenticationmethods.split(/\s+/).includes('publickey'))
66
- throw new Error('服务器 AuthenticationMethods=' + policy.authenticationmethods +
67
- ',不允许仅凭公钥登录;请联系管理员确认认证策略。未修改该策略,尚未生成或添加密钥。');
68
- if (policy.status !== 'ok') {
69
- if (enablePubkey) throw new Error('无法读取服务器有效 SSH 配置,未自动修改服务器策略;请联系管理员检查。');
70
- client.debug('无法读取服务器 SSH 策略,尝试普通公钥授权。');
71
- }
72
- const id = createHash('sha256').update(config.host + '\0' + (config.port ?? '')).digest('hex').slice(0, 20);
73
- const key = path.join(directory, 'id_ed25519_' + id);
74
- await fs.chmod(directory, 0o700);
75
- if (!await exists(key)) {
76
- if (await exists(key + '.pub')) throw new Error('专用 SSH 私钥缺失但公钥仍存在,请检查:' + key);
77
- client.debug('创建专用 SSH 密钥:' + key);
78
- await client.execute('ssh-keygen', ['-q', '-t', 'ed25519', '-N', '', '-C', 'claude-remote-cli', '-f', key],
79
- { env: client.env, capture: true, timeout: 30000 });
80
- }
81
- await fs.chmod(key, 0o600);
82
- // Derive from the private key so an outdated .pub file cannot authorize
83
- // the wrong key. Never transmit the private key.
84
- const derived = await client.execute('ssh-keygen', ['-y', '-P', '', '-f', key],
85
- { env: client.env, capture: true, timeout: 30000 });
86
- const publicKey = derived.stdout.trim();
87
- const script = authorizeKeyScript(publicKey);
88
- await run(login([config.host, 'sh -c ' + quote(script)]), { capture: false, ...(multiplex ? {} : { timeout: 180000 }) });
89
- if (!await probe(['-i', key, '-o', 'IdentitiesOnly=yes', '-o', 'IdentityAgent=none']))
90
- throw new Error('公钥已添加,但独立密钥登录验证失败。请检查服务器的公钥登录策略和 authorized_keys 设置;未修改本机 SSH 配置。');
91
-
92
- // SSH's ordinary config also makes the key available to Mutagen and scp.
93
- // IdentityFile is additive; do not replace existing host/user/proxy rules.
94
- const configFile = path.join(client.home, '.ssh', 'config');
95
- const host = config.host.split('@').at(-1);
96
- // Forward slashes avoid backslash escaping differences between OpenSSH versions on Windows.
97
- const file = (client.platform ?? process.platform) === 'win32' ? key.replaceAll('\\', '/') : key;
98
- const escaped = file.replaceAll('%', '%%').replaceAll('\\', '\\\\').replaceAll('"', '\\"');
99
- const block = `\n# claude-remote-cli ${id}\nHost ${host}\n IdentityFile "${escaped}"\nHost *\n`;
100
- const previous = await fs.readFile(configFile, 'utf8').catch(error => {
101
- if (error.code === 'ENOENT') return ''; throw error;
102
- });
103
- if (!previous.includes(block)) await fs.appendFile(configFile, block, { mode: 0o600 });
104
- if (!await probe()) throw new Error('密钥已授权,但默认 SSH 配置验证失败。请检查 ~/.ssh/config 的 IdentityFile 和 IdentitiesOnly 设置。');
105
- client.debug('SSH 免密登录已配置并验证成功。');
106
- } finally {
107
- if (multiplex) {
108
- if (socket) await run(['-S', socket, '-O', 'exit', config.host], { capture: true, check: false, timeout: 5000 }).catch(() => {});
109
- if (temporary) await fs.rm(temporary, { recursive: true, force: true });
110
- }
111
- }
112
- });
113
- }
1
+ import * as fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+ import { createHash } from 'node:crypto';
5
+ import { sshBase } from './ssh.js';
6
+ import { exists, quote, which, withLock } from './system.js';
7
+ import { parseSSHPolicy, sshPolicyScript } from './ssh-policy.js';
8
+
9
+ // Force a new public-key connection: an existing multiplexed/password session
10
+ // must never be mistaken for successful key authentication.
11
+ const probeOptions = ['-o', 'BatchMode=yes', '-o', 'PreferredAuthentications=publickey',
12
+ '-o', 'ControlPath=none', '-o', 'ControlMaster=no'];
13
+
14
+ export function authorizeKeyScript(publicKey) {
15
+ if (!/^ssh-ed25519 [A-Za-z0-9+/]+={0,3}(?: [^\r\n]*)?$/.test(publicKey))
16
+ throw new Error('专用 SSH 公钥格式无效。');
17
+ return 'set -eu; umask 077; mkdir -p "$HOME/.ssh"; chmod 700 "$HOME/.ssh"; ' +
18
+ 'touch "$HOME/.ssh/authorized_keys"; chmod 600 "$HOME/.ssh/authorized_keys"; ' +
19
+ 'key=' + quote(publicKey) + '; ' +
20
+ 'if ! grep -qxF -- "$key" "$HOME/.ssh/authorized_keys"; then ' +
21
+ // A pre-existing file need not end with a newline.
22
+ 'printf "\\n%s\\n" "$key" >> "$HOME/.ssh/authorized_keys"; fi';
23
+ }
24
+
25
+ export async function ensureSSH(client, config, { enablePubkey = false } = {}) {
26
+ if (!await which('ssh', client.env.PATH)) throw new Error('找不到系统 SSH 客户端。');
27
+ const run = (args, options = {}) => client.execute('ssh', [...sshBase(config), ...args],
28
+ { env: client.env, timeout: 30000, ...options });
29
+ const probe = async (identity = []) => (await run([...probeOptions, ...identity, config.host, 'true'],
30
+ { capture: true, check: false })).code === 0;
31
+ if (await probe()) return;
32
+ if (!client.tty) throw new Error('SSH 免密连接尚不可用。请在交互终端运行 claude-remote setup-ssh,完成首次登录及密钥授权;也请检查服务器地址和主机指纹。');
33
+ if (!await which('ssh-keygen', client.env.PATH)) throw new Error('找不到 ssh-keygen,请安装 OpenSSH 客户端。');
34
+
35
+ const directory = path.join(client.home, '.ssh', 'claude-remote');
36
+ // Windows OpenSSH cannot share one login through a control socket, so each
37
+ // step there opens its own connection and may ask for the password again.
38
+ const multiplex = (client.platform ?? process.platform) !== 'win32';
39
+ await withLock(path.join(directory, 'setup'), async () => {
40
+ if (await probe()) return;
41
+ let temporary, socket;
42
+ const login = args => multiplex ? ['-S', socket, ...args] : args;
43
+ // Without multiplexing, prompts belong to these commands: keep them visible and allow time to answer.
44
+ const prompted = multiplex ? {} : { prompt: true, timeout: 180000 };
45
+ try {
46
+ if (multiplex) {
47
+ const base = os.tmpdir().length > 55 ? '/tmp' : os.tmpdir();
48
+ temporary = await fs.mkdtemp(path.join(base, 'ccra-'));
49
+ socket = path.join(temporary, 'ssh');
50
+ client.log('请按提示完成 SSH 验证。');
51
+ // Authenticate once; subsequent installation reuses this private socket.
52
+ // Do not capture authentication output or handle passwords in JavaScript.
53
+ await run(['-M', '-S', socket, '-o', 'ControlPersist=no', '-o', 'ControlMaster=yes',
54
+ '-f', '-N', config.host], { timeout: 180000, capture: false });
55
+ } else client.log('请按提示完成 SSH 验证。Windows 的 OpenSSH 不支持连接复用,可能需要输入两次密码。');
56
+ const inspect = await run(login([config.host, 'sh -c ' + quote(sshPolicyScript({ enable: enablePubkey }))]),
57
+ { capture: true, ...prompted });
58
+ const policy = parseSSHPolicy(inspect.stdout);
59
+ if (policy.backup) client.log('已开启公钥登录。配置备份:' + policy.backup);
60
+ if (policy.pubkeyauthentication === 'no')
61
+ throw new Error('服务器关闭了公钥登录(PubkeyAuthentication no),尚未生成或添加密钥。' +
62
+ (policy.uid === '0' ? '如需开启,请运行:claude-remote setup-ssh --enable-pubkey(备份配置、校验后重新加载,保留密码登录)。' :
63
+ '请联系管理员开启该账号的公钥登录,再运行 claude-remote setup-ssh。'));
64
+ if (policy.authenticationmethods && policy.authenticationmethods !== 'any' &&
65
+ !policy.authenticationmethods.split(/\s+/).includes('publickey'))
66
+ throw new Error('服务器 AuthenticationMethods=' + policy.authenticationmethods +
67
+ ',不允许仅凭公钥登录;请联系管理员确认认证策略。未修改该策略,尚未生成或添加密钥。');
68
+ if (policy.status !== 'ok') {
69
+ if (enablePubkey) throw new Error('无法读取服务器有效 SSH 配置,未自动修改服务器策略;请联系管理员检查。');
70
+ client.debug('无法读取服务器 SSH 策略,尝试普通公钥授权。');
71
+ }
72
+ const id = createHash('sha256').update(config.host + '\0' + (config.port ?? '')).digest('hex').slice(0, 20);
73
+ const key = path.join(directory, 'id_ed25519_' + id);
74
+ await fs.chmod(directory, 0o700);
75
+ if (!await exists(key)) {
76
+ if (await exists(key + '.pub')) throw new Error('专用 SSH 私钥缺失但公钥仍存在,请检查:' + key);
77
+ client.debug('创建专用 SSH 密钥:' + key);
78
+ await client.execute('ssh-keygen', ['-q', '-t', 'ed25519', '-N', '', '-C', 'claude-remote-cli', '-f', key],
79
+ { env: client.env, capture: true, timeout: 30000 });
80
+ }
81
+ await fs.chmod(key, 0o600);
82
+ // Derive from the private key so an outdated .pub file cannot authorize
83
+ // the wrong key. Never transmit the private key.
84
+ const derived = await client.execute('ssh-keygen', ['-y', '-P', '', '-f', key],
85
+ { env: client.env, capture: true, timeout: 30000 });
86
+ const publicKey = derived.stdout.trim();
87
+ const script = authorizeKeyScript(publicKey);
88
+ await run(login([config.host, 'sh -c ' + quote(script)]), { capture: false, ...(multiplex ? {} : { timeout: 180000 }) });
89
+ if (!await probe(['-i', key, '-o', 'IdentitiesOnly=yes', '-o', 'IdentityAgent=none']))
90
+ throw new Error('公钥已添加,但独立密钥登录验证失败。请检查服务器的公钥登录策略和 authorized_keys 设置;未修改本机 SSH 配置。');
91
+
92
+ // SSH's ordinary config also makes the key available to Mutagen and scp.
93
+ // IdentityFile is additive; do not replace existing host/user/proxy rules.
94
+ const configFile = path.join(client.home, '.ssh', 'config');
95
+ const host = config.host.split('@').at(-1);
96
+ // Forward slashes avoid backslash escaping differences between OpenSSH versions on Windows.
97
+ const file = (client.platform ?? process.platform) === 'win32' ? key.replaceAll('\\', '/') : key;
98
+ const escaped = file.replaceAll('%', '%%').replaceAll('\\', '\\\\').replaceAll('"', '\\"');
99
+ const block = `\n# claude-remote-cli ${id}\nHost ${host}\n IdentityFile "${escaped}"\nHost *\n`;
100
+ const previous = await fs.readFile(configFile, 'utf8').catch(error => {
101
+ if (error.code === 'ENOENT') return ''; throw error;
102
+ });
103
+ if (!previous.includes(block)) await fs.appendFile(configFile, block, { mode: 0o600 });
104
+ if (!await probe()) throw new Error('密钥已授权,但默认 SSH 配置验证失败。请检查 ~/.ssh/config 的 IdentityFile 和 IdentitiesOnly 设置。');
105
+ client.debug('SSH 免密登录已配置并验证成功。');
106
+ } finally {
107
+ if (multiplex) {
108
+ if (socket) await run(['-S', socket, '-O', 'exit', config.host], { capture: true, check: false, timeout: 5000 }).catch(() => {});
109
+ if (temporary) await fs.rm(temporary, { recursive: true, force: true });
110
+ }
111
+ }
112
+ });
113
+ }
package/src/ssh-policy.js CHANGED
@@ -1,92 +1,92 @@
1
- import { quote } from './system.js';
2
-
3
- // configFile is injectable only for isolated filesystem tests, never a CLI flag.
4
- export function sshPolicyScript({ enable = false, configFile = '/etc/ssh/sshd_config', procRoot = '/proc' } = {}) {
5
- return `# claude-remote-ssh-policy
6
- set -eu
7
- sshd=$(command -v sshd || true)
8
- if [ -z "$sshd" ] && [ -x /usr/sbin/sshd ]; then sshd=/usr/sbin/sshd; fi
9
- printf 'uid=%s\\n' "$(id -u)"
10
- if [ -z "$sshd" ]; then printf 'status=unavailable\\n'; exit 0; fi
11
- set -- \${SSH_CONNECTION:-}
12
- connection="user=$(id -un)"
13
- if [ "$#" -eq 4 ]; then connection="$connection,addr=$1,host=$1,laddr=$3,lport=$4"; fi
14
- policy() { "$sshd" -T -C "$connection"; }
15
- if ! current=$(policy 2>/dev/null); then printf 'status=unavailable\\n'; exit 0; fi
16
- ${enable ? `
17
- if printf '%s\\n' "$current" | grep -qx 'pubkeyauthentication no'; then
18
- printf '%s\\n' "$current" | grep -Eq '^authenticationmethods (any|([^ ]+ )*publickey)( |$)' || {
19
- echo '服务器要求额外认证,未修改认证策略。' >&2; exit 1;
20
- }
21
- [ "$(id -u)" = 0 ] || { echo '自动开启公钥登录需要 root;请由管理员配置。' >&2; exit 1; }
22
- [ "$(uname -s)" = Linux ] || { echo '自动开启仅支持 Linux systemd 的标准 SSH 服务。' >&2; exit 1; }
23
- service=
24
- for name in ssh.service sshd.service; do
25
- if systemctl is-active --quiet "$name"; then service=$name; break; fi
26
- done
27
- [ -n "$service" ] || { echo '无法确认正在运行的 SSH 服务,未修改配置。' >&2; exit 1; }
28
- pid=$(systemctl show "$service" --property=MainPID --value)
29
- case "$pid" in ''|*[!0-9]*|0) echo '无法确认 SSH 服务进程,未修改配置。' >&2; exit 1;; esac
30
- process_file=${quote(procRoot)}/"$pid"/cmdline
31
- [ -r "$process_file" ] || { echo '无法读取 SSH 服务参数,未修改配置。' >&2; exit 1; }
32
- process_args=$(tr '\\000' ' ' < "$process_file")
33
- invocation=$(systemctl show "$service" --property=ExecStart --property=Environment)
34
- case "$invocation $process_args" in
35
- *' -f'*|*' -o'*) echo 'SSH 服务使用自定义参数,请由管理员配置;未修改配置。' >&2; exit 1;;
36
- esac
37
- file=${quote(configFile)}
38
- [ -f "$file" ] && [ ! -L "$file" ] || { echo 'SSH 配置不是普通文件,未修改配置。' >&2; exit 1; }
39
- backup=$(mktemp "$file.claude-remote.XXXXXX")
40
- cp -p "$file" "$backup"
41
- changed=0
42
- candidate=
43
- rollback() {
44
- code=$?
45
- trap - EXIT HUP INT TERM
46
- if [ -n "$candidate" ]; then rm -f "$candidate"; fi
47
- if [ "$changed" = 1 ]; then
48
- cp -p "$backup" "$file"
49
- if ! "$sshd" -t || ! systemctl reload "$service"; then
50
- echo "恢复后的 SSH 配置校验或重新加载失败,请使用备份检查:$backup" >&2
51
- fi
52
- echo "开启失败,已恢复原配置;备份:$backup" >&2
53
- fi
54
- exit "$code"
55
- }
56
- trap rollback EXIT
57
- trap 'exit 1' HUP INT TERM
58
- # First global value wins, including over later Include files. Restrictive
59
- # Match rules must pass the connection-specific check or we roll back.
60
- candidate=$(mktemp "$file.claude-remote-stage.XXXXXX")
61
- cp -p "$backup" "$candidate"
62
- { printf 'PubkeyAuthentication yes\\n'; cat "$backup"; } > "$candidate"
63
- changed=1
64
- mv -f "$candidate" "$file"
65
- "$sshd" -t
66
- current=$(policy)
67
- printf '%s\\n' "$current" | grep -qx 'pubkeyauthentication yes' || {
68
- echo '当前账号的 Match 策略仍禁止公钥登录。' >&2; exit 1;
69
- }
70
- systemctl reload "$service"
71
- changed=0
72
- trap - EXIT HUP INT TERM
73
- printf 'backup=%s\\n' "$backup"
74
- fi
75
- ` : ''}
76
- printf 'status=ok\\n'
77
- printf '%s\\n' "$current" | while IFS=' ' read -r key value; do
78
- case "$key" in pubkeyauthentication|authenticationmethods|authorizedkeysfile|permitrootlogin)
79
- printf '%s=%s\\n' "$key" "$value";;
80
- esac
81
- done
82
- `;
83
- }
84
-
85
- export function parseSSHPolicy(output) {
86
- const result = {};
87
- for (const line of output.split(/\r?\n/)) {
88
- const index = line.indexOf('=');
89
- if (index > 0) result[line.slice(0, index)] = line.slice(index + 1);
90
- }
91
- return result;
92
- }
1
+ import { quote } from './system.js';
2
+
3
+ // configFile is injectable only for isolated filesystem tests, never a CLI flag.
4
+ export function sshPolicyScript({ enable = false, configFile = '/etc/ssh/sshd_config', procRoot = '/proc' } = {}) {
5
+ return `# claude-remote-ssh-policy
6
+ set -eu
7
+ sshd=$(command -v sshd || true)
8
+ if [ -z "$sshd" ] && [ -x /usr/sbin/sshd ]; then sshd=/usr/sbin/sshd; fi
9
+ printf 'uid=%s\\n' "$(id -u)"
10
+ if [ -z "$sshd" ]; then printf 'status=unavailable\\n'; exit 0; fi
11
+ set -- \${SSH_CONNECTION:-}
12
+ connection="user=$(id -un)"
13
+ if [ "$#" -eq 4 ]; then connection="$connection,addr=$1,host=$1,laddr=$3,lport=$4"; fi
14
+ policy() { "$sshd" -T -C "$connection"; }
15
+ if ! current=$(policy 2>/dev/null); then printf 'status=unavailable\\n'; exit 0; fi
16
+ ${enable ? `
17
+ if printf '%s\\n' "$current" | grep -qx 'pubkeyauthentication no'; then
18
+ printf '%s\\n' "$current" | grep -Eq '^authenticationmethods (any|([^ ]+ )*publickey)( |$)' || {
19
+ echo '服务器要求额外认证,未修改认证策略。' >&2; exit 1;
20
+ }
21
+ [ "$(id -u)" = 0 ] || { echo '自动开启公钥登录需要 root;请由管理员配置。' >&2; exit 1; }
22
+ [ "$(uname -s)" = Linux ] || { echo '自动开启仅支持 Linux systemd 的标准 SSH 服务。' >&2; exit 1; }
23
+ service=
24
+ for name in ssh.service sshd.service; do
25
+ if systemctl is-active --quiet "$name"; then service=$name; break; fi
26
+ done
27
+ [ -n "$service" ] || { echo '无法确认正在运行的 SSH 服务,未修改配置。' >&2; exit 1; }
28
+ pid=$(systemctl show "$service" --property=MainPID --value)
29
+ case "$pid" in ''|*[!0-9]*|0) echo '无法确认 SSH 服务进程,未修改配置。' >&2; exit 1;; esac
30
+ process_file=${quote(procRoot)}/"$pid"/cmdline
31
+ [ -r "$process_file" ] || { echo '无法读取 SSH 服务参数,未修改配置。' >&2; exit 1; }
32
+ process_args=$(tr '\\000' ' ' < "$process_file")
33
+ invocation=$(systemctl show "$service" --property=ExecStart --property=Environment)
34
+ case "$invocation $process_args" in
35
+ *' -f'*|*' -o'*) echo 'SSH 服务使用自定义参数,请由管理员配置;未修改配置。' >&2; exit 1;;
36
+ esac
37
+ file=${quote(configFile)}
38
+ [ -f "$file" ] && [ ! -L "$file" ] || { echo 'SSH 配置不是普通文件,未修改配置。' >&2; exit 1; }
39
+ backup=$(mktemp "$file.claude-remote.XXXXXX")
40
+ cp -p "$file" "$backup"
41
+ changed=0
42
+ candidate=
43
+ rollback() {
44
+ code=$?
45
+ trap - EXIT HUP INT TERM
46
+ if [ -n "$candidate" ]; then rm -f "$candidate"; fi
47
+ if [ "$changed" = 1 ]; then
48
+ cp -p "$backup" "$file"
49
+ if ! "$sshd" -t || ! systemctl reload "$service"; then
50
+ echo "恢复后的 SSH 配置校验或重新加载失败,请使用备份检查:$backup" >&2
51
+ fi
52
+ echo "开启失败,已恢复原配置;备份:$backup" >&2
53
+ fi
54
+ exit "$code"
55
+ }
56
+ trap rollback EXIT
57
+ trap 'exit 1' HUP INT TERM
58
+ # First global value wins, including over later Include files. Restrictive
59
+ # Match rules must pass the connection-specific check or we roll back.
60
+ candidate=$(mktemp "$file.claude-remote-stage.XXXXXX")
61
+ cp -p "$backup" "$candidate"
62
+ { printf 'PubkeyAuthentication yes\\n'; cat "$backup"; } > "$candidate"
63
+ changed=1
64
+ mv -f "$candidate" "$file"
65
+ "$sshd" -t
66
+ current=$(policy)
67
+ printf '%s\\n' "$current" | grep -qx 'pubkeyauthentication yes' || {
68
+ echo '当前账号的 Match 策略仍禁止公钥登录。' >&2; exit 1;
69
+ }
70
+ systemctl reload "$service"
71
+ changed=0
72
+ trap - EXIT HUP INT TERM
73
+ printf 'backup=%s\\n' "$backup"
74
+ fi
75
+ ` : ''}
76
+ printf 'status=ok\\n'
77
+ printf '%s\\n' "$current" | while IFS=' ' read -r key value; do
78
+ case "$key" in pubkeyauthentication|authenticationmethods|authorizedkeysfile|permitrootlogin)
79
+ printf '%s=%s\\n' "$key" "$value";;
80
+ esac
81
+ done
82
+ `;
83
+ }
84
+
85
+ export function parseSSHPolicy(output) {
86
+ const result = {};
87
+ for (const line of output.split(/\r?\n/)) {
88
+ const index = line.indexOf('=');
89
+ if (index > 0) result[line.slice(0, index)] = line.slice(index + 1);
90
+ }
91
+ return result;
92
+ }
package/src/ssh.js CHANGED
@@ -1,16 +1,16 @@
1
- import { quote } from './system.js';
2
-
3
- export function sshBase(config) {
4
- const args = ['-o', 'ConnectTimeout=15', '-o', 'ServerAliveInterval=15', '-o', 'ServerAliveCountMax=3'];
5
- if (config.port != null) args.push('-p', String(config.port));
6
- return args;
7
- }
8
-
9
- export function sshArgs(config, script, { interactive = false, tty = false, input = false, controlPath } = {}) {
10
- const args = sshBase(config);
11
- if (!interactive && !input) args.push('-n');
12
- if (tty) args.push('-tt');
13
- if (controlPath) args.push('-S', controlPath, '-o', 'ControlMaster=no');
14
- args.push(config.host, 'bash -lc ' + quote(script));
15
- return args;
16
- }
1
+ import { quote } from './system.js';
2
+
3
+ export function sshBase(config) {
4
+ const args = ['-o', 'ConnectTimeout=15', '-o', 'ServerAliveInterval=15', '-o', 'ServerAliveCountMax=3'];
5
+ if (config.port != null) args.push('-p', String(config.port));
6
+ return args;
7
+ }
8
+
9
+ export function sshArgs(config, script, { interactive = false, tty = false, input = false, controlPath } = {}) {
10
+ const args = sshBase(config);
11
+ if (!interactive && !input) args.push('-n');
12
+ if (tty) args.push('-tt');
13
+ if (controlPath) args.push('-S', controlPath, '-o', 'ControlMaster=no');
14
+ args.push(config.host, 'bash -lc ' + quote(script));
15
+ return args;
16
+ }