dsh-remote 0.5.8 → 0.5.10
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 +133 -113
- package/README.zh.md +134 -114
- package/lib/client.js +474 -260
- package/lib/index.js +124 -12
- package/package.json +11 -2
package/lib/index.js
CHANGED
|
@@ -20,7 +20,6 @@ import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
|
20
20
|
import ssh2 from 'ssh2'
|
|
21
21
|
import { readFileSync, mkdirSync, writeFileSync, existsSync, readdirSync, statSync } from 'node:fs'
|
|
22
22
|
import { homedir } from 'node:os'
|
|
23
|
-
import { createHash } from 'node:crypto'
|
|
24
23
|
import path from 'node:path'
|
|
25
24
|
|
|
26
25
|
const { Client } = ssh2
|
|
@@ -42,7 +41,7 @@ export const Config = z.object({
|
|
|
42
41
|
username: z.string().default(''),
|
|
43
42
|
/** Password login (only when the remote has no key. Override the fallback below). */
|
|
44
43
|
password: z.string().default(''),
|
|
45
|
-
/**
|
|
44
|
+
/** Explicit SSH private-key path (optional; only used when supplied). Never auto-reads ~/.ssh. */
|
|
46
45
|
privateKeyPath: z.string().default(''),
|
|
47
46
|
/** Key passphrase when the key is encrypted. */
|
|
48
47
|
passphrase: z.string().default(''),
|
|
@@ -107,11 +106,12 @@ function mirrorRootFor(host, user, port) {
|
|
|
107
106
|
return path.join(homedir(), '.dsh', 'remote-workspaces', tag)
|
|
108
107
|
}
|
|
109
108
|
|
|
110
|
-
/** Local mirror dir for a specific remote path (idempotent → returns same dir).
|
|
109
|
+
/** Local mirror dir for a specific remote path (idempotent → returns same dir).
|
|
110
|
+
* Named after the remote directory's basename so the harness workspace label
|
|
111
|
+
* reads cleanly (e.g. .../project) instead of a concatenated hash string. */
|
|
111
112
|
function mirrorDirFor(remotePath, host, user, port) {
|
|
112
113
|
const base = remotePathBase(remotePath)
|
|
113
|
-
|
|
114
|
-
return path.join(mirrorRootFor(host, user, port), `${base}-${h}`)
|
|
114
|
+
return path.join(mirrorRootFor(host, user, port), base)
|
|
115
115
|
}
|
|
116
116
|
|
|
117
117
|
/** Create the local mirror dir + a meta file describing its remote origin. */
|
|
@@ -236,7 +236,7 @@ class SshPool {
|
|
|
236
236
|
|
|
237
237
|
resolveKeyPath() {
|
|
238
238
|
const p = this.config.privateKeyPath
|
|
239
|
-
if (!p) return
|
|
239
|
+
if (!p) return ''
|
|
240
240
|
if (p.startsWith('~/') || p === '~') return path.join(homedir(), p.slice(1))
|
|
241
241
|
return p
|
|
242
242
|
}
|
|
@@ -296,13 +296,15 @@ class SshPool {
|
|
|
296
296
|
if (this.config.password) {
|
|
297
297
|
opts.password = this.config.password
|
|
298
298
|
} else {
|
|
299
|
+
const keyPath = this.resolveKeyPath()
|
|
300
|
+
if (!keyPath) {
|
|
301
|
+
return fail(new Error('no credentials: set a password or a privateKeyPath to connect'))
|
|
302
|
+
}
|
|
299
303
|
let key
|
|
300
304
|
try {
|
|
301
|
-
key = readFileSync(
|
|
305
|
+
key = readFileSync(keyPath)
|
|
302
306
|
} catch (err) {
|
|
303
|
-
return fail(
|
|
304
|
-
new Error(`cannot read private key "${this.resolveKeyPath()}": ${err && err.message}`),
|
|
305
|
-
)
|
|
307
|
+
return fail(new Error(`cannot read private key "${keyPath}": ${err && err.message}`))
|
|
306
308
|
}
|
|
307
309
|
opts.privateKey = key
|
|
308
310
|
opts.passphrase = this.config.passphrase || undefined
|
|
@@ -443,6 +445,31 @@ export async function apply(ctx, config) {
|
|
|
443
445
|
return text
|
|
444
446
|
}
|
|
445
447
|
|
|
448
|
+
/** Structured-listing of a remote dir: name + usable type. `dir` is decided by
|
|
449
|
+
* `[ -d ]`, which FOLLOWS symlinks — so a symlink to a directory is enterable
|
|
450
|
+
* (the picker can drill into it), while a file symlink stays a file. */
|
|
451
|
+
const listDirStructured = async (p, timeoutMs) => {
|
|
452
|
+
const target = p || '/'
|
|
453
|
+
const cmd =
|
|
454
|
+
`cd ${shq(target)} 2>/dev/null && for f in .[!.]* *; do ` +
|
|
455
|
+
`[ -e "$f" ] || [ -L "$f" ] || continue; ` +
|
|
456
|
+
`if [ -d "$f" ]; then printf 'd\\t%s\\n' "$f"; else printf 'f\\t%s\\n' "$f"; fi; done`
|
|
457
|
+
const res = await pool.exec(cmd, timeoutMs || config.commandTimeoutMs)
|
|
458
|
+
const items = []
|
|
459
|
+
if (res.code !== 0 && res.stderr) {
|
|
460
|
+
throw new Error('ls failed: ' + (res.stderr || '').trim())
|
|
461
|
+
}
|
|
462
|
+
for (const line of String(res.stdout || '').split('\n')) {
|
|
463
|
+
const idx = line.indexOf('\t')
|
|
464
|
+
if (idx < 0) continue
|
|
465
|
+
const type = line.slice(0, idx)
|
|
466
|
+
const name = line.slice(idx + 1)
|
|
467
|
+
if (name === '.' || name === '..' || !name) continue
|
|
468
|
+
items.push({ type: type === 'd' ? 'dir' : 'file', name })
|
|
469
|
+
}
|
|
470
|
+
return { path: target, items }
|
|
471
|
+
}
|
|
472
|
+
|
|
446
473
|
// ── remote workspace state ────────────────────────────────────────────────
|
|
447
474
|
|
|
448
475
|
const wsPath = () => (config.workspace || '').trim()
|
|
@@ -680,6 +707,63 @@ export async function apply(ctx, config) {
|
|
|
680
707
|
return { text: await run(cmd, { timeoutMs: config.commandTimeoutMs }) }
|
|
681
708
|
},
|
|
682
709
|
}),
|
|
710
|
+
|
|
711
|
+
defineTool({
|
|
712
|
+
name: 'rw_write_file',
|
|
713
|
+
description:
|
|
714
|
+
'Write text to a file on the remote host (creating parent directories if needed). Path is absolute. Use this to create or overwrite a remote file directly, instead of round-tripping through a local mirror.',
|
|
715
|
+
parameters: {
|
|
716
|
+
path: { type: 'string', required: true, description: 'Absolute remote file path' },
|
|
717
|
+
content: { type: 'string', required: true, description: 'File content to write (overwrites existing file)' },
|
|
718
|
+
mkdir: { type: 'boolean', description: 'Create missing parent directories (default true)' },
|
|
719
|
+
},
|
|
720
|
+
output: {
|
|
721
|
+
schema: { type: 'object', additionalProperties: false, properties: { ok: { type: 'boolean', required: true }, bytes: { type: 'integer' }, text: { type: 'string' } } },
|
|
722
|
+
render: (_a, a) => [{ type: 'text', text: a.text || (a.ok ? 'written' : 'failed') }],
|
|
723
|
+
},
|
|
724
|
+
async execute(args) {
|
|
725
|
+
const p = normalizeRemotePath(String(args.path || ''))
|
|
726
|
+
if (!p || p === '/') throw new Error('rw_write_file: a file path is required')
|
|
727
|
+
const content = String(args.content == null ? '' : args.content)
|
|
728
|
+
let sftp
|
|
729
|
+
try {
|
|
730
|
+
sftp = await pool.sftp()
|
|
731
|
+
} catch (err) {
|
|
732
|
+
throw new Error('rw_write_file: sftp unavailable: ' + ((err && err.message) || err))
|
|
733
|
+
}
|
|
734
|
+
const mkdir = args.mkdir !== false
|
|
735
|
+
if (mkdir) {
|
|
736
|
+
const parent = remoteDirname(p)
|
|
737
|
+
// create each level from the root so mkdir -p semantics survive even
|
|
738
|
+
// when intermediate privileged-parent dirs don't allow create (best effort)
|
|
739
|
+
const segs = parent.split('/').filter(Boolean)
|
|
740
|
+
let cur = ''
|
|
741
|
+
for (const s of segs) {
|
|
742
|
+
cur += '/' + s
|
|
743
|
+
try { await sftp.mkdir(cur) } catch { /* exists or no perms */ }
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
const buf = Buffer.from(content, 'utf8')
|
|
747
|
+
await sftp.writeFile(p, buf)
|
|
748
|
+
const bytes = Buffer.byteLength(content, 'utf8')
|
|
749
|
+
return { ok: true, bytes, text: `wrote ${bytes} bytes to ${p}` }
|
|
750
|
+
},
|
|
751
|
+
}),
|
|
752
|
+
|
|
753
|
+
defineTool({
|
|
754
|
+
name: 'rw_disconnect',
|
|
755
|
+
description:
|
|
756
|
+
'Close the current SSH connection to the remote host, releasing the persistent pool. Useful to rotate connections or after a long idle.',
|
|
757
|
+
parameters: {},
|
|
758
|
+
output: {
|
|
759
|
+
schema: { type: 'object', additionalProperties: false, properties: { ok: { type: 'boolean', required: true }, text: { type: 'string' } } },
|
|
760
|
+
render: (_a, a) => [{ type: 'text', text: a.text }],
|
|
761
|
+
},
|
|
762
|
+
async execute() {
|
|
763
|
+
pool.close()
|
|
764
|
+
return { ok: true, text: 'disconnected' }
|
|
765
|
+
},
|
|
766
|
+
}),
|
|
683
767
|
]
|
|
684
768
|
|
|
685
769
|
for (const t of tools) {
|
|
@@ -775,8 +859,8 @@ export async function apply(ctx, config) {
|
|
|
775
859
|
try {
|
|
776
860
|
const m = (req.url || '').match(/path=([^&]*)/)
|
|
777
861
|
const p = m ? decodeURIComponent(m[1]) : wsPath()
|
|
778
|
-
const out = await
|
|
779
|
-
return sendJson(res, 200, { path: p,
|
|
862
|
+
const out = await listDirStructured(p || '/')
|
|
863
|
+
return sendJson(res, 200, { path: p, items: out.items })
|
|
780
864
|
} catch (err) {
|
|
781
865
|
return sendJson(res, 500, { error: String((err && err.message) || err) })
|
|
782
866
|
}
|
|
@@ -893,6 +977,34 @@ export async function apply(ctx, config) {
|
|
|
893
977
|
return sendJson(res, 405, { ok: false, error: 'method not allowed' })
|
|
894
978
|
},
|
|
895
979
|
},
|
|
980
|
+
{
|
|
981
|
+
kind: 'exact',
|
|
982
|
+
path: '/dsh-remote/test-connect',
|
|
983
|
+
handler: async (req, res) => {
|
|
984
|
+
if (req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'method not allowed' })
|
|
985
|
+
try {
|
|
986
|
+
const body = JSON.parse((await readBody(req)) || '{}')
|
|
987
|
+
const probe = new SshPool({
|
|
988
|
+
...config,
|
|
989
|
+
host: String(body.host || config.host),
|
|
990
|
+
port: Number(body.port) || config.port,
|
|
991
|
+
username: String(body.username || config.username),
|
|
992
|
+
password: String(body.password || ''),
|
|
993
|
+
privateKeyPath: String(body.privateKeyPath || config.privateKeyPath),
|
|
994
|
+
passphrase: String(body.passphrase || ''),
|
|
995
|
+
connectTimeoutMs: Math.min(Math.max(Number(body.connectTimeoutMs) || config.connectTimeoutMs, 2000), 30000),
|
|
996
|
+
commandTimeoutMs: 10000,
|
|
997
|
+
})
|
|
998
|
+
const started = Date.now()
|
|
999
|
+
await probe.connect()
|
|
1000
|
+
await probe.exec('true', 10000)
|
|
1001
|
+
probe.close()
|
|
1002
|
+
return sendJson(res, 200, { ok: true, host: probe.config.host, user: probe.config.username, latencyMs: Date.now() - started })
|
|
1003
|
+
} catch (err) {
|
|
1004
|
+
return sendJson(res, 200, { ok: false, error: String((err && err.message) || err) })
|
|
1005
|
+
}
|
|
1006
|
+
},
|
|
1007
|
+
},
|
|
896
1008
|
{
|
|
897
1009
|
kind: 'exact',
|
|
898
1010
|
path: '/dsh-remote/current',
|
package/package.json
CHANGED
|
@@ -1,7 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-remote",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.10",
|
|
4
4
|
"description": "Remote-work assistant for DeepSeek Harness: connect SSH (password or key), pick a remote workspace, operate on it with rw_pick_workspace / rw_list_dir / rw_read_file / rw_exec / rw_sync tools, and mirror it to a real local directory (SFTP) so the DSH native workspace can adopt it.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "flymysql <flyphp@outlook.com>",
|
|
7
|
+
"homepage": "https://github.com/flymysql/dsh-remote",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/flymysql/dsh-remote.git"
|
|
11
|
+
},
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/flymysql/dsh-remote/issues"
|
|
14
|
+
},
|
|
5
15
|
"keywords": [
|
|
6
16
|
"deepseek-harness",
|
|
7
17
|
"dsh",
|
|
@@ -23,7 +33,6 @@
|
|
|
23
33
|
"lib",
|
|
24
34
|
"cordis.patch.yml"
|
|
25
35
|
],
|
|
26
|
-
"license": "MIT",
|
|
27
36
|
"dsh": {
|
|
28
37
|
"bundle": {
|
|
29
38
|
"patch": "./cordis.patch.yml"
|