dsh-ssh-tunnel 1.0.0
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/CHANGELOG.md +305 -0
- package/LICENSE +21 -0
- package/README.md +173 -0
- package/README_en.md +171 -0
- package/cordis.patch.yml +4 -0
- package/lib/client.js +1472 -0
- package/lib/index.js +1611 -0
- package/lib/session.js +525 -0
- package/lib/shared/host-key.js +32 -0
- package/lib/shared/host-summary.js +75 -0
- package/lib/shared/http-trust.js +59 -0
- package/lib/shared/path.js +156 -0
- package/lib/shared/persist.js +41 -0
- package/lib/shared/session-auth.js +39 -0
- package/lib/shared/session-policy.js +125 -0
- package/lib/shared/shell-buffer.js +10 -0
- package/lib/shared/vendor.js +50 -0
- package/package.json +84 -0
- package/scripts/install.ps1 +148 -0
- package/scripts/install.sh +226 -0
- package/scripts/portal-probe.mjs +106 -0
- package/scripts/smoke-test.mjs +511 -0
- package/scripts/sync-to-dsh.sh +32 -0
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { lstatSync, realpathSync } from 'node:fs'
|
|
2
|
+
import { dirname, isAbsolute, join, normalize, resolve as pathResolve } from 'node:path'
|
|
3
|
+
|
|
4
|
+
export const WORKSPACE_ROOT = '/workspace'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Local roots the path guard accepts, most specific first.
|
|
8
|
+
*
|
|
9
|
+
* The project workspace the user actually opened (`explicitRoot`) is the real
|
|
10
|
+
* boundary: on hosts whose workspace is a Windows drive path there is no
|
|
11
|
+
* `/workspace` at all, and a fixed root rejects every real local path — which
|
|
12
|
+
* breaks SFTP upload/download and the sidebar file browser. Relative local
|
|
13
|
+
* paths resolve against the first root, so an explicit project root wins over
|
|
14
|
+
* the `DSH_SSH_TUNNEL_WORKSPACE_ROOT` operator override and over the
|
|
15
|
+
* `/workspace` fallback.
|
|
16
|
+
*
|
|
17
|
+
* Read at call time so hosts and tests can set them after module load.
|
|
18
|
+
*/
|
|
19
|
+
export function workspaceRoots(explicitRoot = '') {
|
|
20
|
+
const raw = []
|
|
21
|
+
if (explicitRoot && String(explicitRoot).trim()) raw.push(String(explicitRoot).trim())
|
|
22
|
+
const env = process.env.DSH_SSH_TUNNEL_WORKSPACE_ROOT
|
|
23
|
+
if (env && String(env).trim()) raw.push(String(env).trim())
|
|
24
|
+
if (!raw.length) raw.push(WORKSPACE_ROOT)
|
|
25
|
+
const roots = []
|
|
26
|
+
for (const r of raw) {
|
|
27
|
+
const key = normalizeProjectKey(r)
|
|
28
|
+
if (key && !roots.includes(key)) roots.push(key)
|
|
29
|
+
}
|
|
30
|
+
return roots
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Primary local root: the base for resolving relative local paths. */
|
|
34
|
+
export function workspaceRoot(explicitRoot = '') {
|
|
35
|
+
return workspaceRoots(explicitRoot)[0]
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Normalize a project path key (absolute, no trailing slash except root). */
|
|
39
|
+
export function normalizeProjectKey(raw) {
|
|
40
|
+
if (!raw || typeof raw !== 'string') return ''
|
|
41
|
+
let p = raw.trim()
|
|
42
|
+
if (!p) return ''
|
|
43
|
+
try {
|
|
44
|
+
p = pathResolve(p)
|
|
45
|
+
} catch {
|
|
46
|
+
return ''
|
|
47
|
+
}
|
|
48
|
+
p = normalize(p)
|
|
49
|
+
while (p.length > 1 && (p.endsWith('/') || p.endsWith('\\'))) {
|
|
50
|
+
const next = p.slice(0, -1)
|
|
51
|
+
if (/^[A-Za-z]:$/.test(next)) break
|
|
52
|
+
p = next
|
|
53
|
+
}
|
|
54
|
+
return p
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Comparison form for containment checks: unified separators, and on Windows
|
|
59
|
+
* case-insensitive (Win32 paths are case-insensitive; keys are stored in
|
|
60
|
+
* platform-native form, so string equality alone would misjudge containment).
|
|
61
|
+
*/
|
|
62
|
+
function comparablePath(p) {
|
|
63
|
+
const s = p.replace(/\\/g, '/')
|
|
64
|
+
return process.platform === 'win32' ? s.toLowerCase() : s
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Whether abs is inside one of the allowed roots (lexical, after resolve).
|
|
69
|
+
* Does not follow symlinks — use constrainToWorkspace for FS operations.
|
|
70
|
+
*/
|
|
71
|
+
export function isPathInsideRoots(absPath, roots) {
|
|
72
|
+
const abs = normalizeProjectKey(absPath)
|
|
73
|
+
if (!abs) return false
|
|
74
|
+
const list = (roots || []).map(normalizeProjectKey).filter(Boolean)
|
|
75
|
+
const cAbs = comparablePath(abs)
|
|
76
|
+
for (const root of list) {
|
|
77
|
+
const cRoot = comparablePath(root)
|
|
78
|
+
if (cAbs === cRoot) return true
|
|
79
|
+
// Roots may end with '/' in comparison form ('/', or a drive root like
|
|
80
|
+
// 'c:/'): strip trailing separators before appending one, so filesystem
|
|
81
|
+
// roots still match their children.
|
|
82
|
+
const base = cRoot.replace(/\/+$/, '')
|
|
83
|
+
const prefix = base === '' ? '/' : base + '/'
|
|
84
|
+
if (cAbs.startsWith(prefix)) return true
|
|
85
|
+
}
|
|
86
|
+
return false
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function realExisting(abs) {
|
|
90
|
+
let probe = abs
|
|
91
|
+
while (true) {
|
|
92
|
+
try {
|
|
93
|
+
return realpathSync(probe)
|
|
94
|
+
} catch (e) {
|
|
95
|
+
if (e && e.code === 'ENOENT') {
|
|
96
|
+
const parent = dirname(probe)
|
|
97
|
+
if (!parent || parent === probe) return ''
|
|
98
|
+
probe = parent
|
|
99
|
+
continue
|
|
100
|
+
}
|
|
101
|
+
throw e
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Resolve a user-supplied local path and reject anything that is not under one
|
|
108
|
+
* of the allowed local roots (`workspaceRoots(root)`, i.e. the project
|
|
109
|
+
* workspace the caller passes plus any operator override) both lexically and
|
|
110
|
+
* after following existing symlinks.
|
|
111
|
+
*
|
|
112
|
+
* `{ forUnlink: true }` allows deleting a symlink whose *lexical* path is
|
|
113
|
+
* under an allowed root without following it (unlink the link, never the
|
|
114
|
+
* target).
|
|
115
|
+
*/
|
|
116
|
+
export function constrainToWorkspace(inputPath, { forUnlink = false, root = '' } = {}) {
|
|
117
|
+
if (inputPath == null || String(inputPath).trim() === '') {
|
|
118
|
+
throw new Error('local path required')
|
|
119
|
+
}
|
|
120
|
+
const roots = workspaceRoots(root)
|
|
121
|
+
let abs = String(inputPath)
|
|
122
|
+
if (!isAbsolute(abs)) abs = join(roots[0], abs)
|
|
123
|
+
abs = pathResolve(abs)
|
|
124
|
+
abs = normalize(abs)
|
|
125
|
+
if (abs.length > 1 && (abs.endsWith('/') || abs.endsWith('\\'))) abs = abs.slice(0, -1)
|
|
126
|
+
if (!isPathInsideRoots(abs, roots)) {
|
|
127
|
+
throw new Error('local path must be under ' + roots.join(' or '))
|
|
128
|
+
}
|
|
129
|
+
if (forUnlink) {
|
|
130
|
+
try {
|
|
131
|
+
const st = lstatSync(abs)
|
|
132
|
+
if (st.isSymbolicLink()) return abs
|
|
133
|
+
} catch (e) {
|
|
134
|
+
if (!(e && e.code === 'ENOENT')) throw e
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
const real = realExisting(abs)
|
|
138
|
+
if (real && !isPathInsideRoots(real, roots)) {
|
|
139
|
+
throw new Error('local path escapes ' + roots.join(' or ') + ' via symlink')
|
|
140
|
+
}
|
|
141
|
+
return abs
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function joinUnderRoot(root, relOrAbs) {
|
|
145
|
+
const roots = workspaceRoots()
|
|
146
|
+
const r = normalizeProjectKey(root) || roots[0]
|
|
147
|
+
let abs = String(relOrAbs || r)
|
|
148
|
+
if (!isAbsolute(abs)) abs = join(r, abs)
|
|
149
|
+
abs = pathResolve(abs)
|
|
150
|
+
if (!isPathInsideRoots(abs, [...roots, r])) {
|
|
151
|
+
throw new Error('path outside allowed roots')
|
|
152
|
+
}
|
|
153
|
+
return constrainToWorkspace(abs)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export { isAbsolute, join, normalize, pathResolve }
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { chmodSync, existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { dirname, join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Missing file → fallback. Corrupt JSON → throw (never treat as empty store).
|
|
6
|
+
*/
|
|
7
|
+
export function readJsonFile(path, fallback) {
|
|
8
|
+
if (!existsSync(path)) return fallback
|
|
9
|
+
let raw
|
|
10
|
+
try {
|
|
11
|
+
raw = readFileSync(path, 'utf8')
|
|
12
|
+
} catch (e) {
|
|
13
|
+
throw new Error('cannot read ' + path + ': ' + (e && e.message ? e.message : e))
|
|
14
|
+
}
|
|
15
|
+
try {
|
|
16
|
+
return JSON.parse(raw)
|
|
17
|
+
} catch {
|
|
18
|
+
throw new Error('corrupt json: ' + path)
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function writeJsonAtomic(path, data, mode = 0o600) {
|
|
23
|
+
const tmp = path + '.tmp'
|
|
24
|
+
writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', { mode })
|
|
25
|
+
try { chmodSync(tmp, mode) } catch {}
|
|
26
|
+
renameSync(tmp, path)
|
|
27
|
+
try { chmodSync(path, mode) } catch {}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function migrateMarkerPath(dataDir) {
|
|
31
|
+
return join(dataDir, '.migrated-draft')
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function hostEndpointKey(host, port, username) {
|
|
35
|
+
return `${String(username || '').trim()}@${String(host || '').trim()}:${Number(port) || 22}`
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function findExistingHostByEndpoint(hosts, host, port, username) {
|
|
39
|
+
const key = hostEndpointKey(host, port, username)
|
|
40
|
+
return (hosts || []).find((h) => hostEndpointKey(h.host, h.port, h.username) === key)
|
|
41
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Result-delivery authorization gates for SSH session operations (security).
|
|
3
|
+
* Pure helpers kept in shared/ so they are testable without an ssh2 install.
|
|
4
|
+
*
|
|
5
|
+
* Background (v0.4.3): a host grant can be revoked while an SSHManager
|
|
6
|
+
* operation is already in flight. Closing the session stops the transport,
|
|
7
|
+
* but a command can complete and a file can be pulled in the same tick as the
|
|
8
|
+
* revocation. These gates are the *second* boundary: even if the transport
|
|
9
|
+
* work finished, nothing from the revoked host may be delivered to the agent
|
|
10
|
+
* (and no downloaded file may remain on disk).
|
|
11
|
+
*
|
|
12
|
+
* v0.4.4: missing predicate is fail-closed (never a silent no-op). HTTP and
|
|
13
|
+
* write actions re-check the same gate.
|
|
14
|
+
*/
|
|
15
|
+
import { unlinkSync } from 'node:fs'
|
|
16
|
+
|
|
17
|
+
export function assertSessionStillAuthorized(session, isHostAuthorized) {
|
|
18
|
+
if (!session) throw new Error('SSH session missing')
|
|
19
|
+
if (typeof isHostAuthorized !== 'function') {
|
|
20
|
+
throw new Error('SSH authorization predicate required')
|
|
21
|
+
}
|
|
22
|
+
if (session.projectPathKey && !isHostAuthorized(session.projectPathKey, session.hostId)) {
|
|
23
|
+
throw new Error('SSH session host is not authorized for the current project.')
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function discardRevokedLocalFile(session, localPath, isHostAuthorized) {
|
|
28
|
+
if (!session) return false
|
|
29
|
+
if (typeof isHostAuthorized !== 'function') {
|
|
30
|
+
throw new Error('SSH authorization predicate required')
|
|
31
|
+
}
|
|
32
|
+
if (session.projectPathKey && !isHostAuthorized(session.projectPathKey, session.hostId)) {
|
|
33
|
+
try {
|
|
34
|
+
unlinkSync(localPath)
|
|
35
|
+
} catch {}
|
|
36
|
+
return true
|
|
37
|
+
}
|
|
38
|
+
return false
|
|
39
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure SSH session policy: timeouts, status invariant, reuse vs tombstone.
|
|
3
|
+
* No ssh2, no I/O.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export const SSH_KEEPALIVE_INTERVAL_MS = 30_000
|
|
7
|
+
export const SSH_KEEPALIVE_COUNT_MAX = 3
|
|
8
|
+
export const SSH_READY_TIMEOUT_MS = 20_000
|
|
9
|
+
export const SSH_RECONNECT_MAX_ATTEMPTS = 3
|
|
10
|
+
export const SSH_RECONNECT_DELAYS_MS = [5_000, 10_000, 10_000]
|
|
11
|
+
export const SSH_RECONNECT_ATTEMPT_TIMEOUT_MS = 20_000
|
|
12
|
+
export const SSH_TOOL_TIMEOUT_MS = 300_000
|
|
13
|
+
export const SSH_TIMEOUT_MIN_MS = 1_000
|
|
14
|
+
export const SSH_TIMEOUT_MAX_MS = 300_000
|
|
15
|
+
export const SSH_EXEC_DEFAULT_TIMEOUT_MS = 30_000
|
|
16
|
+
export const SSH_SFTP_META_TIMEOUT_MS = 30_000
|
|
17
|
+
export const SSH_SFTP_TRANSFER_TIMEOUT_MS = 120_000
|
|
18
|
+
export const SSH_SHELL_OPEN_TIMEOUT_MS = 10_000
|
|
19
|
+
|
|
20
|
+
export const SSH_STATUS_CONNECTED = 'connected'
|
|
21
|
+
export const SSH_STATUS_RECONNECTING = 'reconnecting'
|
|
22
|
+
export const SSH_STATUS_DISCONNECTED = 'disconnected'
|
|
23
|
+
|
|
24
|
+
export function clampTimeoutMs(value, fallback) {
|
|
25
|
+
const n = Number(value)
|
|
26
|
+
const base = Number.isFinite(n) && n > 0 ? n : fallback
|
|
27
|
+
return Math.min(SSH_TIMEOUT_MAX_MS, Math.max(SSH_TIMEOUT_MIN_MS, Math.floor(base)))
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function defaultTimeoutMsForAction(action) {
|
|
31
|
+
const a = String(action || '')
|
|
32
|
+
if (a === 'exec') return SSH_EXEC_DEFAULT_TIMEOUT_MS
|
|
33
|
+
if (a === 'sftp_read_text' || a === 'sftp_write_text' || a === 'sftp_upload' || a === 'sftp_download') {
|
|
34
|
+
return SSH_SFTP_TRANSFER_TIMEOUT_MS
|
|
35
|
+
}
|
|
36
|
+
if (a.startsWith('sftp_')) return SSH_SFTP_META_TIMEOUT_MS
|
|
37
|
+
if (a === 'read_session' || a === 'send_input' || a === 'resize_session') return SSH_SHELL_OPEN_TIMEOUT_MS
|
|
38
|
+
return SSH_EXEC_DEFAULT_TIMEOUT_MS
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function isLiveStatus(status, running) {
|
|
42
|
+
return status === SSH_STATUS_CONNECTED && running === true
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function assertStatusInvariant(rec) {
|
|
46
|
+
const running = rec.status === SSH_STATUS_CONNECTED
|
|
47
|
+
return { ...rec, running }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function findReusableLive(sessions, { projectPathKey, hostId, needsSftp }) {
|
|
51
|
+
return sessions
|
|
52
|
+
.filter((s) => {
|
|
53
|
+
if (s.projectPathKey !== projectPathKey || s.hostId !== hostId) return false
|
|
54
|
+
if (!isLiveStatus(s.status, s.running)) return false
|
|
55
|
+
if (needsSftp && !s.sftpEnabled) return false
|
|
56
|
+
return true
|
|
57
|
+
})
|
|
58
|
+
.sort((a, b) => a.createdAt - b.createdAt || String(a.id).localeCompare(String(b.id)))[0]
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function pickOldestTombstone(sessions, { projectPathKey, hostId, needsSftp }) {
|
|
62
|
+
return sessions
|
|
63
|
+
.filter((s) => {
|
|
64
|
+
if (s.projectPathKey !== projectPathKey || s.hostId !== hostId) return false
|
|
65
|
+
if (s.status !== SSH_STATUS_DISCONNECTED || s.running) return false
|
|
66
|
+
if (needsSftp && !s.sftpEnabled) return false
|
|
67
|
+
return true
|
|
68
|
+
})
|
|
69
|
+
.sort((a, b) => a.createdAt - b.createdAt || String(a.id).localeCompare(String(b.id)))[0]
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function disconnectedSessionError() {
|
|
73
|
+
return 'SSH session is disconnected. Reconnect in the SSH Tunnel tab, or omit session_id to reuse_or_create.'
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function kbiReconnectError() {
|
|
77
|
+
return 'keyboard-interactive auth is not supported; edit the host to password or private key.'
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function kbiUnsupportedError() {
|
|
81
|
+
return 'keyboard-interactive auth is not supported; edit the host to password or private key.'
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function withTimeout(work, ms, { signal, onTimeout } = {}) {
|
|
85
|
+
const timeoutMs = clampTimeoutMs(ms, SSH_EXEC_DEFAULT_TIMEOUT_MS)
|
|
86
|
+
const run = typeof work === 'function' ? work() : work
|
|
87
|
+
return new Promise((resolve, reject) => {
|
|
88
|
+
let settled = false
|
|
89
|
+
const finish = (kind, value) => {
|
|
90
|
+
if (settled) return
|
|
91
|
+
settled = true
|
|
92
|
+
clearTimeout(timer)
|
|
93
|
+
if (signal) signal.removeEventListener('abort', onAbort)
|
|
94
|
+
if (kind === 'ok') resolve(value)
|
|
95
|
+
else reject(value)
|
|
96
|
+
}
|
|
97
|
+
const onAbort = () => {
|
|
98
|
+
try {
|
|
99
|
+
onTimeout?.()
|
|
100
|
+
} catch {}
|
|
101
|
+
const err = new Error('Cancelled')
|
|
102
|
+
err.aborted = true
|
|
103
|
+
finish('err', err)
|
|
104
|
+
}
|
|
105
|
+
const timer = setTimeout(() => {
|
|
106
|
+
try {
|
|
107
|
+
onTimeout?.()
|
|
108
|
+
} catch {}
|
|
109
|
+
const err = new Error('timeout')
|
|
110
|
+
err.timedOut = true
|
|
111
|
+
finish('err', err)
|
|
112
|
+
}, timeoutMs)
|
|
113
|
+
if (signal) {
|
|
114
|
+
if (signal.aborted) {
|
|
115
|
+
onAbort()
|
|
116
|
+
return
|
|
117
|
+
}
|
|
118
|
+
signal.addEventListener('abort', onAbort, { once: true })
|
|
119
|
+
}
|
|
120
|
+
Promise.resolve(run).then(
|
|
121
|
+
(v) => finish('ok', v),
|
|
122
|
+
(e) => finish('err', e),
|
|
123
|
+
)
|
|
124
|
+
})
|
|
125
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Incremental shell output slice for polling clients. */
|
|
2
|
+
export function chunkFromBuffer(fullText, since) {
|
|
3
|
+
const full = fullText == null ? '' : String(fullText)
|
|
4
|
+
const start = Math.max(0, Math.min(Number(since) || 0, full.length))
|
|
5
|
+
return {
|
|
6
|
+
chunk: full.slice(start),
|
|
7
|
+
length: full.length,
|
|
8
|
+
since: start,
|
|
9
|
+
}
|
|
10
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Same-origin static assets for the browser client.
|
|
3
|
+
*
|
|
4
|
+
* `dsh-ssh-tunnel/lib/client.js` is a raw browser bundle executed by the DSH
|
|
5
|
+
* client module system; its `require()` only answers platform seeds and
|
|
6
|
+
* registered plugin rows, so `@xterm/xterm` is NOT resolvable there. Instead
|
|
7
|
+
* of the removed jsDelivr fallback we serve the UMD builds from our own web
|
|
8
|
+
* route (`/dsh-ssh-tunnel/vendor/*`) and load them with plain <script>/<link>
|
|
9
|
+
* tags. This module is the host-side allowlist + resolver (Node only).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { createRequire } from 'node:module'
|
|
13
|
+
|
|
14
|
+
const requireHost = createRequire(import.meta.url)
|
|
15
|
+
|
|
16
|
+
/** name -> { spec (resolved from this plugin's deps), content-type } */
|
|
17
|
+
export const VENDOR_ASSETS = {
|
|
18
|
+
'xterm.js': {
|
|
19
|
+
spec: '@xterm/xterm/lib/xterm.js',
|
|
20
|
+
type: 'text/javascript; charset=utf-8',
|
|
21
|
+
},
|
|
22
|
+
'xterm.css': {
|
|
23
|
+
spec: '@xterm/xterm/css/xterm.css',
|
|
24
|
+
type: 'text/css; charset=utf-8',
|
|
25
|
+
},
|
|
26
|
+
'addon-fit.js': {
|
|
27
|
+
spec: '@xterm/addon-fit/lib/addon-fit.js',
|
|
28
|
+
type: 'text/javascript; charset=utf-8',
|
|
29
|
+
},
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Resolve an allowlisted vendor asset to an absolute file path.
|
|
34
|
+
* Anything not in the allowlist throws — callers must treat that as 404 and
|
|
35
|
+
* must not interpolate user input into the file lookup.
|
|
36
|
+
* @param {string} name exact basename from the allowlist
|
|
37
|
+
* @returns {{ path: string, type: string }}
|
|
38
|
+
*/
|
|
39
|
+
export function resolveVendorAsset(name) {
|
|
40
|
+
const entry = VENDOR_ASSETS[name]
|
|
41
|
+
if (!entry) {
|
|
42
|
+
throw new Error(`dsh-ssh-tunnel: vendor asset not allowed: ${String(name)}`)
|
|
43
|
+
}
|
|
44
|
+
return { path: requireHost.resolve(entry.spec), type: entry.type }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** @param {string} name checked against the allowlist only */
|
|
48
|
+
export function isVendorAssetAllowed(name) {
|
|
49
|
+
return Object.prototype.hasOwnProperty.call(VENDOR_ASSETS, name)
|
|
50
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-ssh-tunnel",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Multi-host SSH tunnel and SSHManager tool for DeepSeek Harness (project-scoped grants)",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "thirsty5034",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/OMSociety/dsh-ssh-tunnel.git"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/OMSociety/dsh-ssh-tunnel#readme",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/OMSociety/dsh-ssh-tunnel/issues"
|
|
15
|
+
},
|
|
16
|
+
"contributors": [
|
|
17
|
+
{
|
|
18
|
+
"name": "OMSociety",
|
|
19
|
+
"url": "https://github.com/OMSociety"
|
|
20
|
+
}
|
|
21
|
+
],
|
|
22
|
+
"publishConfig": {
|
|
23
|
+
"access": "public"
|
|
24
|
+
},
|
|
25
|
+
"keywords": [
|
|
26
|
+
"dsh",
|
|
27
|
+
"dsh-plugin",
|
|
28
|
+
"deepseek-harness",
|
|
29
|
+
"ssh",
|
|
30
|
+
"sftp",
|
|
31
|
+
"cordis",
|
|
32
|
+
"better-sidebar",
|
|
33
|
+
"dsh-better-sidebar"
|
|
34
|
+
],
|
|
35
|
+
"engines": {
|
|
36
|
+
"node": ">=20",
|
|
37
|
+
"dsh": ">=0.1.7-rc.2 <0.3.0-0"
|
|
38
|
+
},
|
|
39
|
+
"main": "lib/index.js",
|
|
40
|
+
"exports": {
|
|
41
|
+
".": "./lib/index.js",
|
|
42
|
+
"./client": "./lib/client.js",
|
|
43
|
+
"./package.json": "./package.json"
|
|
44
|
+
},
|
|
45
|
+
"scripts": {
|
|
46
|
+
"test": "node scripts/smoke-test.mjs",
|
|
47
|
+
"check": "node --check lib/index.js && node scripts/smoke-test.mjs"
|
|
48
|
+
},
|
|
49
|
+
"dsh": {
|
|
50
|
+
"manifestVersion": 1,
|
|
51
|
+
"bundle": {
|
|
52
|
+
"patch": "./cordis.patch.yml"
|
|
53
|
+
},
|
|
54
|
+
"client": {
|
|
55
|
+
"inject": [
|
|
56
|
+
"@deepseek-ai/dsh-client-locale"
|
|
57
|
+
],
|
|
58
|
+
"platform": "web"
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
"dependencies": {
|
|
62
|
+
"@xterm/addon-fit": "0.11.0",
|
|
63
|
+
"@xterm/xterm": "5.5.0",
|
|
64
|
+
"ssh2": "^1.17.0"
|
|
65
|
+
},
|
|
66
|
+
"peerDependencies": {
|
|
67
|
+
"@deepseek-ai/cordis": "^4.0.4",
|
|
68
|
+
"@deepseek-ai/dsh-client-locale": ">=0.1.7-rc.2 <0.3.0-0",
|
|
69
|
+
"dsh-better-sidebar": ">=0.12.0"
|
|
70
|
+
},
|
|
71
|
+
"files": [
|
|
72
|
+
"CHANGELOG.md",
|
|
73
|
+
"LICENSE",
|
|
74
|
+
"README.md",
|
|
75
|
+
"README_en.md",
|
|
76
|
+
"cordis.patch.yml",
|
|
77
|
+
"lib",
|
|
78
|
+
"scripts/install.sh",
|
|
79
|
+
"scripts/install.ps1",
|
|
80
|
+
"scripts/portal-probe.mjs",
|
|
81
|
+
"scripts/smoke-test.mjs",
|
|
82
|
+
"scripts/sync-to-dsh.sh"
|
|
83
|
+
]
|
|
84
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# =============================================================================
|
|
2
|
+
# dsh-ssh-tunnel one-click install (Windows PowerShell 5.1+ / pwsh)
|
|
3
|
+
#
|
|
4
|
+
# Usage:
|
|
5
|
+
# irm https://raw.githubusercontent.com/OMSociety/dsh-ssh-tunnel/main/scripts/install.ps1 | iex
|
|
6
|
+
# & ([scriptblock]::Create((irm 'https://raw.githubusercontent.com/OMSociety/dsh-ssh-tunnel/main/scripts/install.ps1'))) -From github -Restart
|
|
7
|
+
# =============================================================================
|
|
8
|
+
param(
|
|
9
|
+
[string]$Version = '',
|
|
10
|
+
[ValidateSet('github', 'npm')]
|
|
11
|
+
[string]$From = 'github',
|
|
12
|
+
[switch]$Restart,
|
|
13
|
+
[switch]$DryRun
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
$PKG = 'dsh-ssh-tunnel'
|
|
17
|
+
$PLUGIN_ID = 'ssh-tunnel'
|
|
18
|
+
$GITHUB_REPO = if ($env:GITHUB_REPO) { $env:GITHUB_REPO } else { 'OMSociety/dsh-ssh-tunnel' }
|
|
19
|
+
$REGISTRY = if ($env:REGISTRY) { $env:REGISTRY } else { 'https://registry.npmjs.org' }
|
|
20
|
+
|
|
21
|
+
if ($env:DSH_HOME) { $DSH_HOME = $env:DSH_HOME }
|
|
22
|
+
elseif ($env:USERPROFILE) { $DSH_HOME = Join-Path $env:USERPROFILE '.dsh' }
|
|
23
|
+
else { $DSH_HOME = Join-Path $HOME '.dsh' }
|
|
24
|
+
|
|
25
|
+
$PROFILE_DIR = Join-Path $DSH_HOME 'profiles\web'
|
|
26
|
+
$WS_YML = Join-Path $PROFILE_DIR 'pnpm-workspace.yaml'
|
|
27
|
+
$PATCH_YML = Join-Path $PROFILE_DIR 'cordis.patch.yml'
|
|
28
|
+
|
|
29
|
+
function Say([string]$m) { Write-Host "[install] $m" -ForegroundColor Green }
|
|
30
|
+
function Warn([string]$m) { Write-Host "[warn] $m" -ForegroundColor Yellow }
|
|
31
|
+
function Die([string]$m) { Write-Host "[error] $m" -ForegroundColor Red; exit 1 }
|
|
32
|
+
|
|
33
|
+
function Get-DshCli {
|
|
34
|
+
if ($env:DSH_CMD) { return $env:DSH_CMD }
|
|
35
|
+
if (Get-Command dsh -ErrorAction SilentlyContinue) { return 'dsh' }
|
|
36
|
+
if (Get-Command npx -ErrorAction SilentlyContinue) { return 'npx' }
|
|
37
|
+
return $null
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function Resolve-AddSpec {
|
|
41
|
+
if ($From -eq 'npm') {
|
|
42
|
+
$given = if ([string]::IsNullOrWhiteSpace($Version)) { 'latest' } else { $Version }
|
|
43
|
+
if ($given -eq 'latest') {
|
|
44
|
+
foreach ($tool in @('npm', 'pnpm')) {
|
|
45
|
+
if (Get-Command $tool -ErrorAction SilentlyContinue) {
|
|
46
|
+
$v = (& $tool view $PKG version "--registry=$REGISTRY" 2>$null | Select-Object -Last 1)
|
|
47
|
+
if ($v) { return "$PKG@$([string]$v.Trim())" }
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return "$PKG@latest"
|
|
51
|
+
}
|
|
52
|
+
return "$PKG@$given"
|
|
53
|
+
}
|
|
54
|
+
if ([string]::IsNullOrWhiteSpace($Version)) { return "$PKG@github:$GITHUB_REPO" }
|
|
55
|
+
return "$PKG@github:$GITHUB_REPO#$Version"
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (-not (Get-Command node -ErrorAction SilentlyContinue)) { Die 'Node.js not found (need >= 20).' }
|
|
59
|
+
$NodeMajor = [int](& node -p "process.versions.node.split('.')[0]")
|
|
60
|
+
if ($NodeMajor -lt 20) { Die "Node.js $(& node -v) is too old (need >= 20)." }
|
|
61
|
+
if (-not (Test-Path $PROFILE_DIR)) { Die "Profile dir missing: $PROFILE_DIR" }
|
|
62
|
+
if (-not (Test-Path $WS_YML)) { Die "Missing $WS_YML" }
|
|
63
|
+
|
|
64
|
+
$ADD_SPEC = Resolve-AddSpec
|
|
65
|
+
$CLI = Get-DshCli
|
|
66
|
+
if (-not $CLI) { Die 'dsh/npx not found. Install DSH or set DSH_CMD.' }
|
|
67
|
+
|
|
68
|
+
Say "Target: $CLI plugin --profile web add $ADD_SPEC"
|
|
69
|
+
Say "Source: $From ($GITHUB_REPO)"
|
|
70
|
+
|
|
71
|
+
if ($DryRun) {
|
|
72
|
+
Say "[dry-run] exclude $PKG from minimumReleaseAge; add $ADD_SPEC; verify bundles; strip manual $PLUGIN_ID mount"
|
|
73
|
+
exit 0
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
$wsScript = @'
|
|
77
|
+
const fs = require("fs");
|
|
78
|
+
const p = process.argv[1];
|
|
79
|
+
const pkg = process.argv[2];
|
|
80
|
+
let t = fs.readFileSync(p, "utf8");
|
|
81
|
+
const before = t;
|
|
82
|
+
if (!new RegExp("^\\s*-\\s+" + pkg.replace(/[.*+?^${}()|[\\]\\]/g, "\\$&") + "\\s*$", "m").test(t)) {
|
|
83
|
+
if (/^\s*minimumReleaseAgeExclude:\s*$/m.test(t)) {
|
|
84
|
+
t = t.replace(/^(\s*minimumReleaseAgeExclude:\s*)$/m, "$1\n - " + pkg);
|
|
85
|
+
} else {
|
|
86
|
+
t += "\nminimumReleaseAgeExclude:\n - " + pkg + "\n";
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (t !== before) fs.writeFileSync(p, t);
|
|
90
|
+
console.log(t === before ? "unchanged" : "updated");
|
|
91
|
+
'@
|
|
92
|
+
$wsResult = & node -e $wsScript $WS_YML $PKG
|
|
93
|
+
if ($wsResult -eq 'updated') { Say "Updated $WS_YML minimumReleaseAgeExclude" } else { Say 'workspace ready' }
|
|
94
|
+
|
|
95
|
+
Say "Running plugin add..."
|
|
96
|
+
if ($CLI -eq 'npx') {
|
|
97
|
+
& npx -y --package @deepseek-ai/dsh dsh plugin --profile web add $ADD_SPEC
|
|
98
|
+
} else {
|
|
99
|
+
& $CLI plugin --profile web add $ADD_SPEC
|
|
100
|
+
}
|
|
101
|
+
if ($LASTEXITCODE -ne 0) { Die 'dsh plugin add failed' }
|
|
102
|
+
|
|
103
|
+
$check = @'
|
|
104
|
+
const fs = require("fs");
|
|
105
|
+
const p = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
|
|
106
|
+
const bundles = p.dsh?.profile?.bundles ?? [];
|
|
107
|
+
process.exit(bundles.includes(process.argv[2]) ? 0 : 1);
|
|
108
|
+
'@
|
|
109
|
+
& node -e $check (Join-Path $PROFILE_DIR 'package.json') $PKG
|
|
110
|
+
if ($LASTEXITCODE -ne 0) { Die "$PKG missing from dsh.profile.bundles" }
|
|
111
|
+
Say "bundle registered: $PKG"
|
|
112
|
+
|
|
113
|
+
if (Test-Path $PATCH_YML) {
|
|
114
|
+
$strip = @'
|
|
115
|
+
const fs = require("fs");
|
|
116
|
+
const p = process.argv[1];
|
|
117
|
+
const id = process.argv[2];
|
|
118
|
+
const lines = fs.readFileSync(p, "utf8").split("\n");
|
|
119
|
+
const out = [];
|
|
120
|
+
let i = 0, removed = false;
|
|
121
|
+
const idRe = new RegExp("id:\\s*" + id.replace(/[.*+?^${}()|[\\]\\]/g, "\\$&") + "\\b");
|
|
122
|
+
while (i < lines.length) {
|
|
123
|
+
const line = lines[i];
|
|
124
|
+
if (/^[ \t]*- insert:\s*$/.test(line)) {
|
|
125
|
+
const block = [line];
|
|
126
|
+
let j = i + 1;
|
|
127
|
+
while (j < lines.length && lines[j].trim() !== "" && !/^-\s/.test(lines[j])) { block.push(lines[j]); j++; }
|
|
128
|
+
if (block.some((l) => idRe.test(l))) {
|
|
129
|
+
while (out.length && /^[ \t]*#/.test(out[out.length - 1])) out.pop();
|
|
130
|
+
i = j; removed = true; continue;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
out.push(line); i++;
|
|
134
|
+
}
|
|
135
|
+
if (!removed) console.log("none");
|
|
136
|
+
else { fs.writeFileSync(p, out.join("\n").replace(/\n{3,}/g, "\n\n")); console.log("removed"); }
|
|
137
|
+
'@
|
|
138
|
+
$mr = & node -e $strip $PATCH_YML $PLUGIN_ID
|
|
139
|
+
if ($mr -eq 'removed') { Say "Removed manual $PLUGIN_ID mount from cordis.patch.yml" }
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
Say "Done: $ADD_SPEC"
|
|
143
|
+
if ($Restart) {
|
|
144
|
+
if (Get-Command pm2 -ErrorAction SilentlyContinue) { pm2 restart dsh-web }
|
|
145
|
+
else { Warn 'pm2 not found; restart DSH web manually' }
|
|
146
|
+
} else {
|
|
147
|
+
Say 'Next: restart DSH web and hard-refresh the browser.'
|
|
148
|
+
}
|