dsh-ssh-workbench 0.0.0-stage → 1.7.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 +552 -0
- package/CONTRIBUTING.md +63 -0
- package/LICENSE +21 -0
- package/README.md +178 -2
- package/SECURITY.md +27 -0
- package/cordis.patch.yml +5 -0
- package/docs/architecture.md +146 -0
- package/docs/execution-model.md +83 -0
- package/docs/images/chat-at-resource.png +0 -0
- package/docs/images/plugin.png +0 -0
- package/docs/images/remote-workbench.png +0 -0
- package/docs/images/workbench.png +0 -0
- package/docs/quickstart.md +106 -0
- package/lib/client.js +4991 -0
- package/lib/core/activity.js +88 -0
- package/lib/core/background.js +69 -0
- package/lib/core/broker.js +802 -0
- package/lib/core/dialect.js +28 -0
- package/lib/core/errors.js +138 -0
- package/lib/core/execution-stats.js +88 -0
- package/lib/core/gate-pwsh.js +88 -0
- package/lib/core/gate.js +572 -0
- package/lib/core/guard.js +123 -0
- package/lib/core/known-hosts.js +74 -0
- package/lib/core/live-executions.js +101 -0
- package/lib/core/local-file-api.js +174 -0
- package/lib/core/localfs.js +381 -0
- package/lib/core/managed-context.js +276 -0
- package/lib/core/mentions.js +216 -0
- package/lib/core/paths.js +104 -0
- package/lib/core/posix-quote.js +4 -0
- package/lib/core/posix-shell.js +89 -0
- package/lib/core/prefs.js +38 -0
- package/lib/core/remote-exec-tool.js +394 -0
- package/lib/core/remote-file-tools.js +559 -0
- package/lib/core/remote-shell-tool.js +310 -0
- package/lib/core/secrets.js +95 -0
- package/lib/core/server-ref.js +61 -0
- package/lib/core/shell-adapters.js +48 -0
- package/lib/core/ssh.js +761 -0
- package/lib/core/store.js +83 -0
- package/lib/core/terminals.js +299 -0
- package/lib/core/win32-adapter.js +141 -0
- package/lib/index.js +409 -0
- package/lib/routes.js +759 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +89 -4
- package/vendor/NOTICE.md +50 -0
- package/vendor/XTERM-LICENSE +21 -0
- package/vendor/node_modules/asn1/LICENSE +19 -0
- package/vendor/node_modules/asn1/README.md +50 -0
- package/vendor/node_modules/asn1/lib/ber/errors.js +13 -0
- package/vendor/node_modules/asn1/lib/ber/index.js +27 -0
- package/vendor/node_modules/asn1/lib/ber/reader.js +262 -0
- package/vendor/node_modules/asn1/lib/ber/types.js +36 -0
- package/vendor/node_modules/asn1/lib/ber/writer.js +317 -0
- package/vendor/node_modules/asn1/lib/index.js +20 -0
- package/vendor/node_modules/asn1/package.json +31 -0
- package/vendor/node_modules/bcrypt-pbkdf/CONTRIBUTING.md +13 -0
- package/vendor/node_modules/bcrypt-pbkdf/LICENSE +66 -0
- package/vendor/node_modules/bcrypt-pbkdf/README.md +45 -0
- package/vendor/node_modules/bcrypt-pbkdf/index.js +556 -0
- package/vendor/node_modules/bcrypt-pbkdf/package.json +15 -0
- package/vendor/node_modules/safer-buffer/LICENSE +21 -0
- package/vendor/node_modules/safer-buffer/Readme.md +156 -0
- package/vendor/node_modules/safer-buffer/dangerous.js +58 -0
- package/vendor/node_modules/safer-buffer/package.json +34 -0
- package/vendor/node_modules/safer-buffer/safer.js +77 -0
- package/vendor/node_modules/tweetnacl/AUTHORS.md +28 -0
- package/vendor/node_modules/tweetnacl/LICENSE +24 -0
- package/vendor/node_modules/tweetnacl/README.md +459 -0
- package/vendor/node_modules/tweetnacl/nacl-fast.js +2388 -0
- package/vendor/node_modules/tweetnacl/nacl.js +1175 -0
- package/vendor/node_modules/tweetnacl/package.json +58 -0
- package/vendor/ssh2/LICENSE +19 -0
- package/vendor/ssh2/lib/Channel.js +295 -0
- package/vendor/ssh2/lib/agent.js +1123 -0
- package/vendor/ssh2/lib/client.js +2176 -0
- package/vendor/ssh2/lib/http-agents.js +84 -0
- package/vendor/ssh2/lib/index.js +44 -0
- package/vendor/ssh2/lib/keygen.js +582 -0
- package/vendor/ssh2/lib/protocol/Protocol.js +2136 -0
- package/vendor/ssh2/lib/protocol/SFTP.js +4052 -0
- package/vendor/ssh2/lib/protocol/constants.js +356 -0
- package/vendor/ssh2/lib/protocol/crypto/poly1305.js +43 -0
- package/vendor/ssh2/lib/protocol/crypto.js +1602 -0
- package/vendor/ssh2/lib/protocol/handlers.js +16 -0
- package/vendor/ssh2/lib/protocol/handlers.misc.js +1285 -0
- package/vendor/ssh2/lib/protocol/kex.js +1908 -0
- package/vendor/ssh2/lib/protocol/keyParser.js +1484 -0
- package/vendor/ssh2/lib/protocol/node-fs-compat.js +115 -0
- package/vendor/ssh2/lib/protocol/utils.js +356 -0
- package/vendor/ssh2/lib/protocol/zlib.js +255 -0
- package/vendor/ssh2/lib/server.js +1380 -0
- package/vendor/ssh2/lib/utils.js +336 -0
- package/vendor/ssh2/package.json +34 -0
- package/vendor/xterm.js +53 -0
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-session resource hints for the composer and the injected context.
|
|
3
|
+
*
|
|
4
|
+
* Every remote tool call carries its own `server`, so this store only
|
|
5
|
+
* remembers which managed resources the human mentioned with `@name` in the
|
|
6
|
+
* current conversation. It is a hint for the model and the UI, never an
|
|
7
|
+
* execution binding. The durable input is still the Session log, which
|
|
8
|
+
* `replayTargets()` folds back in after a resume.
|
|
9
|
+
*/
|
|
10
|
+
import { loadServers } from './store.js'
|
|
11
|
+
import { findServerRef } from './server-ref.js'
|
|
12
|
+
|
|
13
|
+
/** Source kind recorded on the injected resource-context message. */
|
|
14
|
+
export const TARGET_CONTEXT_KIND = 'ssh-workbench-target'
|
|
15
|
+
|
|
16
|
+
/** Concatenate the text blocks of one message. */
|
|
17
|
+
export function extractUserText(message) {
|
|
18
|
+
if (!message || message.role !== 'user' || !Array.isArray(message.content)) return ''
|
|
19
|
+
return message.content
|
|
20
|
+
.filter((block) => block && block.type === 'text' && typeof block.text === 'string')
|
|
21
|
+
.map((block) => block.text)
|
|
22
|
+
.join('\n')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Find the latest human-authored user message in a step's messages. */
|
|
26
|
+
export function lastHumanMessage(messages) {
|
|
27
|
+
if (!Array.isArray(messages)) return undefined
|
|
28
|
+
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
29
|
+
const message = messages[index]
|
|
30
|
+
if (message && message.role === 'user' && message.source?.kind === 'user') return message
|
|
31
|
+
}
|
|
32
|
+
return undefined
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Match `@name` tokens against the managed inventory (name or id,
|
|
37
|
+
* case-insensitive). Returns matched server records in mention order, without
|
|
38
|
+
* duplicates. Unknown tokens are ignored.
|
|
39
|
+
*/
|
|
40
|
+
export function extractServerMentions(text, servers) {
|
|
41
|
+
if (typeof text !== 'string' || !Array.isArray(servers) || servers.length === 0) return []
|
|
42
|
+
const byAlias = new Map()
|
|
43
|
+
for (const server of servers) {
|
|
44
|
+
if (!server || typeof server.name !== 'string' || server.name.length === 0) continue
|
|
45
|
+
byAlias.set(server.name.toLowerCase(), server)
|
|
46
|
+
if (typeof server.id === 'string' && server.id.length > 0) byAlias.set(server.id.toLowerCase(), server)
|
|
47
|
+
}
|
|
48
|
+
// Match known aliases directly instead of a generic `@token` regex: a CJK
|
|
49
|
+
// server name is often followed immediately by CJK text (`@测试机看下磁盘`),
|
|
50
|
+
// so only the known name length tells us where the mention ends.
|
|
51
|
+
const aliases = [...byAlias.keys()].sort((left, right) => right.length - left.length)
|
|
52
|
+
const seen = new Set()
|
|
53
|
+
const matched = []
|
|
54
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
55
|
+
if (text[index] !== '@') continue
|
|
56
|
+
// Like an email address: `ops@example.com` is not a mention.
|
|
57
|
+
const previous = index > 0 ? text[index - 1] : ''
|
|
58
|
+
if (previous && /[A-Za-z0-9._-]/.test(previous)) continue
|
|
59
|
+
for (const alias of aliases) {
|
|
60
|
+
if (text.slice(index + 1, index + 1 + alias.length).toLowerCase() !== alias) continue
|
|
61
|
+
const tail = text[index + 1 + alias.length] ?? ''
|
|
62
|
+
// `@web-12` is a different token than `@web-1`; CJK continuation is fine.
|
|
63
|
+
if (tail && /[A-Za-z0-9._-]/.test(tail)) break
|
|
64
|
+
const server = byAlias.get(alias)
|
|
65
|
+
if (server && !seen.has(server.id ?? server.name)) {
|
|
66
|
+
seen.add(server.id ?? server.name)
|
|
67
|
+
matched.push(server)
|
|
68
|
+
}
|
|
69
|
+
break
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return matched
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function sameList(left, right) {
|
|
76
|
+
if (left.length !== right.length) return false
|
|
77
|
+
return left.every((value, index) => value === right[index])
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function mentionNames(mentions) {
|
|
81
|
+
const names = []
|
|
82
|
+
for (const server of Array.isArray(mentions) ? mentions : []) {
|
|
83
|
+
const name = typeof server?.name === 'string' ? server.name : ''
|
|
84
|
+
if (name && !names.includes(name)) names.push(name)
|
|
85
|
+
}
|
|
86
|
+
return names
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function freshState() {
|
|
90
|
+
return { candidates: [], lastHumanId: null, version: 0, injectedVersion: -1, seeded: false }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Remember the latest @mentions. No target is selected or cleared. */
|
|
94
|
+
export function applyMentions(state, mentions) {
|
|
95
|
+
const names = mentionNames(mentions)
|
|
96
|
+
if (names.length === 0) return state
|
|
97
|
+
if (sameList(state.candidates, names)) return state
|
|
98
|
+
return { ...state, candidates: names, version: state.version + 1 }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Replay the mention hints over a Session's historical messages. */
|
|
102
|
+
export function replayTargets(messages, servers) {
|
|
103
|
+
let state = freshState()
|
|
104
|
+
if (Array.isArray(messages)) {
|
|
105
|
+
for (const message of messages) {
|
|
106
|
+
if (message?.role !== 'user' || message.source?.kind !== 'user') continue
|
|
107
|
+
const mentions = extractServerMentions(extractUserText(message), servers)
|
|
108
|
+
if (mentions.length > 0) state = applyMentions(state, mentions)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return state
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Resolve one resource reference against the inventory (name or id). */
|
|
115
|
+
export function findTarget(servers, ref) {
|
|
116
|
+
return findServerRef(servers, ref)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Create the per-session resource-hint store.
|
|
121
|
+
* @param {{ loadInventory?: () => object[] }} [options]
|
|
122
|
+
*/
|
|
123
|
+
export function createTargetStore(options = {}) {
|
|
124
|
+
const loadInventory = options.loadInventory ?? loadServers
|
|
125
|
+
/** @type {Map<string, object>} */
|
|
126
|
+
const sessions = new Map()
|
|
127
|
+
|
|
128
|
+
function sessionKey(session) {
|
|
129
|
+
if (session === undefined || session === null) return undefined
|
|
130
|
+
if (typeof session === 'string') return session
|
|
131
|
+
const id = session.id
|
|
132
|
+
return typeof id === 'string' && id.length > 0 ? id : undefined
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function get(key) {
|
|
136
|
+
const sessionId = sessionKey(key)
|
|
137
|
+
if (sessionId === undefined) return undefined
|
|
138
|
+
return sessions.get(sessionId)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function ensure(key) {
|
|
142
|
+
const sessionId = sessionKey(key)
|
|
143
|
+
if (sessionId === undefined) return undefined
|
|
144
|
+
let state = sessions.get(sessionId)
|
|
145
|
+
if (state === undefined) {
|
|
146
|
+
state = freshState()
|
|
147
|
+
sessions.set(sessionId, state)
|
|
148
|
+
}
|
|
149
|
+
return state
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Seed one session once from its historical messages (resume support). */
|
|
153
|
+
function ensureSeeded(key, messages, servers = loadInventory()) {
|
|
154
|
+
const state = ensure(key)
|
|
155
|
+
if (state === undefined || state.seeded) return state
|
|
156
|
+
const replayed = replayTargets(messages, servers)
|
|
157
|
+
const history = Array.isArray(messages) ? messages : []
|
|
158
|
+
const lastHuman = [...history].reverse().find(
|
|
159
|
+
(message) => message?.role === 'user' && message.source?.kind === 'user',
|
|
160
|
+
)
|
|
161
|
+
const next = {
|
|
162
|
+
...state,
|
|
163
|
+
candidates: replayed.candidates,
|
|
164
|
+
lastHumanId: typeof lastHuman?.id === 'string' ? lastHuman.id : state.lastHumanId,
|
|
165
|
+
version: replayed.candidates.length > 0 ? 1 : 0,
|
|
166
|
+
seeded: true,
|
|
167
|
+
}
|
|
168
|
+
sessions.set(sessionKey(key), next)
|
|
169
|
+
return next
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Fold one human message into the hint state.
|
|
174
|
+
* A new human message always bumps `version` so the per-turn resource
|
|
175
|
+
* context is injected even when the message contains no @mention.
|
|
176
|
+
*/
|
|
177
|
+
function bindMentions(key, mentions, humanId) {
|
|
178
|
+
const state = ensure(key)
|
|
179
|
+
if (state === undefined) return undefined
|
|
180
|
+
const newHuman = typeof humanId === 'string' && humanId.length > 0 && humanId !== state.lastHumanId
|
|
181
|
+
const names = mentionNames(mentions)
|
|
182
|
+
const changedMentions = names.length > 0 && !sameList(state.candidates, names)
|
|
183
|
+
if (!newHuman && !changedMentions) return state
|
|
184
|
+
const next = {
|
|
185
|
+
...state,
|
|
186
|
+
candidates: names.length > 0 ? names : state.candidates,
|
|
187
|
+
lastHumanId: typeof humanId === 'string' && humanId.length > 0 ? humanId : state.lastHumanId,
|
|
188
|
+
version: state.version + 1,
|
|
189
|
+
seeded: true,
|
|
190
|
+
}
|
|
191
|
+
sessions.set(sessionKey(key), next)
|
|
192
|
+
return next
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function markInjected(key, version) {
|
|
196
|
+
const state = get(key)
|
|
197
|
+
if (state === undefined) return
|
|
198
|
+
if (state.injectedVersion === version) return
|
|
199
|
+
sessions.set(sessionKey(key), { ...state, injectedVersion: version })
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function clear(key) {
|
|
203
|
+
const sessionId = sessionKey(key)
|
|
204
|
+
if (sessionId !== undefined) sessions.delete(sessionId)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function clearAll() {
|
|
208
|
+
sessions.clear()
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function size() {
|
|
212
|
+
return sessions.size
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return { get, ensure, ensureSeeded, bindMentions, markInjected, clear, clearAll, size, loadInventory }
|
|
216
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Filesystem layout for dsh-ssh-workbench.
|
|
3
|
+
*
|
|
4
|
+
* Everything the plugin persists lives under the harness home so a profile
|
|
5
|
+
* export/import carries it, and so tests can redirect it with DSH_SSH_WORKBENCH_DIR.
|
|
6
|
+
*/
|
|
7
|
+
import os from 'node:os'
|
|
8
|
+
import path from 'node:path'
|
|
9
|
+
import fs from 'node:fs'
|
|
10
|
+
|
|
11
|
+
function dshHome() {
|
|
12
|
+
return process.env.DSH_HOME || path.join(os.homedir(), '.dsh')
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** New storage directory name, plus the pre-1.6.4 name for one-time migration. */
|
|
16
|
+
const DIR_NAME = 'ssh-workbench'
|
|
17
|
+
const LEGACY_DIR_NAME = 'ssh-ops'
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Directory that holds servers, credentials, known hosts and preferences.
|
|
21
|
+
*
|
|
22
|
+
* `DSH_SSH_WORKBENCH_DIR` wins in tests and for power users. Otherwise the
|
|
23
|
+
* plugin uses `$DSH_HOME/ssh-workbench` and, on the first launch after the
|
|
24
|
+
* 1.6.4 rename, moves an existing `$DSH_HOME/ssh-ops` directory into place so
|
|
25
|
+
* credentials and trusted host keys are not lost. If the move fails (for
|
|
26
|
+
* example because of permissions) the legacy directory stays in use.
|
|
27
|
+
*/
|
|
28
|
+
export function workbenchDir() {
|
|
29
|
+
const configured = process.env.DSH_SSH_WORKBENCH_DIR
|
|
30
|
+
if (configured) return configured
|
|
31
|
+
const current = path.join(dshHome(), DIR_NAME)
|
|
32
|
+
if (!fs.existsSync(current)) {
|
|
33
|
+
const legacy = path.join(dshHome(), LEGACY_DIR_NAME)
|
|
34
|
+
if (fs.existsSync(legacy)) {
|
|
35
|
+
try {
|
|
36
|
+
fs.renameSync(legacy, current)
|
|
37
|
+
} catch {
|
|
38
|
+
return legacy
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return current
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function serversPath() {
|
|
46
|
+
return path.join(workbenchDir(), 'servers.json')
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function activityPath() {
|
|
50
|
+
return path.join(workbenchDir(), 'activity.json')
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function knownHostsPath() {
|
|
54
|
+
return path.join(workbenchDir(), 'known_hosts')
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function secretsPath() {
|
|
58
|
+
return path.join(workbenchDir(), 'secrets.json')
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function secretsKeyPath() {
|
|
62
|
+
return path.join(workbenchDir(), 'secrets.key')
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function prefsPath() {
|
|
66
|
+
return path.join(workbenchDir(), 'prefs.json')
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Create a private directory (0700) if missing. */
|
|
70
|
+
function ensurePrivateDir(dir = workbenchDir()) {
|
|
71
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 })
|
|
72
|
+
try {
|
|
73
|
+
fs.chmodSync(dir, 0o700)
|
|
74
|
+
} catch {
|
|
75
|
+
// Windows cannot honour POSIX modes; the mkdir mode is best effort there.
|
|
76
|
+
}
|
|
77
|
+
return dir
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Write a private file atomically (temp file + rename, 0600). */
|
|
81
|
+
export function writePrivateFile(file, text) {
|
|
82
|
+
ensurePrivateDir(path.dirname(file))
|
|
83
|
+
const tmp = `${file}.${process.pid}.${Date.now()}.tmp`
|
|
84
|
+
fs.writeFileSync(tmp, text, { mode: 0o600 })
|
|
85
|
+
try {
|
|
86
|
+
fs.chmodSync(tmp, 0o600)
|
|
87
|
+
} catch {
|
|
88
|
+
// best effort on Windows
|
|
89
|
+
}
|
|
90
|
+
fs.renameSync(tmp, file)
|
|
91
|
+
try {
|
|
92
|
+
fs.chmodSync(file, 0o600)
|
|
93
|
+
} catch {
|
|
94
|
+
// best effort on Windows
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function readTextFile(file, fallback = '') {
|
|
99
|
+
try {
|
|
100
|
+
return fs.readFileSync(file, 'utf8')
|
|
101
|
+
} catch {
|
|
102
|
+
return fallback
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared POSIX remote-shell helpers.
|
|
3
|
+
*
|
|
4
|
+
* Pure command construction used by `remote_bash` and `remote_exec`; the
|
|
5
|
+
* native local `bash` tool never reaches this module.
|
|
6
|
+
*/
|
|
7
|
+
import { quotePosix } from './posix-quote.js'
|
|
8
|
+
|
|
9
|
+
const MIN_TIMEOUT_MS = 1000
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Wrap a remote command with the remote `timeout` utility.
|
|
13
|
+
*
|
|
14
|
+
* A client-side timeout only closes the SSH channel; a process that ignores
|
|
15
|
+
* SIGHUP (nohup/background) keeps running on the server. Wrapping the command
|
|
16
|
+
* gives the remote side the same deadline: coreutils `timeout` runs the
|
|
17
|
+
* managed command in its own process group and signals the whole group, so
|
|
18
|
+
* when the client gives up the remote work stops too. `kill` adds `-k <n>`
|
|
19
|
+
* (KILL after TERM); `basic` sends TERM only; `none` (or an unknown
|
|
20
|
+
* capability) leaves the command untouched.
|
|
21
|
+
*
|
|
22
|
+
* The wrapper re-enters the remote login shell (`<shell> -c`) so shell
|
|
23
|
+
* semantics do not change. The shell path comes from the capability probe and
|
|
24
|
+
* is referenced as a quoted literal, so this also works on fish/csh (no POSIX
|
|
25
|
+
* `${SHELL:-...}` expansion). The command itself is single-quote escaped and
|
|
26
|
+
* therefore expanded exactly once, inside that shell.
|
|
27
|
+
*
|
|
28
|
+
* @param {string} command
|
|
29
|
+
* @param {number} timeoutMs
|
|
30
|
+
* @param {'kill'|'basic'|'none'|{mode?: string, shell?: string}} [capability]
|
|
31
|
+
* @param {{ killAfterMs?: number }} [options]
|
|
32
|
+
*/
|
|
33
|
+
export function wrapRemoteTimeout(command, timeoutMs, capability = 'none', options = {}) {
|
|
34
|
+
const info = typeof capability === 'string' ? { mode: capability } : (capability ?? {})
|
|
35
|
+
const mode = info.mode === 'kill' || info.mode === 'basic' ? info.mode : 'none'
|
|
36
|
+
if (mode === 'none') return String(command ?? '')
|
|
37
|
+
const shell = typeof info.shell === 'string' && info.shell.startsWith('/') ? info.shell : '/bin/sh'
|
|
38
|
+
const killAfterMs = Number.isFinite(options.killAfterMs) ? options.killAfterMs : 5000
|
|
39
|
+
const killSeconds = Math.max(0, Math.ceil(killAfterMs / 1000))
|
|
40
|
+
const seconds = Math.max(1, Number(timeoutMs) / 1000)
|
|
41
|
+
const suffix = mode === 'kill' && killSeconds > 0 ? `-k ${killSeconds} ` : ''
|
|
42
|
+
return `timeout ${suffix}${seconds} ${quotePosix(shell)} -c ${quotePosix(command)}`
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Turn the model-facing `workdir` into a shell prefix and the value's cwd label.
|
|
47
|
+
*
|
|
48
|
+
* The remote shell starts in the login home; absolute paths are used verbatim,
|
|
49
|
+
* `~`/`~/x` and relative paths are anchored at `$HOME`.
|
|
50
|
+
*
|
|
51
|
+
* @returns {{ command: string, cwd: string } | { error: string }}
|
|
52
|
+
*/
|
|
53
|
+
export function applyWorkdir(command, workdir) {
|
|
54
|
+
const raw = typeof workdir === 'string' ? workdir.trim() : ''
|
|
55
|
+
const body = String(command ?? '')
|
|
56
|
+
if (!raw) return { command: body, cwd: '~' }
|
|
57
|
+
if (raw.includes('\n') || raw.includes('\r')) return { error: 'workdir must not contain newlines' }
|
|
58
|
+
if (raw.startsWith('~')) {
|
|
59
|
+
const rest = raw === '~' ? '' : raw.slice(1)
|
|
60
|
+
if (rest !== '' && !rest.startsWith('/')) return { error: 'workdir must be an absolute path, ~/path, or a relative path' }
|
|
61
|
+
const target = rest === '' ? '"$HOME"' : `"$HOME"${quotePosix(rest)}`
|
|
62
|
+
return { command: `cd -- ${target} && ${body}`, cwd: raw }
|
|
63
|
+
}
|
|
64
|
+
if (raw.startsWith('/')) {
|
|
65
|
+
return { command: `cd -- ${quotePosix(raw)} && ${body}`, cwd: raw }
|
|
66
|
+
}
|
|
67
|
+
return { command: `cd -- "$HOME"/${quotePosix(raw)} && ${body}`, cwd: `~/${raw}` }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Effective timeout for one call, clamped to the configured ceiling. */
|
|
71
|
+
export function resolveTimeoutMs(args, config) {
|
|
72
|
+
const ceiling = Number.isInteger(config?.maxCommandTimeoutMs) ? config.maxCommandTimeoutMs : 30 * 60 * 1000
|
|
73
|
+
const requested = Number.isInteger(args?.timeoutMs) ? args.timeoutMs : undefined
|
|
74
|
+
const fallback = Number.isInteger(config?.commandTimeoutMs) ? config.commandTimeoutMs : 120000
|
|
75
|
+
const value = requested ?? fallback
|
|
76
|
+
return Math.max(MIN_TIMEOUT_MS, Math.min(value, ceiling))
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Whether a runtime result means the command never reached the remote shell.
|
|
81
|
+
* Timeouts and aborts are real command outcomes (timedOut / aborted on the
|
|
82
|
+
* native value), not transport failures.
|
|
83
|
+
*/
|
|
84
|
+
export function isTransportFailure(result) {
|
|
85
|
+
if (!result || typeof result !== 'object') return true
|
|
86
|
+
if (result.exitCode !== null && result.exitCode !== undefined) return false
|
|
87
|
+
if (typeof result.signal === 'string' && result.signal.length > 0) return false
|
|
88
|
+
return result.outcome === 'failed'
|
|
89
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Small user-visible UI preferences that are not part of the server inventory.
|
|
3
|
+
*
|
|
4
|
+
* Kept in the same private data directory as the inventory so a profile export
|
|
5
|
+
* carries them, and so the plugin manager's sidebar switch can change them
|
|
6
|
+
* without rewriting the profile's cordis.patch.yml.
|
|
7
|
+
*/
|
|
8
|
+
import { prefsPath, readTextFile, writePrivateFile } from './paths.js'
|
|
9
|
+
|
|
10
|
+
export const DEFAULT_PREFS = Object.freeze({ sidebar: true })
|
|
11
|
+
|
|
12
|
+
/** Coerce a stored document into the current preference shape. */
|
|
13
|
+
function normalizePrefs(raw) {
|
|
14
|
+
const prefs = { ...DEFAULT_PREFS }
|
|
15
|
+
if (!raw || typeof raw !== 'object') return prefs
|
|
16
|
+
if (typeof raw.sidebar === 'boolean') prefs.sidebar = raw.sidebar
|
|
17
|
+
return prefs
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function loadPrefs() {
|
|
21
|
+
const text = readTextFile(prefsPath(), '')
|
|
22
|
+
if (!text.trim()) return { ...DEFAULT_PREFS }
|
|
23
|
+
try {
|
|
24
|
+
return normalizePrefs(JSON.parse(text))
|
|
25
|
+
} catch {
|
|
26
|
+
// A corrupt preferences file must never make the plugin unusable; fall
|
|
27
|
+
// back to the defaults and let the next save rewrite it.
|
|
28
|
+
return { ...DEFAULT_PREFS }
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Merge one patch into the stored preferences and persist the result. */
|
|
33
|
+
export function savePrefs(patch) {
|
|
34
|
+
if (!patch || typeof patch !== 'object') throw new Error('prefs patch must be an object')
|
|
35
|
+
const next = normalizePrefs({ ...loadPrefs(), ...patch })
|
|
36
|
+
writePrivateFile(prefsPath(), `${JSON.stringify(next, null, 2)}\n`)
|
|
37
|
+
return next
|
|
38
|
+
}
|