dsh-remote-workspaces 0.1.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/LICENSE +21 -0
- package/README.md +149 -0
- package/README.zh.md +151 -0
- package/cordis.patch.yml +25 -0
- package/package.json +44 -0
- package/src/anchor.js +67 -0
- package/src/client.js +756 -0
- package/src/containment.js +71 -0
- package/src/errors.js +17 -0
- package/src/fs-sftp.js +231 -0
- package/src/index.js +327 -0
- package/src/local-backend.js +224 -0
- package/src/machine-store.js +238 -0
- package/src/registry.js +76 -0
- package/src/routing-fs.js +230 -0
- package/src/search.js +286 -0
- package/src/shell-exec.js +423 -0
- package/src/ssh-config.js +52 -0
- package/src/ssh-uri.js +19 -0
- package/src/transport.js +401 -0
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local filesystem backend (the "local path" half of the routing provider).
|
|
3
|
+
*
|
|
4
|
+
* Self-contained over `node:fs` so the plugin does not depend on the
|
|
5
|
+
* `deepseek-harness` local backend (which would register `ctx.fs` and conflict
|
|
6
|
+
* with the routing provider). Implements the same 12 operations as the
|
|
7
|
+
* `@deepseek-ai/dsh-fs` `FileSystem` seam, on plain `{ targetKey, displayPath }`
|
|
8
|
+
* targets and plain version strings.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { readFile, readdir, rename, rm, stat, lstat, realpath, writeFile } from 'node:fs/promises'
|
|
12
|
+
import { isAbsolute, join, relative, resolve, dirname, basename, sep } from 'node:path'
|
|
13
|
+
import { pathToFileURL } from 'node:url'
|
|
14
|
+
import { fsError } from './errors.js'
|
|
15
|
+
import { writableRoots, isPathUnder } from './containment.js'
|
|
16
|
+
|
|
17
|
+
function versionOf(st) {
|
|
18
|
+
return `mtime:${Math.round(st.mtimeMs)}:size:${st.size}`
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function typeOf(st) {
|
|
22
|
+
if (st.isDirectory()) return 'directory'
|
|
23
|
+
if (st.isFile()) return 'file'
|
|
24
|
+
return 'other'
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class LocalBackend {
|
|
28
|
+
constructor({ cwd = process.cwd(), getPolicy } = {}) {
|
|
29
|
+
this.cwd = cwd
|
|
30
|
+
this.getPolicy = getPolicy
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async resolve(path, opts = {}) {
|
|
34
|
+
const base = opts.cwd ?? this.cwd
|
|
35
|
+
const displayPath = isAbsolute(path) ? resolve(path) : resolve(base, path)
|
|
36
|
+
let targetKey = displayPath
|
|
37
|
+
try {
|
|
38
|
+
targetKey = await realpath(displayPath)
|
|
39
|
+
} catch {
|
|
40
|
+
// A path about to be created has no canonical form yet; keep the resolved path.
|
|
41
|
+
}
|
|
42
|
+
return { targetKey, displayPath }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
processPath(target) {
|
|
46
|
+
return target.targetKey
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
fileUrl(target) {
|
|
50
|
+
return pathToFileURL(target.targetKey).href
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
contains(parent, child) {
|
|
54
|
+
const rel = relative(parent.targetKey, child.targetKey)
|
|
55
|
+
return rel === '' || (rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel))
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async stat(target) {
|
|
59
|
+
try {
|
|
60
|
+
const st = await stat(target.targetKey)
|
|
61
|
+
return { version: versionOf(st), type: typeOf(st), ...(st.isFile() ? { size: st.size } : {}) }
|
|
62
|
+
} catch (error) {
|
|
63
|
+
if (error && error.code === 'ENOENT') return undefined
|
|
64
|
+
throw fsError('FS_IO_ERROR', `cannot stat "${target.displayPath}": ${error.message}`, error)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async lstat(path, opts = {}) {
|
|
69
|
+
const base = opts.cwd ?? this.cwd
|
|
70
|
+
const full = isAbsolute(path) ? path : resolve(base, path)
|
|
71
|
+
try {
|
|
72
|
+
const st = await lstat(full)
|
|
73
|
+
const type = st.isSymbolicLink() ? 'symlink' : typeOf(st)
|
|
74
|
+
return { version: versionOf(st), type, ...(st.isFile() ? { size: st.size } : {}) }
|
|
75
|
+
} catch (error) {
|
|
76
|
+
if (error && error.code === 'ENOENT') return undefined
|
|
77
|
+
throw fsError('FS_IO_ERROR', `cannot lstat "${full}": ${error.message}`, error)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async readText(target) {
|
|
82
|
+
try {
|
|
83
|
+
const text = await readFile(target.targetKey, 'utf8')
|
|
84
|
+
if (text.includes('\0')) throw fsError('FS_NOT_TEXT', `cannot read "${target.displayPath}": binary file`)
|
|
85
|
+
return text
|
|
86
|
+
} catch (error) {
|
|
87
|
+
if (error && error.code) throw error
|
|
88
|
+
throw fsError('FS_IO_ERROR', `cannot read "${target.displayPath}": ${error.message}`, error)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async streamText(target) {
|
|
93
|
+
const text = await this.readText(target)
|
|
94
|
+
return {
|
|
95
|
+
async *[Symbol.asyncIterator]() {
|
|
96
|
+
yield text
|
|
97
|
+
},
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async readBytes(target, signal, maxBytes) {
|
|
102
|
+
const info = await this.stat(target)
|
|
103
|
+
if (info === undefined) throw fsError('FS_NOT_FOUND', `cannot read "${target.displayPath}": not found`)
|
|
104
|
+
if (info.type !== 'file') throw fsError('FS_NOT_REGULAR_FILE', `cannot read "${target.displayPath}": not a regular file`)
|
|
105
|
+
if (info.size !== undefined && info.size > maxBytes) {
|
|
106
|
+
throw fsError('FS_TOO_LARGE', `cannot read "${target.displayPath}": ${info.size} bytes exceeds the ${maxBytes}-byte limit`)
|
|
107
|
+
}
|
|
108
|
+
try {
|
|
109
|
+
return await readFile(target.targetKey)
|
|
110
|
+
} catch (error) {
|
|
111
|
+
throw fsError('FS_IO_ERROR', `cannot read "${target.displayPath}": ${error.message}`, error)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async listDir(target) {
|
|
116
|
+
try {
|
|
117
|
+
const entries = await readdir(target.targetKey, { withFileTypes: true })
|
|
118
|
+
return entries
|
|
119
|
+
.map((entry) => ({
|
|
120
|
+
name: entry.name,
|
|
121
|
+
type: entry.isDirectory() ? 'directory' : entry.isFile() ? 'file' : 'other',
|
|
122
|
+
target: {
|
|
123
|
+
targetKey: join(target.targetKey, entry.name),
|
|
124
|
+
displayPath: join(target.displayPath, entry.name),
|
|
125
|
+
},
|
|
126
|
+
}))
|
|
127
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
128
|
+
} catch (error) {
|
|
129
|
+
if (error && error.code === 'ENOENT') throw fsError('FS_NOT_FOUND', `cannot list "${target.displayPath}": not found`, error)
|
|
130
|
+
throw fsError('FS_IO_ERROR', `cannot list "${target.displayPath}": ${error.message}`, error)
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async writeText(target, content, expected, signal, sandboxPolicy) {
|
|
135
|
+
const t = await this.checkedTarget(target, sandboxPolicy)
|
|
136
|
+
const existing = await this.stat(t)
|
|
137
|
+
if (existing !== undefined && existing.type !== 'file') {
|
|
138
|
+
throw fsError('FS_NOT_REGULAR_FILE', `cannot write "${t.displayPath}": not a regular file`)
|
|
139
|
+
}
|
|
140
|
+
if (expected && expected.kind === 'createIfAbsent' && existing !== undefined) {
|
|
141
|
+
throw fsError('FS_NOT_OBSERVED', `cannot overwrite existing "${t.displayPath}" without reading it first`)
|
|
142
|
+
}
|
|
143
|
+
if (expected && expected.kind === 'replaceIfVersion') {
|
|
144
|
+
if (existing === undefined || existing.version !== expected.version) {
|
|
145
|
+
throw fsError('FS_STALE_VERSION', `cannot write "${t.displayPath}": file changed since it was read`)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
let before = null
|
|
149
|
+
if (existing !== undefined && existing.type === 'file') {
|
|
150
|
+
try { before = await this.readText(t) } catch { before = null }
|
|
151
|
+
}
|
|
152
|
+
await this.atomicWrite(t.targetKey, content)
|
|
153
|
+
const after = await this.stat(t)
|
|
154
|
+
return { operation: existing === undefined ? 'create' : 'update', version: after.version, before, after: content }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async editText(target, edit, expected, signal, sandboxPolicy) {
|
|
158
|
+
const t = await this.checkedTarget(target, sandboxPolicy)
|
|
159
|
+
const existing = await this.stat(t)
|
|
160
|
+
if (existing === undefined) throw fsError('FS_STALE_VERSION', `cannot edit "${t.displayPath}": file changed since it was read`)
|
|
161
|
+
if (existing.type !== 'file') throw fsError('FS_NOT_REGULAR_FILE', `cannot edit "${t.displayPath}": not a regular file`)
|
|
162
|
+
if (expected && existing.version !== expected.version) {
|
|
163
|
+
throw fsError('FS_STALE_VERSION', `cannot edit "${t.displayPath}": file changed since it was read`)
|
|
164
|
+
}
|
|
165
|
+
const content = await this.readText(t)
|
|
166
|
+
const oldString = edit.oldString
|
|
167
|
+
if (!oldString) throw fsError('FS_EDIT_NOT_FOUND', `cannot edit "${t.displayPath}": old_string must be non-empty`)
|
|
168
|
+
let matches = 0
|
|
169
|
+
let offset = 0
|
|
170
|
+
while (true) {
|
|
171
|
+
const found = content.indexOf(oldString, offset)
|
|
172
|
+
if (found < 0) break
|
|
173
|
+
matches += 1
|
|
174
|
+
offset = found + oldString.length
|
|
175
|
+
}
|
|
176
|
+
if (matches === 0) throw fsError('FS_EDIT_NOT_FOUND', `cannot edit "${t.displayPath}": old_string was not found`)
|
|
177
|
+
if (!edit.replaceAll && matches !== 1) {
|
|
178
|
+
throw fsError('FS_AMBIGUOUS_EDIT', `cannot edit "${t.displayPath}": old_string matched ${matches} times`)
|
|
179
|
+
}
|
|
180
|
+
const next = edit.replaceAll ? content.split(oldString).join(edit.newString) : content.replace(oldString, edit.newString)
|
|
181
|
+
await this.atomicWrite(t.targetKey, next)
|
|
182
|
+
const after = await this.stat(t)
|
|
183
|
+
return { version: after.version, before: content, after: next }
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Enforce the per-call sandbox policy against `target` (mirrors the harness's
|
|
188
|
+
* `@deepseek-ai/dsh-fs-sandbox` fence). No policy service or
|
|
189
|
+
* `danger-full-access` → target unchanged; `read-only` → deny;
|
|
190
|
+
* `workspace-write` → re-canonicalize and require containment under a
|
|
191
|
+
* writable root.
|
|
192
|
+
*/
|
|
193
|
+
async checkedTarget(target, sandboxPolicy) {
|
|
194
|
+
const policy = sandboxPolicy ?? this.getPolicy?.()?.resolve?.()
|
|
195
|
+
if (policy === undefined) return target
|
|
196
|
+
const { mode } = policy
|
|
197
|
+
if (mode === 'danger-full-access') return target
|
|
198
|
+
if (mode === 'read-only') {
|
|
199
|
+
throw fsError('FS_SANDBOX_DENIED', `cannot write "${target.displayPath}": file access denied under read-only mode`)
|
|
200
|
+
}
|
|
201
|
+
const fresh = await this.resolve(target.displayPath)
|
|
202
|
+
let contained = false
|
|
203
|
+
for (const root of writableRoots(policy)) {
|
|
204
|
+
if (await isPathUnder(fresh.targetKey, root)) { contained = true; break }
|
|
205
|
+
}
|
|
206
|
+
if (!contained) {
|
|
207
|
+
throw fsError('FS_SANDBOX_DENIED', `cannot write "${target.displayPath}": file access denied under workspace-write mode`)
|
|
208
|
+
}
|
|
209
|
+
return fresh
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async atomicWrite(targetKey, content) {
|
|
213
|
+
const tmp = join(dirname(targetKey), `.dsh-${basename(targetKey)}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`)
|
|
214
|
+
try {
|
|
215
|
+
await writeFile(tmp, content, 'utf8')
|
|
216
|
+
await rename(tmp, targetKey)
|
|
217
|
+
} catch (error) {
|
|
218
|
+
await rm(tmp, { force: true }).catch(() => {})
|
|
219
|
+
throw fsError('FS_IO_ERROR', `cannot write "${targetKey}": ${error.message}`, error)
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export default LocalBackend
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { randomUUID, randomBytes, createCipheriv, createDecipheriv } from 'node:crypto'
|
|
2
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { homedir } from 'node:os'
|
|
4
|
+
import { dirname, join, resolve } from 'node:path'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Persistent multi-machine SSH registry, stored as a local JSON file under
|
|
8
|
+
* the DSH home (matching the harness's own data location):
|
|
9
|
+
*
|
|
10
|
+
* <dsh-home>/remote-workspaces/machines.json
|
|
11
|
+
*
|
|
12
|
+
* Secrets (`password`, `passphrase`) are AES-256-GCM encrypted at rest with a
|
|
13
|
+
* key kept in a separate file (`<dsh-home>/remote-workspaces/.secret-key`), so
|
|
14
|
+
* a plaintext dump of machines.json never exposes them. They are decrypted only
|
|
15
|
+
* in memory and never sent back to the browser — `sanitizeMachine` strips them,
|
|
16
|
+
* exposing only a `hasPassword`/`hasPassphrase` flag. On POSIX both files are
|
|
17
|
+
* chmod'd 0600; on Windows the OS ACL applies (the encrypted form still stops
|
|
18
|
+
* casual/grep-style disclosure). Legacy plaintext secrets are read transparently
|
|
19
|
+
* and re-encrypted on the next write (see `ensureSecretsEncrypted`).
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const SECRET_PREFIX = 'enc:v1:'
|
|
23
|
+
const KEY_NAME = '.secret-key'
|
|
24
|
+
|
|
25
|
+
function expandHomePath(value) {
|
|
26
|
+
if (value === '~') return homedir()
|
|
27
|
+
if (value.startsWith('~/') || value.startsWith('~\\')) return join(homedir(), value.slice(2))
|
|
28
|
+
return value
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Mirror of the harness's `resolveDshHome()`: `$DSH_HOME` (non-blank) else `~/.dsh`. */
|
|
32
|
+
export function machinesRoot() {
|
|
33
|
+
const fromEnv = process.env.DSH_HOME
|
|
34
|
+
const selected = fromEnv !== undefined && String(fromEnv).trim().length > 0
|
|
35
|
+
? fromEnv
|
|
36
|
+
: join(homedir(), '.dsh')
|
|
37
|
+
return resolve(expandHomePath(selected))
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function machinesPath() {
|
|
41
|
+
return join(machinesRoot(), 'remote-workspaces', 'machines.json')
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function keyPath() {
|
|
45
|
+
return join(machinesRoot(), 'remote-workspaces', KEY_NAME)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Load (or create) the local 32-byte encryption key. Creating it on first use
|
|
50
|
+
* keeps it out of the registry file itself; a machine that loses the key file
|
|
51
|
+
* simply cannot decrypt old secrets (a fresh key is created and the user
|
|
52
|
+
* re-enters the credentials).
|
|
53
|
+
*/
|
|
54
|
+
function loadKey() {
|
|
55
|
+
const path = keyPath()
|
|
56
|
+
try {
|
|
57
|
+
const hex = readFileSync(path, 'utf8').trim()
|
|
58
|
+
const buf = Buffer.from(hex, 'hex')
|
|
59
|
+
if (buf.length === 32) return buf
|
|
60
|
+
} catch {}
|
|
61
|
+
const key = randomBytes(32)
|
|
62
|
+
try {
|
|
63
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
64
|
+
writeFileSync(path, key.toString('hex') + '\n', { encoding: 'utf8', mode: 0o600 })
|
|
65
|
+
try { chmodSync(path, 0o600) } catch {}
|
|
66
|
+
} catch {}
|
|
67
|
+
return key
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** `enc:v1:<iv><tag><ciphertext>` (base64) for a non-empty, not-yet-encrypted secret. */
|
|
71
|
+
function encryptSecret(value) {
|
|
72
|
+
if (value === undefined || value === null) return value
|
|
73
|
+
const s = String(value)
|
|
74
|
+
if (s === '' || s.startsWith(SECRET_PREFIX)) return s
|
|
75
|
+
const key = loadKey()
|
|
76
|
+
const iv = randomBytes(12)
|
|
77
|
+
const cipher = createCipheriv('aes-256-gcm', key, iv)
|
|
78
|
+
const enc = Buffer.concat([cipher.update(s, 'utf8'), cipher.final()])
|
|
79
|
+
const tag = cipher.getAuthTag()
|
|
80
|
+
return SECRET_PREFIX + Buffer.concat([iv, tag, enc]).toString('base64')
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Decrypt an `enc:` secret; pass legacy plaintext through; `undefined` on failure. */
|
|
84
|
+
function decryptSecret(value) {
|
|
85
|
+
if (typeof value !== 'string' || !value.startsWith(SECRET_PREFIX)) return value
|
|
86
|
+
try {
|
|
87
|
+
const key = loadKey()
|
|
88
|
+
const raw = Buffer.from(value.slice(SECRET_PREFIX.length), 'base64')
|
|
89
|
+
if (raw.length < 28) return undefined
|
|
90
|
+
const iv = raw.subarray(0, 12)
|
|
91
|
+
const tag = raw.subarray(12, 28)
|
|
92
|
+
const enc = raw.subarray(28)
|
|
93
|
+
const decipher = createDecipheriv('aes-256-gcm', key, iv)
|
|
94
|
+
decipher.setAuthTag(tag)
|
|
95
|
+
return Buffer.concat([decipher.update(enc), decipher.final()]).toString('utf8')
|
|
96
|
+
} catch {
|
|
97
|
+
return undefined
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function encryptMachineSecrets(machine) {
|
|
102
|
+
const out = { ...machine }
|
|
103
|
+
for (const field of ['password', 'passphrase']) {
|
|
104
|
+
if (out[field] === undefined) continue
|
|
105
|
+
out[field] = encryptSecret(out[field])
|
|
106
|
+
}
|
|
107
|
+
return out
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function decryptMachineSecrets(machine) {
|
|
111
|
+
const out = { ...machine }
|
|
112
|
+
for (const field of ['password', 'passphrase']) {
|
|
113
|
+
if (out[field] === undefined || out[field] === null) continue
|
|
114
|
+
const plain = decryptSecret(out[field])
|
|
115
|
+
if (plain === undefined) delete out[field]
|
|
116
|
+
else out[field] = plain
|
|
117
|
+
}
|
|
118
|
+
return out
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function isPlainSecret(value) {
|
|
122
|
+
return typeof value === 'string' && value.length > 0 && !value.startsWith(SECRET_PREFIX)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function loadMachines() {
|
|
126
|
+
const path = machinesPath()
|
|
127
|
+
if (!existsSync(path)) return []
|
|
128
|
+
try {
|
|
129
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'))
|
|
130
|
+
const machines = parsed && typeof parsed === 'object' ? parsed.machines : undefined
|
|
131
|
+
return Array.isArray(machines)
|
|
132
|
+
? machines.filter((m) => m && typeof m === 'object').map(decryptMachineSecrets)
|
|
133
|
+
: []
|
|
134
|
+
} catch {
|
|
135
|
+
return []
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function persistMachines(machines) {
|
|
140
|
+
const path = machinesPath()
|
|
141
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
142
|
+
writeFileSync(path, JSON.stringify({ machines: machines.map(encryptMachineSecrets) }, null, 2) + '\n', 'utf8')
|
|
143
|
+
try { chmodSync(path, 0o600) } catch {}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* One-time migration: if the on-disk registry still holds plaintext secrets,
|
|
148
|
+
* re-persist it (which encrypts them). Called once at plugin startup so an
|
|
149
|
+
* existing install becomes secure without the user having to edit a host.
|
|
150
|
+
* Returns true when a rewrite happened.
|
|
151
|
+
*/
|
|
152
|
+
export function ensureSecretsEncrypted() {
|
|
153
|
+
const path = machinesPath()
|
|
154
|
+
if (!existsSync(path)) return false
|
|
155
|
+
try {
|
|
156
|
+
const raw = JSON.parse(readFileSync(path, 'utf8'))
|
|
157
|
+
const machines = raw && typeof raw === 'object' ? raw.machines : undefined
|
|
158
|
+
if (!Array.isArray(machines)) return false
|
|
159
|
+
const hasPlain = machines.some((m) => m && typeof m === 'object' && (
|
|
160
|
+
isPlainSecret(m.password) || isPlainSecret(m.passphrase)
|
|
161
|
+
))
|
|
162
|
+
if (!hasPlain) return false
|
|
163
|
+
persistMachines(machines)
|
|
164
|
+
return true
|
|
165
|
+
} catch {
|
|
166
|
+
return false
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Projection safe to cross the Remote boundary (no password, no passphrase). */
|
|
171
|
+
export function sanitizeMachine(machine) {
|
|
172
|
+
return {
|
|
173
|
+
id: machine.id,
|
|
174
|
+
alias: machine.alias ?? '',
|
|
175
|
+
host: machine.host ?? '',
|
|
176
|
+
port: machine.port ?? null,
|
|
177
|
+
user: machine.user ?? null,
|
|
178
|
+
identityFile: machine.identityFile ?? null,
|
|
179
|
+
hasPassword: Boolean(machine.password),
|
|
180
|
+
hasPassphrase: Boolean(machine.passphrase),
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Insert or update one machine by id. A missing id creates a new machine.
|
|
186
|
+
* `password`/`passphrase` are one-way writes: an empty string clears the stored
|
|
187
|
+
* secret, and `undefined`/absent leaves the existing value untouched.
|
|
188
|
+
*/
|
|
189
|
+
export function upsertMachine(input) {
|
|
190
|
+
const machines = loadMachines()
|
|
191
|
+
let machine = input.id === undefined ? undefined : machines.find((m) => m.id === input.id)
|
|
192
|
+
if (machine === undefined) {
|
|
193
|
+
machine = { id: randomUUID() }
|
|
194
|
+
machines.push(machine)
|
|
195
|
+
}
|
|
196
|
+
if (input.alias !== undefined) machine.alias = String(input.alias ?? '')
|
|
197
|
+
if (input.host !== undefined) machine.host = String(input.host ?? '')
|
|
198
|
+
if (input.port !== undefined) machine.port = input.port === null || input.port === '' ? null : Number(input.port) || null
|
|
199
|
+
if (input.user !== undefined) machine.user = input.user === null || input.user === '' ? null : String(input.user)
|
|
200
|
+
if (input.identityFile !== undefined) {
|
|
201
|
+
machine.identityFile = input.identityFile === null || input.identityFile === '' ? null : String(input.identityFile)
|
|
202
|
+
}
|
|
203
|
+
if (input.password === '') delete machine.password
|
|
204
|
+
else if (input.password !== undefined) machine.password = String(input.password)
|
|
205
|
+
if (input.passphrase === '') delete machine.passphrase
|
|
206
|
+
else if (input.passphrase !== undefined) machine.passphrase = String(input.passphrase)
|
|
207
|
+
persistMachines(machines)
|
|
208
|
+
return sanitizeMachine(machine)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function removeMachine(id) {
|
|
212
|
+
const machines = loadMachines()
|
|
213
|
+
const next = machines.filter((m) => m.id !== id)
|
|
214
|
+
persistMachines(next)
|
|
215
|
+
return next.length !== machines.length
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** The raw record (with secrets) used to build an SSH connection. */
|
|
219
|
+
export function machineById(id) {
|
|
220
|
+
return loadMachines().find((m) => m.id === id)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Find a saved machine by its connection identity (host + user + effective
|
|
225
|
+
* port). Mirrors record only these fields in their `.dsh-remote-meta.json`,
|
|
226
|
+
* so reconnecting for sync matches them back to a stored machine (and its
|
|
227
|
+
* secret) this way, surviving machine id churn.
|
|
228
|
+
*/
|
|
229
|
+
export function machineForRemote({ host, port, user }) {
|
|
230
|
+
const h = String(host ?? '')
|
|
231
|
+
const u = String(user ?? '')
|
|
232
|
+
const p = Number(port) || 22
|
|
233
|
+
return loadMachines().find((m) => {
|
|
234
|
+
return String(m.host ?? '') === h
|
|
235
|
+
&& String(m.user ?? '') === u
|
|
236
|
+
&& (Number(m.port) || 22) === p
|
|
237
|
+
})
|
|
238
|
+
}
|
package/src/registry.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Remote-workspace registry: the durable map from a LOCAL anchor directory to
|
|
3
|
+
* its remote origin (machine + remote path). This is the routing key the
|
|
4
|
+
* `RoutingFileSystem` and `SshShellExecutor` consult per session cwd.
|
|
5
|
+
*
|
|
6
|
+
* An anchor is an EMPTY local directory adopted by the harness as the
|
|
7
|
+
* workspace identity (`fs.realpath` must resolve it); all file/command I/O is
|
|
8
|
+
* routed to the remote, never through the anchor's contents.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
12
|
+
import { join, sep } from 'node:path'
|
|
13
|
+
import { remoteWorkspacesRoot } from './anchor.js'
|
|
14
|
+
|
|
15
|
+
function anchorsPath() {
|
|
16
|
+
return join(remoteWorkspacesRoot(), 'anchors.json')
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Load the anchor map (anchorPath → record). Returns {} when absent. */
|
|
20
|
+
export function loadAnchors() {
|
|
21
|
+
const file = anchorsPath()
|
|
22
|
+
if (!existsSync(file)) return {}
|
|
23
|
+
try {
|
|
24
|
+
const parsed = JSON.parse(readFileSync(file, 'utf8'))
|
|
25
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}
|
|
26
|
+
} catch {
|
|
27
|
+
return {}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function saveAnchors(anchors) {
|
|
32
|
+
const root = remoteWorkspacesRoot()
|
|
33
|
+
try { mkdirSync(root, { recursive: true }) } catch {}
|
|
34
|
+
writeFileSync(anchorsPath(), JSON.stringify(anchors, null, 2) + '\n', 'utf8')
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Record an anchor (idempotent by anchorPath). Returns the stored record. */
|
|
38
|
+
export function registerAnchor({ anchorPath, machineId, host, port, user, remotePath }) {
|
|
39
|
+
const anchors = loadAnchors()
|
|
40
|
+
anchors[anchorPath] = { machineId, host, port, user, remotePath, registeredAt: new Date().toISOString() }
|
|
41
|
+
saveAnchors(anchors)
|
|
42
|
+
return anchors[anchorPath]
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Remove one anchor by its local path. */
|
|
46
|
+
export function unregisterAnchor(anchorPath) {
|
|
47
|
+
const anchors = loadAnchors()
|
|
48
|
+
delete anchors[anchorPath]
|
|
49
|
+
saveAnchors(anchors)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Resolve a session cwd (the anchor path, or any descendant) to its remote
|
|
54
|
+
* origin. Returns `{ anchorPath, remotePath, remoteSubpath, host, port, user, machineId }`
|
|
55
|
+
* or `undefined` when the cwd is not under any registered anchor.
|
|
56
|
+
*/
|
|
57
|
+
export function findByCwd(cwd) {
|
|
58
|
+
if (typeof cwd !== 'string' || cwd === '') return undefined
|
|
59
|
+
const anchors = loadAnchors()
|
|
60
|
+
let best
|
|
61
|
+
let bestLen = -1
|
|
62
|
+
for (const [anchorPath, rec] of Object.entries(anchors)) {
|
|
63
|
+
const base = anchorPath.endsWith(sep) ? anchorPath : anchorPath + sep
|
|
64
|
+
if (cwd === anchorPath || cwd.startsWith(base)) {
|
|
65
|
+
if (anchorPath.length > bestLen) {
|
|
66
|
+
bestLen = anchorPath.length
|
|
67
|
+
best = { anchorPath, ...rec }
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (best === undefined) return undefined
|
|
72
|
+
const rel = cwd === best.anchorPath ? '' : cwd.slice(best.anchorPath.length + sep.length)
|
|
73
|
+
return { ...best, remoteSubpath: rel === '' ? '' : rel.split(sep).join('/') }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export default { loadAnchors, registerAnchor, unregisterAnchor, findByCwd, remoteWorkspacesRoot }
|