@tiphareth/dsh-hardssh 0.1.2-alpha
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +50 -0
- package/cordis.patch.yml +26 -0
- package/lib/client.js +17794 -0
- package/lib/environment-BL1jddfB.js +449 -0
- package/lib/fs.js +478 -0
- package/lib/index.js +6758 -0
- package/lib/subprocess.js +600 -0
- package/lib/switch-fs-CAJpFY9C.js +193 -0
- package/lib/switch-fs-RrZtG2gv.js +210 -0
- package/lib/types/backend.d.ts +108 -0
- package/lib/types/base/capability.d.ts +107 -0
- package/lib/types/base/index.d.ts +18 -0
- package/lib/types/base/ledger-router.d.ts +48 -0
- package/lib/types/base/ledger.d.ts +82 -0
- package/lib/types/base/model.d.ts +108 -0
- package/lib/types/base/namespace.d.ts +57 -0
- package/lib/types/base/plugin.d.ts +102 -0
- package/lib/types/base/registry.d.ts +60 -0
- package/lib/types/base/router.d.ts +40 -0
- package/lib/types/client/api.d.ts +85 -0
- package/lib/types/client/directory-flow.d.ts +82 -0
- package/lib/types/client/icons.d.ts +19 -0
- package/lib/types/client/index.d.ts +34 -0
- package/lib/types/client/locales.d.ts +104 -0
- package/lib/types/client/manager-button.d.ts +32 -0
- package/lib/types/client/migrate.d.ts +20 -0
- package/lib/types/client/ssh/api.d.ts +89 -0
- package/lib/types/client/ssh/apply.d.ts +23 -0
- package/lib/types/client/ssh/locales.d.ts +156 -0
- package/lib/types/client/ssh/mount.d.ts +13 -0
- package/lib/types/client/ssh/panel/ClusterTab.d.ts +8 -0
- package/lib/types/client/ssh/panel/HostFingerprintDialog.d.ts +16 -0
- package/lib/types/client/ssh/panel/HostFormDialog.d.ts +13 -0
- package/lib/types/client/ssh/panel/HostsTab.d.ts +10 -0
- package/lib/types/client/ssh/panel/SessionSecretDialog.d.ts +16 -0
- package/lib/types/client/ssh/panel/SshPanel.d.ts +14 -0
- package/lib/types/client/ssh/panel/TerminalTab.d.ts +12 -0
- package/lib/types/client/ssh/panel/TransferTab.d.ts +8 -0
- package/lib/types/client/ssh/panel/TunnelsTab.d.ts +8 -0
- package/lib/types/client/ssh/panel/controller.d.ts +23 -0
- package/lib/types/client/ssh/panel/helpers.d.ts +15 -0
- package/lib/types/client/ssh/panel/xterm.css.d.ts +3 -0
- package/lib/types/client/ssh/sidebar-entry.d.ts +25 -0
- package/lib/types/client/state.d.ts +32 -0
- package/lib/types/client/text.d.ts +11 -0
- package/lib/types/client/workspace-badges.d.ts +38 -0
- package/lib/types/client/workspace-gate.d.ts +15 -0
- package/lib/types/client-http.d.ts +15 -0
- package/lib/types/core.d.ts +43 -0
- package/lib/types/fs.d.ts +36 -0
- package/lib/types/host-http.d.ts +25 -0
- package/lib/types/index.d.ts +53 -0
- package/lib/types/ledger.d.ts +132 -0
- package/lib/types/protocol.d.ts +101 -0
- package/lib/types/providers/index.d.ts +19 -0
- package/lib/types/providers/local/provider.d.ts +58 -0
- package/lib/types/providers/ssh/provider.d.ts +112 -0
- package/lib/types/remote/environment.d.ts +29 -0
- package/lib/types/remote/output.d.ts +38 -0
- package/lib/types/remote/remote-fs.d.ts +69 -0
- package/lib/types/remote/remote-process.d.ts +41 -0
- package/lib/types/remote/remote-subprocess.d.ts +31 -0
- package/lib/types/remote/remote-terminal.d.ts +41 -0
- package/lib/types/remote-runner.d.ts +83 -0
- package/lib/types/remote-search.d.ts +50 -0
- package/lib/types/routes.d.ts +24 -0
- package/lib/types/runtime/workspace-core.d.ts +59 -0
- package/lib/types/seam-state.d.ts +69 -0
- package/lib/types/shell.d.ts +8 -0
- package/lib/types/ssh/connection/lease.d.ts +16 -0
- package/lib/types/ssh/connection/pool.d.ts +57 -0
- package/lib/types/ssh/engine.d.ts +434 -0
- package/lib/types/ssh/exec/output.d.ts +31 -0
- package/lib/types/ssh/known-hosts.d.ts +89 -0
- package/lib/types/ssh/plugin.d.ts +57 -0
- package/lib/types/ssh/protocol.d.ts +236 -0
- package/lib/types/ssh/routes.d.ts +44 -0
- package/lib/types/ssh/store.d.ts +107 -0
- package/lib/types/ssh/tools.d.ts +35 -0
- package/lib/types/ssh/transfer/progress.d.ts +14 -0
- package/lib/types/ssh/vault.d.ts +110 -0
- package/lib/types/subprocess.d.ts +32 -0
- package/lib/types/switch/switch-fs.d.ts +88 -0
- package/lib/types/switch/switch-subprocess.d.ts +34 -0
- package/lib/types/tools.d.ts +12 -0
- package/package.json +142 -0
- package/src/backend.ts +624 -0
- package/src/base/capability.ts +86 -0
- package/src/base/index.ts +18 -0
- package/src/base/ledger-router.ts +128 -0
- package/src/base/ledger.ts +299 -0
- package/src/base/model.ts +118 -0
- package/src/base/namespace.ts +102 -0
- package/src/base/plugin.ts +170 -0
- package/src/base/registry.ts +109 -0
- package/src/base/router.ts +43 -0
- package/src/client/api.ts +196 -0
- package/src/client/css-modules.d.ts +5 -0
- package/src/client/directory-flow.tsx +482 -0
- package/src/client/icons.tsx +50 -0
- package/src/client/index.ts +210 -0
- package/src/client/locales.ts +105 -0
- package/src/client/manager-button.tsx +269 -0
- package/src/client/migrate.ts +109 -0
- package/src/client/ssh/api.ts +411 -0
- package/src/client/ssh/apply.ts +50 -0
- package/src/client/ssh/locales.ts +322 -0
- package/src/client/ssh/mount.tsx +83 -0
- package/src/client/ssh/panel/ClusterTab.tsx +123 -0
- package/src/client/ssh/panel/HostFingerprintDialog.tsx +82 -0
- package/src/client/ssh/panel/HostFormDialog.tsx +228 -0
- package/src/client/ssh/panel/HostsTab.tsx +236 -0
- package/src/client/ssh/panel/SessionSecretDialog.tsx +80 -0
- package/src/client/ssh/panel/SshPanel.tsx +77 -0
- package/src/client/ssh/panel/TerminalTab.tsx +176 -0
- package/src/client/ssh/panel/TransferTab.tsx +232 -0
- package/src/client/ssh/panel/TunnelsTab.tsx +177 -0
- package/src/client/ssh/panel/controller.ts +48 -0
- package/src/client/ssh/panel/helpers.ts +26 -0
- package/src/client/ssh/panel/panel.module.css +1006 -0
- package/src/client/ssh/panel/xterm.css.ts +2 -0
- package/src/client/ssh/sidebar-entry.ts +123 -0
- package/src/client/state.ts +99 -0
- package/src/client/text.ts +22 -0
- package/src/client/workspace-badges.ts +99 -0
- package/src/client/workspace-gate.ts +232 -0
- package/src/client/workspace.module.css +283 -0
- package/src/client-http.ts +45 -0
- package/src/core.ts +47 -0
- package/src/fs.ts +85 -0
- package/src/host-http.ts +74 -0
- package/src/index.ts +244 -0
- package/src/ledger.ts +416 -0
- package/src/protocol.ts +114 -0
- package/src/providers/index.ts +34 -0
- package/src/providers/local/provider.ts +179 -0
- package/src/providers/ssh/provider.ts +274 -0
- package/src/remote/environment.ts +123 -0
- package/src/remote/output.ts +142 -0
- package/src/remote/remote-fs.ts +532 -0
- package/src/remote/remote-process.ts +203 -0
- package/src/remote/remote-subprocess.ts +159 -0
- package/src/remote/remote-terminal.ts +141 -0
- package/src/remote-runner.ts +201 -0
- package/src/remote-search.ts +163 -0
- package/src/routes.ts +395 -0
- package/src/runtime/workspace-core.ts +154 -0
- package/src/seam-state.ts +185 -0
- package/src/shell.ts +10 -0
- package/src/ssh/connection/lease.ts +17 -0
- package/src/ssh/connection/pool.ts +275 -0
- package/src/ssh/engine.ts +1761 -0
- package/src/ssh/exec/output.ts +70 -0
- package/src/ssh/known-hosts.ts +214 -0
- package/src/ssh/plugin.ts +184 -0
- package/src/ssh/protocol.ts +227 -0
- package/src/ssh/routes.ts +892 -0
- package/src/ssh/store.ts +544 -0
- package/src/ssh/tools.ts +402 -0
- package/src/ssh/transfer/progress.ts +75 -0
- package/src/ssh/vault.ts +477 -0
- package/src/subprocess.ts +71 -0
- package/src/switch/switch-fs.ts +253 -0
- package/src/switch/switch-subprocess.ts +59 -0
- package/src/tools.ts +221 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-hardssh — host half. Owns the local⇄remote mode store, the
|
|
3
|
+
* /api/dsh-hardssh route family (loopback-only), the remote_* agent
|
|
4
|
+
* tools, a model-facing announcement section, the shared workspace core
|
|
5
|
+
* (`ctx.hardsshCore`, including the shared seam state the fs/subprocess
|
|
6
|
+
* switch rows consume), and — since the legacy dsh-ssh package was merged
|
|
7
|
+
* in — the SSH operations capability (host manager, ssh_* tools, /api/dsh-ssh,
|
|
8
|
+
* web terminal). In SSH mode the model's ordinary read/write/edit/bash tools
|
|
9
|
+
* run transparently on the remote host through those switch rows. File
|
|
10
|
+
* operations and SSH operations ride ONE shared SshEngine/HostStore over
|
|
11
|
+
* ~/.dsh/dsh-ssh.json (a single connection pool; SSH ops and workspaces
|
|
12
|
+
* invalidate together on config change). The browser half (./client) renders
|
|
13
|
+
* the header buttons, the SSH config dialog, the left workspace panel, and
|
|
14
|
+
* the SSH host-manager surfaces.
|
|
15
|
+
*
|
|
16
|
+
* The announcement section is rendered PER SESSION from the ledger facts
|
|
17
|
+
* (which workspace this session's cwd binds to), never from path-string
|
|
18
|
+
* heuristics; and a global tool guard blocks glob/grep/pwsh calls in
|
|
19
|
+
* SSH-bound sessions with a clear error instead of letting them fail with
|
|
20
|
+
* "No such file or directory" on the remote host.
|
|
21
|
+
*/
|
|
22
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
23
|
+
import type {} from '@deepseek-ai/dsh-host-webserver'
|
|
24
|
+
import type {} from '@deepseek-ai/dsh-system-prompt'
|
|
25
|
+
import type {} from '@deepseek-ai/dsh-tools'
|
|
26
|
+
import z from '@deepseek-ai/schemastery'
|
|
27
|
+
import type { HardsshCore } from './core.ts'
|
|
28
|
+
import { SshWorkspaceLedger } from './ledger.ts'
|
|
29
|
+
import type { SshWorkspaceRecord } from './protocol.ts'
|
|
30
|
+
import { LedgerWorkspaceFileService } from './backend.ts'
|
|
31
|
+
import { RemoteWorkspaceRunner } from './remote-runner.ts'
|
|
32
|
+
import { makeRoutes } from './routes.ts'
|
|
33
|
+
import { makeWorkspaceTools } from './tools.ts'
|
|
34
|
+
import { WorkspaceSeamState } from './seam-state.ts'
|
|
35
|
+
import { SshEngine } from './ssh/engine.ts'
|
|
36
|
+
import { mountSshCapability } from './ssh/plugin.ts'
|
|
37
|
+
import { HostStore } from './ssh/store.ts'
|
|
38
|
+
import { SecureHostStore } from './ssh/store.ts'
|
|
39
|
+
import { KnownHostsStore } from './ssh/known-hosts.ts'
|
|
40
|
+
import { Vault } from './ssh/vault.ts'
|
|
41
|
+
import { RemoteSearchService } from './remote-search.ts'
|
|
42
|
+
import { mountWorkspaceCore } from './runtime/workspace-core.ts'
|
|
43
|
+
|
|
44
|
+
/** Stable cordis plugin name. */
|
|
45
|
+
export const name = 'hardssh'
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Services required before the workspace surfaces can mount. `webServer` is
|
|
49
|
+
* deliberately NOT here: headless profiles lack it, and a hard inject would
|
|
50
|
+
* block the whole load tree — routes register through the dynamic
|
|
51
|
+
* ctx.inject(['webServer'], …) below (DSH 插件规范 §4.2).
|
|
52
|
+
*/
|
|
53
|
+
export const inject = ['tools', 'systemPrompt']
|
|
54
|
+
|
|
55
|
+
/** Plugin config (schemastery; optional fields use .default, never .optional). */
|
|
56
|
+
export interface Config {
|
|
57
|
+
/** Master switch (default on). Disabling requires reverting the profile seam patch. */
|
|
58
|
+
enabled: boolean
|
|
59
|
+
/** Whether the model-facing announcement section is mounted. */
|
|
60
|
+
announceToAgent: boolean
|
|
61
|
+
/**
|
|
62
|
+
* Secret storage mode: 'none' (default, VSCode Remote-SSH style: passwords
|
|
63
|
+
* never persisted, prompted once per session) or 'vault' (encrypted at
|
|
64
|
+
* rest, for unattended agents). Mirrors the dsh-ssh settings namespace.
|
|
65
|
+
*/
|
|
66
|
+
secretStorage: 'none' | 'vault'
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export const Config: z<Config> = z.object({
|
|
70
|
+
enabled: z.boolean().default(true),
|
|
71
|
+
announceToAgent: z.boolean().default(true),
|
|
72
|
+
secretStorage: z.union([z.const('none'), z.const('vault')]).default('none'),
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
/** Order of the announcement section (right after the dsh-ssh section at 150). */
|
|
76
|
+
const SECTION_ORDER = 160
|
|
77
|
+
|
|
78
|
+
/** Tools that execute a WINDOWS-format binary locally and therefore cannot
|
|
79
|
+
* work when the spawn routes to a remote host (glob/grep spawn the packaged
|
|
80
|
+
* ripgrep, pwsh spawns powershell). Blocked with a clear error in SSH-bound
|
|
81
|
+
* sessions instead of failing with "No such file or directory". */
|
|
82
|
+
const REMOTE_INCOMPATIBLE_TOOLS = new Set(['glob', 'grep', 'pwsh'])
|
|
83
|
+
|
|
84
|
+
/** Minimal shape of the assembly context the guidance section reads. The DSH
|
|
85
|
+
* type (`AssembleContext`) only declares scope/signal, but the agent loop
|
|
86
|
+
* passes `{ agent, scope, signal }` (see dsh-agent assembleContextFor). */
|
|
87
|
+
interface GuidanceAssemblyContext {
|
|
88
|
+
agent?: { session?: { header?: { cwd?: string } } }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Render the workspace guidance from the LEDGER facts for this session:
|
|
92
|
+
* a cwd the ledger binds renders the remote branch (with the real alias /
|
|
93
|
+
* remote root), everything else the local branch. No path-string matching. */
|
|
94
|
+
function renderWorkspaceGuidance(ledger: SshWorkspaceLedger, context: unknown): string {
|
|
95
|
+
const agent = (context as GuidanceAssemblyContext).agent
|
|
96
|
+
const cwd = agent?.session?.header?.cwd
|
|
97
|
+
const record = cwd === undefined || cwd === '' ? undefined : ledger.findByAnchorSync(cwd)
|
|
98
|
+
if (record === undefined) return localGuidance()
|
|
99
|
+
return remoteGuidance(record)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Local-session branch of the announcement. */
|
|
103
|
+
function localGuidance(): string {
|
|
104
|
+
return '本机已安装 dsh-hardssh 插件(SSH 工作区)。当前会话是本机工作区:正常使用全部工具(pwsh / glob / grep / read / write / edit),文件操作在本机。remote_ls / remote_search / remote_status 与 ssh_exec / ssh_upload / ssh_download / ssh_tunnel / ssh_cluster 用于一次性远程运维(消耗真实远程资源,先确认再执行)。用户提到「SSH 工作区 / 远程工作区 / 远程文件 / 远程项目 / 远程服务器上改代码」时即指本插件。'
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** SSH-bound-session branch of the announcement. */
|
|
108
|
+
function remoteGuidance(record: SshWorkspaceRecord): string {
|
|
109
|
+
return `本机已安装 dsh-hardssh 插件(SSH 工作区)。当前会话绑定到远程工作区「${record.title}」(${record.alias} @ ${record.remoteRoot}):
|
|
110
|
+
- read / write / edit 自动路由到远程(SFTP);路径用远程绝对路径(如 ${record.remoteRoot}/src/main.ts),相对路径以远程根目录为基准。
|
|
111
|
+
- glob / grep / pwsh 在本工作区不可用(已被自动拦截并返回明确错误),请改用 remote_search(mode="glob" 按文件名、mode="grep" 按固定字符串搜内容)、remote_ls、remote_status、ssh_exec。
|
|
112
|
+
- 远程操作消耗真实远程资源,先确认再执行;remote_search 有限深与条数上限。`
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Mount the mode store, routes, tools, announcement, guard, and the shared core.
|
|
117
|
+
* @param ctx - host plugin context carrying tools/systemPrompt (webServer optional).
|
|
118
|
+
* @param config - resolved plugin config (schema defaults applied by the loader).
|
|
119
|
+
*/
|
|
120
|
+
export function apply(ctx: Context, config?: Config): void {
|
|
121
|
+
const resolved = {
|
|
122
|
+
enabled: config?.enabled ?? true,
|
|
123
|
+
announceToAgent: config?.announceToAgent ?? true,
|
|
124
|
+
// Secret storage: 'none' (VSCode Remote-SSH style; default) or 'vault'.
|
|
125
|
+
secretStorage: config?.secretStorage ?? 'none' as const,
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Host-key TOFU: connections are refused until the operator confirms the
|
|
129
|
+
// server fingerprint (see known-hosts.ts). Default-enabled for new installs;
|
|
130
|
+
// the engine falls back to pre-security behavior when no store is passed.
|
|
131
|
+
const knownHosts = new KnownHostsStore()
|
|
132
|
+
// Credential handling per secretStorage:
|
|
133
|
+
// - 'vault': encrypt at rest (AES-256-GCM + scrypt) via SecureHostStore;
|
|
134
|
+
// for unattended agents running password hosts.
|
|
135
|
+
// - 'none' (default): passwords are NEVER persisted — they are prompted
|
|
136
|
+
// once per session (VSCode Remote-SSH style) and held in the engine's
|
|
137
|
+
// in-memory session table for the connection pool lifetime.
|
|
138
|
+
const vault = resolved.secretStorage === 'vault' ? new Vault() : undefined
|
|
139
|
+
const secureHosts = new SecureHostStore(vault, undefined, undefined, resolved.secretStorage)
|
|
140
|
+
const engine = new SshEngine(secureHosts, undefined, {
|
|
141
|
+
knownHosts,
|
|
142
|
+
resolveSecrets: (entry) => secureHosts.resolveAuth(entry),
|
|
143
|
+
})
|
|
144
|
+
const search = new RemoteSearchService(engine)
|
|
145
|
+
const ledger = new SshWorkspaceLedger()
|
|
146
|
+
const seams = new WorkspaceSeamState(ledger)
|
|
147
|
+
ctx.effect(() => () => {
|
|
148
|
+
engine.dispose()
|
|
149
|
+
vault?.dispose()
|
|
150
|
+
}, 'dsh-hardssh: engine')
|
|
151
|
+
|
|
152
|
+
// The shared core is ALWAYS provided: the seam-switch rows (./fs,
|
|
153
|
+
// ./subprocess) inject it, and they replace the deployment's fs/subprocess
|
|
154
|
+
// providers — starving them would break the model's file tools.
|
|
155
|
+
const runner = new RemoteWorkspaceRunner(engine, ledger)
|
|
156
|
+
const core: HardsshCore = { hosts: secureHosts, engine, ledger, seams, resolveRemote: (root) => runner.resolveRemote(root) }
|
|
157
|
+
ctx.provide('hardsshCore', core)
|
|
158
|
+
// Workspace clients (e.g. dsh-workbench-tiphareth) may read the SSH
|
|
159
|
+
// workspace core under the legacy `sshWorkspaceCore` service name —
|
|
160
|
+
// provide the same instance under both names so SSH-mode fs/git
|
|
161
|
+
// delegation actually engages.
|
|
162
|
+
ctx.provide('sshWorkspaceCore', core)
|
|
163
|
+
|
|
164
|
+
// Generic workspace base: canonical `workspaceCore` service carrying the
|
|
165
|
+
// provider-agnostic ledger/registry/router + builtin providers (local/ssh).
|
|
166
|
+
// Third-party plugins consume this; the legacy aliases above keep existing
|
|
167
|
+
// consumers working unchanged.
|
|
168
|
+
mountWorkspaceCore(ctx, { engine, hosts: secureHosts })
|
|
169
|
+
|
|
170
|
+
// Keep the shared seam state in sync with the ledger (synchronous re-apply
|
|
171
|
+
// on every commit + initial load). Registered before the enabled switch so
|
|
172
|
+
// routing stays consistent even when the agent-facing surfaces are off.
|
|
173
|
+
ctx.effect(() => seams.attach(), 'dsh-hardssh: seam state')
|
|
174
|
+
|
|
175
|
+
// SSH operations capability (host manager, ssh_* tools, /api/dsh-ssh,
|
|
176
|
+
// terminal, settings, prompt) — mounted against the SAME engine/store and
|
|
177
|
+
// kept independent of the workspace `enabled` switch below (its own
|
|
178
|
+
// `dsh-ssh` settings namespace toggles it).
|
|
179
|
+
mountSshCapability(ctx, { store: secureHosts, engine, knownHosts, vault, ledger })
|
|
180
|
+
|
|
181
|
+
if (!resolved.enabled) return
|
|
182
|
+
|
|
183
|
+
// Host workspace registration hooks (make the anchor a real sidebar
|
|
184
|
+
// workspace). workspaceRegistry is optional — headless profiles lack it.
|
|
185
|
+
const registerHostWorkspace = async (anchorPath: string, title: string): Promise<void> => {
|
|
186
|
+
const registry = ctx.get('workspaceRegistry') as { resolveByPath?: (path: string) => Promise<{ id: string } | undefined>; create?: (path: string, title?: string) => Promise<{ id: string }> } | undefined
|
|
187
|
+
if (registry?.create === undefined) return
|
|
188
|
+
const existing = registry.resolveByPath !== undefined ? await registry.resolveByPath(anchorPath) : undefined
|
|
189
|
+
if (existing !== undefined) return
|
|
190
|
+
await registry.create(anchorPath, title)
|
|
191
|
+
}
|
|
192
|
+
const unregisterHostWorkspace = async (anchorPath: string): Promise<void> => {
|
|
193
|
+
const registry = ctx.get('workspaceRegistry') as { resolveByPath?: (path: string) => Promise<{ id: string } | undefined>; delete?: (id: string) => Promise<boolean> } | undefined
|
|
194
|
+
if (registry?.resolveByPath === undefined || registry.delete === undefined) return
|
|
195
|
+
const existing = await registry.resolveByPath(anchorPath)
|
|
196
|
+
if (existing !== undefined) await registry.delete(existing.id)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const routes = makeRoutes({
|
|
200
|
+
hosts: secureHosts,
|
|
201
|
+
engine,
|
|
202
|
+
ledger,
|
|
203
|
+
files: new LedgerWorkspaceFileService(ledger, engine, search),
|
|
204
|
+
registerHostWorkspace,
|
|
205
|
+
unregisterHostWorkspace,
|
|
206
|
+
})
|
|
207
|
+
// webServer is optional (headless profiles lack it): dynamic inject keeps
|
|
208
|
+
// this plugin loadable everywhere, mounting routes only when it appears.
|
|
209
|
+
// The inject callback receives a scoped Context with the service available.
|
|
210
|
+
ctx.inject(['webServer'], (scoped) => {
|
|
211
|
+
const disposers = routes.map(route => scoped.webServer.register(route))
|
|
212
|
+
return () => {
|
|
213
|
+
for (const dispose of disposers) dispose()
|
|
214
|
+
}
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
const tools = makeWorkspaceTools({ engine, ledger, search })
|
|
218
|
+
ctx.effect(() => {
|
|
219
|
+
const disposers = tools.map(tool => ctx.tools.register(tool))
|
|
220
|
+
return () => {
|
|
221
|
+
for (const dispose of disposers) dispose()
|
|
222
|
+
}
|
|
223
|
+
}, 'dsh-hardssh: tools')
|
|
224
|
+
|
|
225
|
+
// Global guard: deny glob/grep/pwsh in SSH-bound sessions with a clear
|
|
226
|
+
// reason (their executables are Windows-format and cannot run on the
|
|
227
|
+
// remote host); every other session is untouched.
|
|
228
|
+
ctx.effect(() => ctx.tools.guard((execution) => {
|
|
229
|
+
if (execution.agent === undefined || !REMOTE_INCOMPATIBLE_TOOLS.has(execution.name)) return undefined
|
|
230
|
+
const cwd = execution.agent.session?.header?.cwd
|
|
231
|
+
if (cwd === undefined || cwd === '') return undefined
|
|
232
|
+
const record = ledger.findByAnchorSync(cwd)
|
|
233
|
+
if (record === undefined) return undefined
|
|
234
|
+
return `当前会话绑定 SSH 远程工作区「${record.title}」(${record.alias} @ ${record.remoteRoot}),${execution.name} 的可执行文件是 Windows 格式、在远程主机上不存在,已被拦截。请改用 remote_search / remote_ls / ssh_exec 操作远程文件与命令,或使用 read / write / edit(自动路由到远程)。`
|
|
235
|
+
}), 'dsh-hardssh: remote-incompatible tool guard')
|
|
236
|
+
|
|
237
|
+
if (resolved.announceToAgent) {
|
|
238
|
+
ctx.systemPrompt.section({
|
|
239
|
+
name: 'plugin:dsh-hardssh',
|
|
240
|
+
order: SECTION_ORDER,
|
|
241
|
+
text: (context) => renderWorkspaceGuidance(ledger, context),
|
|
242
|
+
})
|
|
243
|
+
}
|
|
244
|
+
}
|
package/src/ledger.ts
ADDED
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSH-bound workspace ledger: the mapping from a LOCAL ANCHOR directory (the
|
|
3
|
+
* host-visible workspace path, which sessions use as their cwd) to a REMOTE
|
|
4
|
+
* directory on an SSH host. Persisted as JSON next to dsh-ssh.json so the
|
|
5
|
+
* fs/subprocess seams can route a session whose cwd is an anchor to the
|
|
6
|
+
* remote execution world, and everything else stays local.
|
|
7
|
+
*
|
|
8
|
+
* A ledger record is created through the GUI (pick a host, browse a remote
|
|
9
|
+
* dir, give it a title). The host-side anchor directory is auto-created
|
|
10
|
+
* under ~/.dsh/ssh-workspaces/<id>/; the host workspace registry owns it as
|
|
11
|
+
* a normal local workspace, so session creation/listing/persistence keep
|
|
12
|
+
* working untouched.
|
|
13
|
+
*
|
|
14
|
+
* Mutations are serialized through a per-instance queue and committed as:
|
|
15
|
+
* write temp file → atomic rename → swap memory/index → bump revision →
|
|
16
|
+
* notify subscribers. A persistence failure leaves observable state
|
|
17
|
+
* unchanged and never publishes an event.
|
|
18
|
+
*
|
|
19
|
+
* @module dsh-hardssh/ledger
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { randomUUID } from 'node:crypto'
|
|
23
|
+
import { realpathSync } from 'node:fs'
|
|
24
|
+
import {
|
|
25
|
+
mkdir,
|
|
26
|
+
readFile,
|
|
27
|
+
rename as renameFile,
|
|
28
|
+
rm,
|
|
29
|
+
writeFile,
|
|
30
|
+
} from 'node:fs/promises'
|
|
31
|
+
import { homedir } from 'node:os'
|
|
32
|
+
import { join, posix } from 'node:path'
|
|
33
|
+
import type { SshWorkspaceRecord } from './protocol.ts'
|
|
34
|
+
|
|
35
|
+
/** One committed snapshot of the ledger. */
|
|
36
|
+
export interface LedgerSnapshot {
|
|
37
|
+
revision: number
|
|
38
|
+
records: readonly SshWorkspaceRecord[]
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** One committed mutation, delivered to subscribers. */
|
|
42
|
+
export type LedgerChange =
|
|
43
|
+
| { type: 'created'; revision: number; record: SshWorkspaceRecord }
|
|
44
|
+
| { type: 'renamed'; revision: number; before: SshWorkspaceRecord; record: SshWorkspaceRecord }
|
|
45
|
+
| { type: 'removed'; revision: number; record: SshWorkspaceRecord }
|
|
46
|
+
|
|
47
|
+
export type LedgerListener = (change: LedgerChange) => void
|
|
48
|
+
|
|
49
|
+
/** The change payload before the committed revision is stamped on. */
|
|
50
|
+
type LedgerChangeWithoutRevision =
|
|
51
|
+
| { type: 'created'; record: SshWorkspaceRecord }
|
|
52
|
+
| { type: 'renamed'; before: SshWorkspaceRecord; record: SshWorkspaceRecord }
|
|
53
|
+
| { type: 'removed'; record: SshWorkspaceRecord }
|
|
54
|
+
|
|
55
|
+
/** Ledger file location: ~/.dsh/dsh-hardssh-workspaces.json. */
|
|
56
|
+
export function ledgerPath(): string {
|
|
57
|
+
return join(homedir(), '.dsh', 'dsh-hardssh-workspaces.json')
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Normalize a remote POSIX root: keep '/' as '/', collapse repeated
|
|
62
|
+
* separators and dot segments, reject relative paths and NUL.
|
|
63
|
+
*/
|
|
64
|
+
export function normalizeRemoteRoot(raw: string): string {
|
|
65
|
+
if (raw.includes('\0')) {
|
|
66
|
+
throw new Error(`remoteRoot must not contain NUL (got '${raw}')`)
|
|
67
|
+
}
|
|
68
|
+
const normalized = posix.normalize(raw.trim())
|
|
69
|
+
if (!normalized.startsWith('/')) {
|
|
70
|
+
throw new Error(`remoteRoot must be an absolute POSIX path (got '${raw}')`)
|
|
71
|
+
}
|
|
72
|
+
return normalized === '/' ? '/' : normalized.replace(/\/+$/, '')
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Anchor roots: ~/.dsh/ssh-workspaces/<id>/ — visible in the sidebar as
|
|
76
|
+
* ordinary host workspaces (and thus selectable for sessions). */
|
|
77
|
+
export function anchorRoot(): string {
|
|
78
|
+
return join(homedir(), '.dsh', 'ssh-workspaces')
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The anchor directory for one id. */
|
|
82
|
+
export function anchorPathFor(id: string): string {
|
|
83
|
+
return join(anchorRoot(), id)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Default record title when the user gives none. */
|
|
87
|
+
export function defaultTitle(remoteRoot: string, alias: string): string {
|
|
88
|
+
return `${alias}:${remoteRoot.split('/').filter(Boolean).pop() ?? remoteRoot}`
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Normalize a local anchor path for comparison: Windows anchors are
|
|
93
|
+
* case-insensitive with mixed separators; POSIX anchors stay case-sensitive.
|
|
94
|
+
*/
|
|
95
|
+
export function normalizeAnchorPath(path: string): string {
|
|
96
|
+
const windowsStyle = isWindowsAnchor(path)
|
|
97
|
+
if (windowsStyle) {
|
|
98
|
+
const normalized = path.replace(/\//g, '\\')
|
|
99
|
+
const rootLength = windowsRootLength(normalized)
|
|
100
|
+
return trimTrailingSeparators(normalized, rootLength).toLowerCase()
|
|
101
|
+
}
|
|
102
|
+
const rootLength = path.startsWith('/') ? 1 : 0
|
|
103
|
+
return trimTrailingSeparators(path, rootLength)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** True when candidate equals anchor or is one of its descendants. Lexical
|
|
107
|
+
* comparison over already-resolved paths; deliberately no fs access. */
|
|
108
|
+
export function isPathUnderAnchor(anchor: string, candidate: string): boolean {
|
|
109
|
+
const windowsStyle = isWindowsAnchor(anchor)
|
|
110
|
+
const normalizedAnchor = normalizeAnchorPath(anchor)
|
|
111
|
+
|
|
112
|
+
let normalizedCandidate: string
|
|
113
|
+
if (windowsStyle) {
|
|
114
|
+
const withWindowsSeparators = candidate.replace(/\//g, '\\')
|
|
115
|
+
const rootLength = windowsRootLength(withWindowsSeparators)
|
|
116
|
+
normalizedCandidate = trimTrailingSeparators(withWindowsSeparators, rootLength).toLowerCase()
|
|
117
|
+
} else {
|
|
118
|
+
const rootLength = candidate.startsWith('/') ? 1 : 0
|
|
119
|
+
normalizedCandidate = trimTrailingSeparators(candidate, rootLength)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (normalizedCandidate === normalizedAnchor) return true
|
|
123
|
+
|
|
124
|
+
const separator = windowsStyle ? '\\' : '/'
|
|
125
|
+
const descendantPrefix = normalizedAnchor.endsWith(separator)
|
|
126
|
+
? normalizedAnchor
|
|
127
|
+
: `${normalizedAnchor}${separator}`
|
|
128
|
+
return normalizedCandidate.startsWith(descendantPrefix)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The in-memory ledger. Loaded lazily on first access; every mutation writes
|
|
133
|
+
* through durably (atomic rename) and is serialized through a per-instance
|
|
134
|
+
* queue. A synchronous anchor index is maintained from the latest committed
|
|
135
|
+
* records so host seams can resolve a cwd/root without awaiting I/O.
|
|
136
|
+
*/
|
|
137
|
+
export class SshWorkspaceLedger {
|
|
138
|
+
private records: SshWorkspaceRecord[] | undefined
|
|
139
|
+
private loadPromise: Promise<void> | undefined
|
|
140
|
+
private anchorIndex: Array<{ anchor: string; record: SshWorkspaceRecord }> = []
|
|
141
|
+
private mutationTail: Promise<void> = Promise.resolve()
|
|
142
|
+
private currentRevision = 0
|
|
143
|
+
private readonly listeners = new Set<LedgerListener>()
|
|
144
|
+
|
|
145
|
+
/** Overridable file location (tests isolate per instance). */
|
|
146
|
+
constructor(private readonly fileOverride?: string, private readonly anchorOverride?: string) {}
|
|
147
|
+
|
|
148
|
+
private file(): string {
|
|
149
|
+
return this.fileOverride ?? ledgerPath()
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
private anchors(): string {
|
|
153
|
+
return this.anchorOverride ?? anchorRoot()
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
private anchorFor(id: string): string {
|
|
157
|
+
return join(this.anchors(), id)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Load the ledger once (missing/unreadable/malformed file -> empty ledger). */
|
|
161
|
+
private async ensureLoaded(): Promise<void> {
|
|
162
|
+
if (this.records !== undefined) return
|
|
163
|
+
if (this.loadPromise === undefined) {
|
|
164
|
+
this.loadPromise = this.readRecords()
|
|
165
|
+
}
|
|
166
|
+
await this.loadPromise
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
private async readRecords(): Promise<void> {
|
|
170
|
+
try {
|
|
171
|
+
const text = await readFile(this.file(), 'utf8')
|
|
172
|
+
const parsed = JSON.parse(text) as unknown
|
|
173
|
+
this.records = Array.isArray(parsed) ? parsed.filter(isRecord).map(cloneRecord) : []
|
|
174
|
+
} catch {
|
|
175
|
+
this.records = []
|
|
176
|
+
}
|
|
177
|
+
this.reindex()
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Rebuild the synchronous anchor index from the current records. */
|
|
181
|
+
private reindex(): void {
|
|
182
|
+
this.anchorIndex = (this.records ?? [])
|
|
183
|
+
.map((record) => ({ anchor: normalizeAnchorPath(record.anchorPath), record }))
|
|
184
|
+
.sort((a, b) => b.anchor.length - a.anchor.length) // longest prefix first
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Persist a proposed record array without touching observable state. */
|
|
188
|
+
private async save(nextRecords: readonly SshWorkspaceRecord[]): Promise<void> {
|
|
189
|
+
const target = this.file()
|
|
190
|
+
await mkdir(join(target, '..'), { recursive: true })
|
|
191
|
+
const temporary = `${target}.tmp-${process.pid}-${Date.now()}-${randomUUID()}`
|
|
192
|
+
try {
|
|
193
|
+
await writeFile(temporary, JSON.stringify(nextRecords, null, 2), 'utf8')
|
|
194
|
+
await renameFile(temporary, target)
|
|
195
|
+
} catch (error) {
|
|
196
|
+
await rm(temporary, { force: true }).catch(() => undefined)
|
|
197
|
+
throw error
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Serialize one mutation; a rejected earlier mutation never poisons later ones. */
|
|
202
|
+
private enqueueMutation<T>(operation: () => Promise<T>): Promise<T> {
|
|
203
|
+
const result = this.mutationTail.then(operation)
|
|
204
|
+
this.mutationTail = result.then(() => undefined, () => undefined)
|
|
205
|
+
return result
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Commit a computed next state: swap memory, bump revision, notify. */
|
|
209
|
+
private commit(nextRecords: SshWorkspaceRecord[], change: LedgerChangeWithoutRevision): void {
|
|
210
|
+
this.records = nextRecords
|
|
211
|
+
this.reindex()
|
|
212
|
+
this.currentRevision += 1
|
|
213
|
+
this.emit({ ...change, revision: this.currentRevision } as LedgerChange)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
private emit(change: LedgerChange): void {
|
|
217
|
+
for (const listener of [...this.listeners]) {
|
|
218
|
+
try {
|
|
219
|
+
listener(cloneChange(change))
|
|
220
|
+
} catch (error) {
|
|
221
|
+
// Listener failures must never reject an already-committed mutation.
|
|
222
|
+
console.warn('[dsh-hardssh] ledger subscriber failed:', error instanceof Error ? error.message : String(error))
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Load the ledger (compat; returns detached copies). */
|
|
228
|
+
async load(): Promise<SshWorkspaceRecord[]> {
|
|
229
|
+
await this.ensureLoaded()
|
|
230
|
+
return cloneRecords(this.records ?? [])
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** All records, in creation order (detached copies). */
|
|
234
|
+
async list(): Promise<SshWorkspaceRecord[]> {
|
|
235
|
+
await this.ensureLoaded()
|
|
236
|
+
return cloneRecords(this.records ?? [])
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Current detached snapshot. */
|
|
240
|
+
async snapshot(): Promise<LedgerSnapshot> {
|
|
241
|
+
await this.ensureLoaded()
|
|
242
|
+
return { revision: this.currentRevision, records: cloneRecords(this.records ?? []) }
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Current in-process snapshot, synchronously (empty before first load).
|
|
246
|
+
* The returned records are the live immutable objects — callers must not
|
|
247
|
+
* mutate them (commits always replace the array wholesale). */
|
|
248
|
+
snapshotSync(): LedgerSnapshot {
|
|
249
|
+
return { revision: this.currentRevision, records: this.records ?? [] }
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Current in-process revision (restarts at 0 on process restart). */
|
|
253
|
+
revision(): number {
|
|
254
|
+
return this.currentRevision
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Subscribe to committed mutations (no initial event). Returns a disposer. */
|
|
258
|
+
subscribe(listener: LedgerListener): () => void {
|
|
259
|
+
this.listeners.add(listener)
|
|
260
|
+
let disposed = false
|
|
261
|
+
return () => {
|
|
262
|
+
if (disposed) return
|
|
263
|
+
disposed = true
|
|
264
|
+
this.listeners.delete(listener)
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Look up by id. */
|
|
269
|
+
async get(id: string): Promise<SshWorkspaceRecord | undefined> {
|
|
270
|
+
await this.ensureLoaded()
|
|
271
|
+
const record = this.records?.find((candidate) => candidate.id === id)
|
|
272
|
+
return record === undefined ? undefined : cloneRecord(record)
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Look up by LOCAL anchor path (resolved). Returns the record whose
|
|
276
|
+
* anchor owns the path (the anchor itself or any descendant). */
|
|
277
|
+
async findByAnchor(path: string): Promise<SshWorkspaceRecord | undefined> {
|
|
278
|
+
await this.ensureLoaded()
|
|
279
|
+
return this.findByAnchorSync(path)
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Synchronous anchor lookup (uses the in-memory index). */
|
|
283
|
+
findByAnchorSync(path: string): SshWorkspaceRecord | undefined {
|
|
284
|
+
const canonical = safeRealpathSync(path)
|
|
285
|
+
if (canonical === undefined) return undefined
|
|
286
|
+
for (const { anchor, record } of this.anchorIndex) {
|
|
287
|
+
if (isPathUnderAnchor(anchor, canonical)) return cloneRecord(record)
|
|
288
|
+
}
|
|
289
|
+
return undefined
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Create a record: materialize the anchor, then persist+commit. */
|
|
293
|
+
async create(input: { title: string; alias: string; remoteRoot: string }): Promise<SshWorkspaceRecord> {
|
|
294
|
+
return this.enqueueMutation(async () => {
|
|
295
|
+
await this.ensureLoaded()
|
|
296
|
+
const id = randomUUID()
|
|
297
|
+
const remoteRoot = normalizeRemoteRoot(input.remoteRoot)
|
|
298
|
+
const record: SshWorkspaceRecord = {
|
|
299
|
+
id,
|
|
300
|
+
title: input.title.trim() === '' ? defaultTitle(remoteRoot, input.alias) : input.title.trim(),
|
|
301
|
+
alias: input.alias,
|
|
302
|
+
remoteRoot,
|
|
303
|
+
anchorPath: this.anchorFor(id),
|
|
304
|
+
createdAt: new Date().toISOString(),
|
|
305
|
+
}
|
|
306
|
+
// Materialize before publishing a binding that points at the anchor;
|
|
307
|
+
// a persistence failure leaves an unreferenced dir but no snapshot change.
|
|
308
|
+
await mkdir(record.anchorPath, { recursive: true })
|
|
309
|
+
const nextRecords = [...(this.records ?? []), record]
|
|
310
|
+
await this.save(nextRecords)
|
|
311
|
+
this.commit(nextRecords, { type: 'created', record })
|
|
312
|
+
return cloneRecord(record)
|
|
313
|
+
})
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** Rename a record (display title only). */
|
|
317
|
+
async rename(id: string, title: string): Promise<SshWorkspaceRecord | undefined> {
|
|
318
|
+
return this.enqueueMutation(async () => {
|
|
319
|
+
await this.ensureLoaded()
|
|
320
|
+
const records = this.records ?? []
|
|
321
|
+
const index = records.findIndex((record) => record.id === id)
|
|
322
|
+
if (index < 0) return undefined
|
|
323
|
+
const before = records[index]
|
|
324
|
+
const record: SshWorkspaceRecord = { ...before, title: title.trim() }
|
|
325
|
+
const nextRecords = [...records]
|
|
326
|
+
nextRecords[index] = record
|
|
327
|
+
await this.save(nextRecords)
|
|
328
|
+
this.commit(nextRecords, { type: 'renamed', before, record })
|
|
329
|
+
return cloneRecord(record)
|
|
330
|
+
})
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** Remove a record. The anchor directory is left in place (a host
|
|
334
|
+
* workspace may still reference it; the caller decides on deletion). */
|
|
335
|
+
async remove(id: string): Promise<boolean> {
|
|
336
|
+
return this.enqueueMutation(async () => {
|
|
337
|
+
await this.ensureLoaded()
|
|
338
|
+
const records = this.records ?? []
|
|
339
|
+
const index = records.findIndex((record) => record.id === id)
|
|
340
|
+
if (index < 0) return false
|
|
341
|
+
const record = records[index]
|
|
342
|
+
const nextRecords = [...records.slice(0, index), ...records.slice(index + 1)]
|
|
343
|
+
await this.save(nextRecords)
|
|
344
|
+
this.commit(nextRecords, { type: 'removed', record })
|
|
345
|
+
return true
|
|
346
|
+
})
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** The anchor root shared by every record (for UI hints). */
|
|
350
|
+
anchorsRoot(): string {
|
|
351
|
+
return this.anchors()
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** Guard: a parsed JSON value is a valid record. */
|
|
356
|
+
function isRecord(value: unknown): value is SshWorkspaceRecord {
|
|
357
|
+
if (typeof value !== 'object' || value === null) return false
|
|
358
|
+
const record = value as Record<string, unknown>
|
|
359
|
+
return typeof record.id === 'string'
|
|
360
|
+
&& typeof record.title === 'string'
|
|
361
|
+
&& typeof record.alias === 'string'
|
|
362
|
+
&& typeof record.remoteRoot === 'string'
|
|
363
|
+
&& typeof record.anchorPath === 'string'
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** Synchronous realpath, swallowing errors (a session cwd can vanish mid-flight). */
|
|
367
|
+
function safeRealpathSync(path: string): string | undefined {
|
|
368
|
+
try {
|
|
369
|
+
return realpathSync(path)
|
|
370
|
+
} catch {
|
|
371
|
+
return undefined
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function isWindowsAnchor(path: string): boolean {
|
|
376
|
+
return /^[a-zA-Z]:[\\/]/.test(path) || path.startsWith('\\\\') || path.includes('\\')
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function windowsRootLength(path: string): number {
|
|
380
|
+
if (/^[a-zA-Z]:\\/.test(path)) return 3
|
|
381
|
+
if (path.startsWith('\\\\')) {
|
|
382
|
+
const segments = path.slice(2).split('\\')
|
|
383
|
+
if (segments.length >= 2 && segments[0] !== '' && segments[1] !== '') {
|
|
384
|
+
return 2 + segments[0].length + 1 + segments[1].length
|
|
385
|
+
}
|
|
386
|
+
return 2
|
|
387
|
+
}
|
|
388
|
+
return 0
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function trimTrailingSeparators(path: string, minimumLength: number): string {
|
|
392
|
+
let end = path.length
|
|
393
|
+
while (end > minimumLength && (path[end - 1] === '/' || path[end - 1] === '\\')) {
|
|
394
|
+
end -= 1
|
|
395
|
+
}
|
|
396
|
+
return path.slice(0, end)
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function cloneRecord(record: SshWorkspaceRecord): SshWorkspaceRecord {
|
|
400
|
+
return { ...record }
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function cloneRecords(records: readonly SshWorkspaceRecord[]): SshWorkspaceRecord[] {
|
|
404
|
+
return records.map(cloneRecord)
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function cloneChange(change: LedgerChange): LedgerChange {
|
|
408
|
+
switch (change.type) {
|
|
409
|
+
case 'created':
|
|
410
|
+
return { ...change, record: cloneRecord(change.record) }
|
|
411
|
+
case 'renamed':
|
|
412
|
+
return { ...change, before: cloneRecord(change.before), record: cloneRecord(change.record) }
|
|
413
|
+
case 'removed':
|
|
414
|
+
return { ...change, record: cloneRecord(change.record) }
|
|
415
|
+
}
|
|
416
|
+
}
|