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.
Files changed (5) hide show
  1. package/README.md +133 -113
  2. package/README.zh.md +134 -114
  3. package/lib/client.js +474 -260
  4. package/lib/index.js +124 -12
  5. 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
- /** Absolute private-key path; empty → ~/.ssh/id_rsa. */
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
- const h = createHash('sha1').update(`${host}:${port}:${remotePath}`).digest('hex').slice(0, 8)
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 path.join(homedir(), '.ssh', 'id_rsa')
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(this.resolveKeyPath())
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 run(`ls -la --color=never ${shq(p || '/')}`)
779
- return sendJson(res, 200, { path: p, text: out })
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.8",
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"