@tiphareth/dsh-hardssh 0.1.2-alpha → 0.2.2
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 +30 -0
- package/README.md +49 -25
- package/SKILLS.md +140 -0
- package/lib/base/index.js +95 -0
- package/lib/client.js +1165 -1329
- package/lib/fs.js +307 -416
- package/lib/index.js +5428 -2991
- package/lib/ledger-B-LXlftp.js +43 -0
- package/lib/ledger-D2ezq1iW.js +376 -0
- package/lib/model-Cp7f70Mb.js +5 -0
- package/lib/registry-CViuzKYI.js +421 -0
- package/lib/subprocess.js +92 -549
- package/lib/types/backend.d.ts +73 -78
- package/lib/types/base/capability.d.ts +4 -0
- package/lib/types/base/ledger-router.d.ts +42 -26
- package/lib/types/base/ledger.d.ts +30 -19
- package/lib/types/base/model.d.ts +17 -8
- package/lib/types/base/plugin.d.ts +11 -0
- package/lib/types/base/registry.d.ts +28 -31
- package/lib/types/base/router.d.ts +0 -7
- package/lib/types/client/api.d.ts +8 -8
- package/lib/types/client/connect-host.d.ts +13 -0
- package/lib/types/client/index.d.ts +6 -4
- package/lib/types/client/locales.d.ts +15 -1
- package/lib/types/client/session-connect-gate.d.ts +71 -0
- package/lib/types/client/ssh/api.d.ts +11 -9
- package/lib/types/client/ssh/apply.d.ts +14 -8
- package/lib/types/client/ssh/locales.d.ts +8 -1
- package/lib/types/client/ssh/ops-tab.d.ts +34 -0
- package/lib/types/client/ssh/panel/ClusterTab.d.ts +3 -1
- package/lib/types/client/ssh/panel/ConnectionErrorDialog.d.ts +8 -0
- package/lib/types/client/ssh/panel/SessionSecretDialog.d.ts +4 -1
- package/lib/types/client/ssh/panel/SshPanel.d.ts +8 -8
- package/lib/types/client/ssh/panel/TerminalTab.d.ts +3 -5
- package/lib/types/client/ssh/panel/TransferTab.d.ts +5 -1
- package/lib/types/client/ssh/panel/TunnelsTab.d.ts +3 -1
- package/lib/types/client/ssh/session-target.d.ts +17 -0
- package/lib/types/client/state.d.ts +4 -1
- package/lib/types/client/workspace-panel-entry.d.ts +30 -0
- package/lib/types/client/workspace-panel.d.ts +24 -0
- package/lib/types/client-http.d.ts +77 -6
- package/lib/types/core.d.ts +5 -16
- package/lib/types/fs.d.ts +18 -25
- package/lib/types/index.d.ts +138 -9
- package/lib/types/ledger.d.ts +16 -111
- package/lib/types/protocol.d.ts +0 -5
- package/lib/types/providers/index.d.ts +6 -3
- package/lib/types/providers/local/provider.d.ts +83 -23
- package/lib/types/providers/ssh/provider.d.ts +24 -57
- package/lib/types/remote/environment.d.ts +2 -0
- package/lib/types/remote/remote-fs.d.ts +12 -1
- package/lib/types/remote/remote-process.d.ts +22 -1
- package/lib/types/remote/remote-subprocess.d.ts +13 -1
- package/lib/types/remote/remote-terminal.d.ts +4 -0
- package/lib/types/remote-search.d.ts +3 -3
- package/lib/types/routes.d.ts +36 -8
- package/lib/types/runtime/dsh-capabilities.d.ts +18 -0
- package/lib/types/runtime/workspace-core.d.ts +73 -38
- package/lib/types/runtime/workspace-migration.d.ts +113 -0
- package/lib/types/ssh/connection/lease.d.ts +23 -0
- package/lib/types/ssh/connection/manager.d.ts +359 -0
- package/lib/types/ssh/connection/pool.d.ts +8 -1
- package/lib/types/ssh/engine.d.ts +97 -267
- package/lib/types/ssh/known-hosts.d.ts +22 -5
- package/lib/types/ssh/local-transfer-policy.d.ts +20 -0
- package/lib/types/ssh/plugin.d.ts +10 -15
- package/lib/types/ssh/protocol.d.ts +18 -0
- package/lib/types/ssh/routes.d.ts +15 -7
- package/lib/types/ssh/sftp/service.d.ts +216 -0
- package/lib/types/ssh/store.d.ts +20 -8
- package/lib/types/ssh/terminal/service.d.ts +49 -0
- package/lib/types/ssh/tunnel/service.d.ts +26 -0
- package/lib/types/ssh/vault.d.ts +51 -4
- package/lib/types/subprocess.d.ts +13 -21
- package/lib/types/switch/switch-fs.d.ts +52 -2
- package/lib/types/switch/switch-subprocess.d.ts +32 -3
- package/lib/types/tools.d.ts +7 -6
- package/lib/types/workspace-tool-ops.d.ts +33 -0
- package/lib/types/workspace.d.ts +18 -0
- package/lib/vault-3gpWct2Q.js +559 -0
- package/lib/workspace.js +2 -0
- package/package.json +36 -11
- package/scripts/export-legacy-workspaces.mjs +136 -0
- package/src/backend.ts +155 -483
- package/src/base/capability.ts +4 -0
- package/src/base/ledger-router.ts +214 -65
- package/src/base/ledger.ts +175 -48
- package/src/base/model.ts +18 -8
- package/src/base/plugin.ts +15 -4
- package/src/base/registry.ts +49 -41
- package/src/base/router.ts +0 -8
- package/src/client/api.ts +8 -36
- package/src/client/connect-host.ts +199 -0
- package/src/client/directory-flow.tsx +91 -36
- package/src/client/index.ts +56 -53
- package/src/client/locales.ts +14 -0
- package/src/client/session-connect-gate.ts +163 -0
- package/src/client/ssh/api.ts +245 -108
- package/src/client/ssh/apply.ts +23 -24
- package/src/client/ssh/locales.ts +16 -2
- package/src/client/ssh/ops-tab.tsx +79 -0
- package/src/client/ssh/panel/ClusterTab.tsx +9 -20
- package/src/client/ssh/panel/ConnectionErrorDialog.tsx +33 -0
- package/src/client/ssh/panel/SessionSecretDialog.tsx +5 -1
- package/src/client/ssh/panel/SshPanel.tsx +81 -77
- package/src/client/ssh/panel/TerminalTab.tsx +18 -37
- package/src/client/ssh/panel/TransferTab.tsx +43 -33
- package/src/client/ssh/panel/TunnelsTab.tsx +9 -26
- package/src/client/ssh/panel/panel.module.css +90 -127
- package/src/client/ssh/session-target.ts +69 -0
- package/src/client/state.ts +9 -1
- package/src/client/workspace-badges.ts +99 -99
- package/src/client/workspace-panel-entry.tsx +91 -0
- package/src/client/workspace-panel.tsx +227 -0
- package/src/client/workspace.module.css +76 -2
- package/src/client-http.ts +118 -18
- package/src/core.ts +37 -47
- package/src/fs.ts +137 -85
- package/src/index.ts +535 -244
- package/src/ledger.ts +62 -416
- package/src/protocol.ts +0 -6
- package/src/providers/index.ts +8 -4
- package/src/providers/local/provider.ts +259 -87
- package/src/providers/ssh/provider.ts +158 -155
- package/src/remote/environment.ts +22 -1
- package/src/remote/remote-fs.ts +123 -34
- package/src/remote/remote-process.ts +265 -203
- package/src/remote/remote-subprocess.ts +61 -18
- package/src/remote/remote-terminal.ts +106 -21
- package/src/remote-search.ts +122 -26
- package/src/routes.ts +416 -395
- package/src/runtime/dsh-capabilities.ts +19 -0
- package/src/runtime/workspace-core.ts +171 -88
- package/src/runtime/workspace-migration.ts +472 -0
- package/src/ssh/connection/lease.ts +35 -0
- package/src/ssh/connection/manager.ts +1083 -0
- package/src/ssh/connection/pool.ts +341 -275
- package/src/ssh/engine.ts +269 -1477
- package/src/ssh/known-hosts.ts +42 -18
- package/src/ssh/local-transfer-policy.ts +83 -0
- package/src/ssh/plugin.ts +20 -19
- package/src/ssh/protocol.ts +22 -1
- package/src/ssh/routes.ts +419 -187
- package/src/ssh/sftp/service.ts +967 -0
- package/src/ssh/store.ts +169 -72
- package/src/ssh/terminal/service.ts +177 -0
- package/src/ssh/tools.ts +42 -20
- package/src/ssh/tunnel/service.ts +217 -0
- package/src/ssh/vault.ts +245 -91
- package/src/subprocess.ts +88 -71
- package/src/switch/switch-fs.ts +141 -10
- package/src/switch/switch-subprocess.ts +80 -6
- package/src/tools.ts +217 -221
- package/src/workspace-tool-ops.ts +101 -0
- package/src/workspace.ts +52 -0
- package/lib/environment-BL1jddfB.js +0 -449
- package/lib/switch-fs-CAJpFY9C.js +0 -193
- package/lib/switch-fs-RrZtG2gv.js +0 -210
- package/lib/types/client/manager-button.d.ts +0 -32
- package/lib/types/client/ssh/mount.d.ts +0 -13
- package/lib/types/client/ssh/panel/HostsTab.d.ts +0 -10
- package/lib/types/client/ssh/panel/controller.d.ts +0 -23
- package/lib/types/client/ssh/sidebar-entry.d.ts +0 -25
- package/lib/types/client/workspace-gate.d.ts +0 -15
- package/lib/types/remote-runner.d.ts +0 -83
- package/lib/types/seam-state.d.ts +0 -69
- package/src/client/manager-button.tsx +0 -269
- package/src/client/ssh/mount.tsx +0 -83
- package/src/client/ssh/panel/HostsTab.tsx +0 -236
- package/src/client/ssh/panel/controller.ts +0 -48
- package/src/client/ssh/sidebar-entry.ts +0 -123
- package/src/client/workspace-gate.ts +0 -232
- package/src/remote-runner.ts +0 -201
- package/src/seam-state.ts +0 -185
package/src/index.ts
CHANGED
|
@@ -1,244 +1,535 @@
|
|
|
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
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* the
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
import type {
|
|
30
|
-
import {
|
|
31
|
-
import {
|
|
32
|
-
import {
|
|
33
|
-
import {
|
|
34
|
-
import {
|
|
35
|
-
import {
|
|
36
|
-
import
|
|
37
|
-
import {
|
|
38
|
-
import {
|
|
39
|
-
import {
|
|
40
|
-
import {
|
|
41
|
-
import {
|
|
42
|
-
import {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
/**
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
const
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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 generic workspace core,
|
|
5
|
+
* and — since the legacy dsh-ssh package was merged
|
|
6
|
+
* in — the SSH operations capability (host manager, ssh_* tools, /api/dsh-ssh,
|
|
7
|
+
* web terminal). In SSH mode the model's ordinary read/write/edit/bash tools
|
|
8
|
+
* run transparently on the remote host through those switch rows. File
|
|
9
|
+
* operations and SSH operations ride ONE shared SshEngine/HostStore over
|
|
10
|
+
* ~/.dsh/dsh-ssh.json (a single connection pool; SSH ops and workspaces
|
|
11
|
+
* invalidate together on config change). The browser half (./client) renders
|
|
12
|
+
* the header buttons, the SSH config dialog, the left workspace panel, and
|
|
13
|
+
* the SSH host-manager surfaces.
|
|
14
|
+
*
|
|
15
|
+
* The announcement section is rendered PER SESSION from the workspace facts
|
|
16
|
+
* (which workspace this session's cwd binds to), never from path-string
|
|
17
|
+
* heuristics. There is no tool guard layer: routing lives in the fs/subprocess
|
|
18
|
+
* seams, and the subprocess facade refuses the one combination that cannot be
|
|
19
|
+
* routed correctly — a client-side search helper (glob/grep's bundled ripgrep)
|
|
20
|
+
* inside a remote-bound session, which would otherwise return confidently wrong
|
|
21
|
+
* "no matches" from the local anchor.
|
|
22
|
+
*
|
|
23
|
+
* The generic WorkspaceCore is the only production workspace runtime. On the
|
|
24
|
+
* first upgraded boot, the frozen legacy SSH ledger is imported once into
|
|
25
|
+
* `~/.dsh/workspaces/index.v1.json`; the atomic migration report is the durable
|
|
26
|
+
* cutover marker. Emergency generic-to-legacy conversion is an offline export,
|
|
27
|
+
* never an alternate in-process routing mode.
|
|
28
|
+
*/
|
|
29
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
30
|
+
import type {} from '@deepseek-ai/dsh-host-webserver'
|
|
31
|
+
import type {} from '@deepseek-ai/dsh-settings'
|
|
32
|
+
import type {} from '@deepseek-ai/dsh-system-prompt'
|
|
33
|
+
import type {} from '@deepseek-ai/dsh-tools'
|
|
34
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
|
35
|
+
import { join } from 'node:path'
|
|
36
|
+
import z from '@deepseek-ai/schemastery'
|
|
37
|
+
import type { HardsshCore } from './core.ts'
|
|
38
|
+
import { ledgerPath, anchorRoot } from './ledger.ts'
|
|
39
|
+
import { GenericWorkspaceStore, type WorkspaceStoreView } from './backend.ts'
|
|
40
|
+
import { makeRoutes, reconcileHostWorkspaces, type HostWorkspaceReconcileDeps, type HostWorkspaceReconcileReport } from './routes.ts'
|
|
41
|
+
import { makeWorkspaceTools } from './tools.ts'
|
|
42
|
+
import { capabilityToolOpsResolver } from './workspace-tool-ops.ts'
|
|
43
|
+
import { SshEngine } from './ssh/engine.ts'
|
|
44
|
+
import { mountSshCapability, SSH_SETTINGS_NAMESPACE } from './ssh/plugin.ts'
|
|
45
|
+
import { HostStore } from './ssh/store.ts'
|
|
46
|
+
import { SecureHostStore } from './ssh/store.ts'
|
|
47
|
+
import { KnownHostsStore } from './ssh/known-hosts.ts'
|
|
48
|
+
import { Vault } from './ssh/vault.ts'
|
|
49
|
+
import { mountWorkspaceCore, genericLedgerPath, type WorkspaceCore } from './runtime/workspace-core.ts'
|
|
50
|
+
import { inspectGenericLedger, migrateLegacySshLedger, recoverGenericLedger, type WorkspaceMigrationReport } from './runtime/workspace-migration.ts'
|
|
51
|
+
import type { SshWorkspaceRecord } from './protocol.ts'
|
|
52
|
+
|
|
53
|
+
/** Stable cordis plugin name. */
|
|
54
|
+
export const name = 'hardssh'
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Services required before the workspace surfaces can mount. `webServer` is
|
|
58
|
+
* deliberately NOT here: headless profiles lack it, and a hard inject would
|
|
59
|
+
* block the whole load tree — routes register through the dynamic
|
|
60
|
+
* ctx.inject(['webServer'], …) below (DSH 插件规范 §4.2).
|
|
61
|
+
*/
|
|
62
|
+
export const inject = ['tools', 'systemPrompt']
|
|
63
|
+
|
|
64
|
+
/** Plugin config (schemastery; optional fields use .default, never .optional). */
|
|
65
|
+
export interface Config {
|
|
66
|
+
/**
|
|
67
|
+
* Workspace-surface switch (default on). It gates ONLY the SSH-workspace
|
|
68
|
+
* surfaces this row mounts after the check below: the /api/dsh-hardssh
|
|
69
|
+
* workspace CRUD routes, the remote_* workspace agent tools, and the
|
|
70
|
+
* workspace announcement section. It does NOT gate the SSH operations
|
|
71
|
+
* capability (host manager, ssh_* tools, /api/dsh-ssh, web terminal — its
|
|
72
|
+
* own `dsh-ssh` settings namespace `enabled`), the shared
|
|
73
|
+
* engine/host-store/vault, the fs/subprocess routing rows, or the seam
|
|
74
|
+
* replacement itself: those mount before this switch and keep running.
|
|
75
|
+
*/
|
|
76
|
+
enabled: boolean
|
|
77
|
+
/** Whether the model-facing announcement section is mounted. */
|
|
78
|
+
announceToAgent: boolean
|
|
79
|
+
/**
|
|
80
|
+
* Secret storage mode: 'none' (default, VSCode Remote-SSH style: passwords
|
|
81
|
+
* never persisted, prompted once per session) or 'vault' (encrypted at
|
|
82
|
+
* rest, for unattended agents).
|
|
83
|
+
*
|
|
84
|
+
* This is the SINGLE SOURCE for the mode: the Vault and SecureHostStore are
|
|
85
|
+
* constructed once from it when the plugin loads, so changing it requires a
|
|
86
|
+
* plugin reload. The `dsh-ssh` settings namespace also exposes
|
|
87
|
+
* `secretStorage` for the settings UI, but that value cannot switch storage
|
|
88
|
+
* mode at runtime (see reportSecretStorageDrift below).
|
|
89
|
+
*/
|
|
90
|
+
secretStorage: 'none' | 'vault'
|
|
91
|
+
/**
|
|
92
|
+
* Whether the credential vault may auto-unlock from the
|
|
93
|
+
* `DSH_CREDENTIAL_PASSWORD` environment variable at plugin load.
|
|
94
|
+
*
|
|
95
|
+
* `'off'` (default): the vault stays locked until a password is entered, so a
|
|
96
|
+
* leaked ciphertext is not enough to recover credentials. `'env'` is the
|
|
97
|
+
* explicit opt-in for unattended/headless agents.
|
|
98
|
+
*/
|
|
99
|
+
vaultAutoUnlock: 'off' | 'env'
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export const Config: z<Config> = z.object({
|
|
103
|
+
enabled: z.boolean().default(true),
|
|
104
|
+
announceToAgent: z.boolean().default(true),
|
|
105
|
+
secretStorage: z.union([z.const('none'), z.const('vault')]).default('none'),
|
|
106
|
+
vaultAutoUnlock: z.union([z.const('off'), z.const('env')]).default('off'),
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The ONE effective secretStorage mode: the plugin config value, defaulting to
|
|
111
|
+
* 'none'. The Vault and SecureHostStore are constructed from this value once
|
|
112
|
+
* (plugin load), which is what makes it authoritative.
|
|
113
|
+
*/
|
|
114
|
+
export function resolveSecretStorageMode(config?: { secretStorage?: unknown }): 'none' | 'vault' {
|
|
115
|
+
return config?.secretStorage === 'vault' ? 'vault' : 'none'
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Read the `secretStorage` field out of a resolved settings section. */
|
|
119
|
+
function secretStorageOf(section: unknown): 'none' | 'vault' | undefined {
|
|
120
|
+
if (typeof section !== 'object' || section === null) return undefined
|
|
121
|
+
const value = (section as { secretStorage?: unknown }).secretStorage
|
|
122
|
+
return value === 'vault' ? 'vault' : value === 'none' ? 'none' : undefined
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Report a secretStorage disagreement between the RUNNING mode (fixed when the
|
|
127
|
+
* Vault/SecureHostStore were constructed at plugin load) and the value the
|
|
128
|
+
* `dsh-ssh` settings namespace currently resolves to. The mode cannot change
|
|
129
|
+
* at runtime, so staying silent would let the settings UI claim a storage mode
|
|
130
|
+
* this process is not using.
|
|
131
|
+
* @param running - the construction-time mode actually in effect.
|
|
132
|
+
* @param section - the resolved `dsh-ssh` settings section (any shape).
|
|
133
|
+
* @returns the warning text, or undefined when both agree / the value is unusable.
|
|
134
|
+
*/
|
|
135
|
+
export function secretStorageDriftMessage(running: 'none' | 'vault', section: unknown): string | undefined {
|
|
136
|
+
const requested = secretStorageOf(section)
|
|
137
|
+
if (requested === undefined || requested === running) return undefined
|
|
138
|
+
return `[dsh-hardssh] secretStorage: the dsh-ssh settings namespace resolves to '${requested}' but this process is running '${running}'. The mode is fixed by the plugin config (secretStorage) when the plugin loads — the vault and host store are constructed once — so the namespace value is ignored; set secretStorage: '${requested}' in the plugin config and reload the plugin (restart dsh web) to switch.`
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Report secretStorage drift once at mount and on every committed settings
|
|
143
|
+
* change (deduplicated, so a repeated change does not spam the log). The
|
|
144
|
+
* settings service is a sibling context and cordis events travel UP from the
|
|
145
|
+
* emitting ctx, so the listener sits on the root context; `ctx.effect` keeps
|
|
146
|
+
* the subscription tied to this plugin's lifetime.
|
|
147
|
+
* @param ctx - the plugin context (provides `settings` when present).
|
|
148
|
+
* @param running - the construction-time mode actually in effect.
|
|
149
|
+
*/
|
|
150
|
+
export function watchSecretStorageDrift(ctx: Context, running: 'none' | 'vault'): void {
|
|
151
|
+
let last: string | undefined
|
|
152
|
+
const report = (section: unknown): void => {
|
|
153
|
+
const message = secretStorageDriftMessage(running, section)
|
|
154
|
+
if (message === undefined || message === last) return
|
|
155
|
+
last = message
|
|
156
|
+
console.warn(message)
|
|
157
|
+
}
|
|
158
|
+
const settings = ctx.get('settings') as { get?: (ns: string) => unknown } | undefined
|
|
159
|
+
report(settings?.get?.(SSH_SETTINGS_NAMESPACE))
|
|
160
|
+
ctx.effect(() => ctx.root.on('settings/updated', (ns, next) => {
|
|
161
|
+
if (ns === SSH_SETTINGS_NAMESPACE) report(next)
|
|
162
|
+
}), 'dsh-hardssh: secretStorage drift watch')
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Isolated-path options for one generic startup (tests inject temp dirs). */
|
|
166
|
+
export interface GenericBootOptions {
|
|
167
|
+
/** The frozen legacy SSH ledger file (~/.dsh/dsh-hardssh-workspaces.json). */
|
|
168
|
+
legacyPath: string
|
|
169
|
+
/** The generic ledger target (~/.dsh/workspaces/index.v1.json). */
|
|
170
|
+
genericPath: string
|
|
171
|
+
/** Migration report path. The report is also the durable cutover marker. */
|
|
172
|
+
reportPath?: string
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Run the Phase-7 cutover sequence against one generic core. A structurally
|
|
177
|
+
* valid atomic migration report is the durable cutover marker: once it exists,
|
|
178
|
+
* the generic ledger is permanently authoritative and startup never compares
|
|
179
|
+
* it with (or even reads) the frozen legacy source again. Without a marker the
|
|
180
|
+
* one-time migration runs and atomically publishes that report only after the
|
|
181
|
+
* generic snapshot has committed and verified.
|
|
182
|
+
*
|
|
183
|
+
* A valid marker is also a proof of existence: it records that N workspaces
|
|
184
|
+
* were committed, so a MISSING or UNREADABLE ledger underneath it is data loss,
|
|
185
|
+
* not a fresh deployment. `WorkspaceLedger` treats ENOENT as an empty array,
|
|
186
|
+
* which would silently drop every workspace forever (the marker suppresses the
|
|
187
|
+
* one-time import); this gate therefore recovers the ledger — `.last-good`,
|
|
188
|
+
* then the newest `.backup-*`, then a legacy re-import — and, failing that,
|
|
189
|
+
* refuses to start so the surfaces fail closed instead of showing nothing.
|
|
190
|
+
*
|
|
191
|
+
* The recovery is verified against the marker's own id list, so a source that
|
|
192
|
+
* would restore FEWER workspaces than were recorded is rejected instead of
|
|
193
|
+
* accepted as success. A ledger that exists but is corrupt is treated like a
|
|
194
|
+
* missing one; a ledger that exists and is valid is authoritative even when it
|
|
195
|
+
* is empty (deleting every workspace is a legitimate, recorded intent).
|
|
196
|
+
*
|
|
197
|
+
* On any failure the promise rejects and the core stays NOT ready, so workspace
|
|
198
|
+
* consumers fail closed instead of silently operating local files.
|
|
199
|
+
*/
|
|
200
|
+
export async function bootstrapGenericWorkspaceCore(core: WorkspaceCore, options: GenericBootOptions): Promise<void> {
|
|
201
|
+
const reportPath = options.reportPath ?? join(options.genericPath, '..', 'migration-report.json')
|
|
202
|
+
const marker = readGenericCutoverMarker(reportPath)
|
|
203
|
+
if (marker === undefined) {
|
|
204
|
+
await migrateLegacySshLedger({
|
|
205
|
+
mode: 'generic',
|
|
206
|
+
legacyPath: options.legacyPath,
|
|
207
|
+
genericPath: options.genericPath,
|
|
208
|
+
reportPath,
|
|
209
|
+
})
|
|
210
|
+
} else if (marker.recordCount > 0) {
|
|
211
|
+
const state = await inspectGenericLedger(options.genericPath)
|
|
212
|
+
if (state !== 'readable') {
|
|
213
|
+
const recovery = await recoverGenericLedger({
|
|
214
|
+
genericPath: options.genericPath,
|
|
215
|
+
legacyPath: options.legacyPath,
|
|
216
|
+
expectedIds: marker.ids,
|
|
217
|
+
})
|
|
218
|
+
if (!recovery.recovered) {
|
|
219
|
+
throw new Error(
|
|
220
|
+
`generic workspace ledger '${options.genericPath}' is ${state} although the cutover marker '${reportPath}' reports ${marker.recordCount} workspace(s) `
|
|
221
|
+
+ `(recovery failed: ${recovery.reason ?? 'unknown reason'}); refusing to start with a reduced workspace set `
|
|
222
|
+
+ '— restore the ledger from a backup, or delete the marker to re-run the one-time import',
|
|
223
|
+
)
|
|
224
|
+
}
|
|
225
|
+
// Leave durable, user-inspectable evidence: a console line alone is easy
|
|
226
|
+
// to miss, and this is the only signal that workspaces came back from a
|
|
227
|
+
// recovery source rather than normal startup.
|
|
228
|
+
writeRecoveryReport(options.genericPath, marker, recovery)
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
await core.initialize()
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Best-effort sidecar record of one automatic ledger recovery. */
|
|
235
|
+
function writeRecoveryReport(
|
|
236
|
+
genericPath: string,
|
|
237
|
+
marker: WorkspaceMigrationReport,
|
|
238
|
+
recovery: { source?: string; path?: string; recordCount?: number },
|
|
239
|
+
): void {
|
|
240
|
+
try {
|
|
241
|
+
const target = join(genericPath, '..', 'recovery-report.json')
|
|
242
|
+
writeFileSync(target, `${JSON.stringify({
|
|
243
|
+
schemaVersion: 1,
|
|
244
|
+
recoveredAt: new Date().toISOString(),
|
|
245
|
+
ledger: genericPath,
|
|
246
|
+
source: recovery.source,
|
|
247
|
+
sourcePath: recovery.path,
|
|
248
|
+
restoredRecordCount: recovery.recordCount,
|
|
249
|
+
markerRecordCount: marker.recordCount,
|
|
250
|
+
markerIds: marker.ids,
|
|
251
|
+
}, null, 2)}\n`, 'utf8')
|
|
252
|
+
} catch (error) {
|
|
253
|
+
console.warn(`[dsh-hardssh] workspace ledger was restored but the recovery report could not be written: ${error instanceof Error ? error.message : String(error)}`)
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Validate only the persisted report schema, never current ledger/source
|
|
259
|
+
* content. The marker records that the migration committed and verified; its
|
|
260
|
+
* presence is what makes later generic CRUD (including rename and deleting the
|
|
261
|
+
* last SSH record) authoritative across restarts.
|
|
262
|
+
*
|
|
263
|
+
* The parsed marker is returned (not just a boolean) because its `recordCount`
|
|
264
|
+
* is what distinguishes "the ledger is legitimately empty" from "the ledger
|
|
265
|
+
* file went missing" at boot.
|
|
266
|
+
*
|
|
267
|
+
* @param path - the migration report path.
|
|
268
|
+
* @returns the validated marker, or undefined when absent/malformed.
|
|
269
|
+
*/
|
|
270
|
+
function readGenericCutoverMarker(path: string): WorkspaceMigrationReport | undefined {
|
|
271
|
+
if (!existsSync(path)) return undefined
|
|
272
|
+
let value: unknown
|
|
273
|
+
try {
|
|
274
|
+
value = JSON.parse(readFileSync(path, 'utf8')) as unknown
|
|
275
|
+
} catch {
|
|
276
|
+
return undefined
|
|
277
|
+
}
|
|
278
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined
|
|
279
|
+
const marker = value as Record<string, unknown>
|
|
280
|
+
if (marker.schemaVersion !== 1 || marker.mode !== 'generic') return undefined
|
|
281
|
+
if (marker.status !== 'migrated' && marker.status !== 'unchanged') return undefined
|
|
282
|
+
if (typeof marker.createdAt !== 'string' || Number.isNaN(Date.parse(marker.createdAt))) return undefined
|
|
283
|
+
if (typeof marker.sourceDigest !== 'string' || !/^[0-9a-f]{64}$/u.test(marker.sourceDigest)) return undefined
|
|
284
|
+
if (typeof marker.targetDigest !== 'string' || !/^[0-9a-f]{64}$/u.test(marker.targetDigest)) return undefined
|
|
285
|
+
if (typeof marker.recordCount !== 'number' || !Number.isSafeInteger(marker.recordCount) || marker.recordCount < 0) return undefined
|
|
286
|
+
if (!Array.isArray(marker.ids) || marker.ids.length !== marker.recordCount || marker.ids.some(id => typeof id !== 'string' || id === '')) return undefined
|
|
287
|
+
if (new Set(marker.ids).size !== marker.ids.length) return undefined
|
|
288
|
+
if (!Array.isArray(marker.differences) || marker.differences.some(difference => typeof difference !== 'string') || marker.differences.length !== 0) return undefined
|
|
289
|
+
if (marker.backupPath !== undefined && typeof marker.backupPath !== 'string') return undefined
|
|
290
|
+
return value as WorkspaceMigrationReport
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Migration report path next to the generic ledger (~/.dsh/workspaces/
|
|
295
|
+
* migration-report.json). The migration module cannot know the deployment
|
|
296
|
+
* directory (only the caller passes it through GenericBootOptions), so the
|
|
297
|
+
* production assembly computes the path here once.
|
|
298
|
+
*/
|
|
299
|
+
function genericMigrationReportPath(): string {
|
|
300
|
+
return join(genericLedgerPath(), '..', 'migration-report.json')
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Order of the announcement section (right after the dsh-ssh section at 150). */
|
|
304
|
+
const SECTION_ORDER = 160
|
|
305
|
+
|
|
306
|
+
/** Minimal shape of the assembly context the guidance section reads. The DSH
|
|
307
|
+
* type (`AssembleContext`) only declares scope/signal, but the agent loop
|
|
308
|
+
* passes `{ agent, scope, signal }` (see dsh-agent assembleContextFor). */
|
|
309
|
+
interface GuidanceAssemblyContext {
|
|
310
|
+
agent?: { session?: { header?: { cwd?: string } } }
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Render the workspace guidance from the RECORD SOURCE for this session:
|
|
314
|
+
* a cwd the store binds renders the remote branch (with the real alias /
|
|
315
|
+
* remote root), everything else the local branch. No path-string matching. */
|
|
316
|
+
function renderWorkspaceGuidance(workspaces: WorkspaceStoreView, context: unknown): string {
|
|
317
|
+
const agent = (context as GuidanceAssemblyContext).agent
|
|
318
|
+
const cwd = agent?.session?.header?.cwd
|
|
319
|
+
const record = cwd === undefined || cwd === '' ? undefined : workspaces.findByAnchorSync(cwd)
|
|
320
|
+
if (record === undefined) return localGuidance()
|
|
321
|
+
return remoteGuidance(record)
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** Local-session branch of the announcement. Exported for the guidance test. */
|
|
325
|
+
export function localGuidance(): string {
|
|
326
|
+
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 工作区 / 远程工作区 / 远程文件 / 远程项目 / 远程服务器上改代码」时即指本插件。'
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* SSH-bound-session branch of the announcement.
|
|
331
|
+
*
|
|
332
|
+
* The tool facts here must match the seams. read/write/edit route through
|
|
333
|
+
* `SwitchFileSystem` (server data remote, client-declared local roots local).
|
|
334
|
+
* glob/grep do NOT: they run the CLIENT's bundled ripgrep through
|
|
335
|
+
* `ctx.subprocess`, which cannot see the remote workspace, so the subprocess
|
|
336
|
+
* facade refuses that combination and points here to the remote tools. `pwsh`
|
|
337
|
+
* is a client-native binary and runs on this machine.
|
|
338
|
+
*
|
|
339
|
+
* Exported for the guidance test.
|
|
340
|
+
*/
|
|
341
|
+
export function remoteGuidance(record: SshWorkspaceRecord): string {
|
|
342
|
+
return `本机已安装 dsh-hardssh 插件(SSH 工作区)。当前会话绑定到远程工作区「${record.title}」(${record.alias} @ ${record.remoteRoot}):
|
|
343
|
+
- read / write / edit 自动路由到远程(SFTP);路径用远程绝对路径(如 ${record.remoteRoot}/src/main.ts),相对路径以远程根目录为基准。
|
|
344
|
+
- glob / grep 用的是**本机**打包的 ripgrep,看不到服务器内容;在 SSH 工作区里调用会被明确拒绝(不会静默返回空结果)。请在远端检索时用 remote_search(mode="glob" 按文件名、mode="grep" 按固定字符串搜内容,有限深与条数上限)、remote_ls、remote_status。
|
|
345
|
+
- pwsh / powershell / cmd 是客户端原生二进制,在本机执行;远端为 POSIX 主机时请用 bash 语义命令或 ssh_exec。
|
|
346
|
+
- 远程操作消耗真实远程资源,先确认再执行。`
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* A-04 startup half: replay host-workspace registration for every stored
|
|
351
|
+
* record once the workspace store is ready, so a process restart (or a create
|
|
352
|
+
* whose registration failed before the compensating rollback existed) cannot
|
|
353
|
+
* leave a binding whose sidebar entry is missing. `registerHostWorkspace` is
|
|
354
|
+
* create-if-missing, so replay is idempotent.
|
|
355
|
+
*
|
|
356
|
+
* Thin delegation to the shared `reconcileHostWorkspaces` (src/routes.ts) that
|
|
357
|
+
* the loopback-guarded `/reconcile` route also calls, so the two halves cannot
|
|
358
|
+
* drift. This wrapper owns the startup-only rules: never reject (a broken host
|
|
359
|
+
* registry or an unreadable ledger must not break plugin load) and log what
|
|
360
|
+
* happened instead of surfacing it to the loader.
|
|
361
|
+
* @param deps - the record source and the host-registry registration hook.
|
|
362
|
+
* @returns the shared reconcile report (empty when the record list failed).
|
|
363
|
+
*/
|
|
364
|
+
export async function reconcileHostWorkspacesOnStartup(deps: HostWorkspaceReconcileDeps): Promise<HostWorkspaceReconcileReport> {
|
|
365
|
+
try {
|
|
366
|
+
const report = await reconcileHostWorkspaces(deps)
|
|
367
|
+
// The shared helper reports an unreadable record source instead of
|
|
368
|
+
// throwing; the startup path must still surface it in the log.
|
|
369
|
+
if (report.listError !== undefined) {
|
|
370
|
+
console.warn(`[dsh-hardssh] host-workspace startup reconciliation could not list workspaces: ${report.listError}`)
|
|
371
|
+
}
|
|
372
|
+
for (const failure of report.failures) {
|
|
373
|
+
console.warn(`[dsh-hardssh] host-workspace startup reconciliation failed for workspace '${failure.id}': ${failure.error}`)
|
|
374
|
+
}
|
|
375
|
+
return report
|
|
376
|
+
} catch (error) {
|
|
377
|
+
console.warn(`[dsh-hardssh] host-workspace startup reconciliation failed: ${error instanceof Error ? error.message : String(error)}`)
|
|
378
|
+
return { registered: 0, failures: [] }
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Mount the mode store, routes, tools, announcement, guard, and the shared core.
|
|
384
|
+
* @param ctx - host plugin context carrying tools/systemPrompt (webServer optional).
|
|
385
|
+
* @param config - resolved plugin config (schema defaults applied by the loader).
|
|
386
|
+
*/
|
|
387
|
+
export function apply(ctx: Context, config?: Config): void {
|
|
388
|
+
const resolved = {
|
|
389
|
+
enabled: config?.enabled ?? true,
|
|
390
|
+
announceToAgent: config?.announceToAgent ?? true,
|
|
391
|
+
// Secret storage: 'none' (VSCode Remote-SSH style; default) or 'vault'.
|
|
392
|
+
// Single-sourced: the Vault/SecureHostStore below are built from THIS
|
|
393
|
+
// value, and any disagreement with the dsh-ssh settings namespace is
|
|
394
|
+
// reported through secretStorageDriftMessage (never silently accepted).
|
|
395
|
+
secretStorage: resolveSecretStorageMode(config),
|
|
396
|
+
// Env-based vault auto-unlock is opt-in (see the Config docs): the variable
|
|
397
|
+
// is visible to anything running as this user, so it is not a default.
|
|
398
|
+
vaultAutoUnlock: config?.vaultAutoUnlock === 'env' ? 'env' as const : 'off' as const,
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Host-key TOFU: connections are refused until the operator confirms the
|
|
402
|
+
// server fingerprint (see known-hosts.ts). Default-enabled for new installs;
|
|
403
|
+
// the engine falls back to pre-security behavior when no store is passed.
|
|
404
|
+
const knownHosts = new KnownHostsStore()
|
|
405
|
+
// Credential handling per secretStorage:
|
|
406
|
+
// - 'vault': encrypt at rest (AES-256-GCM + scrypt) via SecureHostStore;
|
|
407
|
+
// for unattended agents running password hosts.
|
|
408
|
+
// - 'none' (default): passwords are NEVER persisted — they are prompted
|
|
409
|
+
// once (VSCode Remote-SSH style) and held in the engine's in-memory table
|
|
410
|
+
// for that CONNECTION's lifetime (the pool's retirement drops them).
|
|
411
|
+
const vault = resolved.secretStorage === 'vault'
|
|
412
|
+
? new Vault(undefined, { allowEnvUnlock: resolved.vaultAutoUnlock === 'env' })
|
|
413
|
+
: undefined
|
|
414
|
+
const secureHosts = new SecureHostStore(vault, undefined, undefined, resolved.secretStorage)
|
|
415
|
+
const engine = new SshEngine(secureHosts, undefined, {
|
|
416
|
+
knownHosts,
|
|
417
|
+
resolveSecrets: (entry) => secureHosts.resolveAuth(entry),
|
|
418
|
+
redactOutput: vault === undefined ? undefined : (text) => vault.redact(text),
|
|
419
|
+
})
|
|
420
|
+
ctx.effect(() => () => {
|
|
421
|
+
engine.dispose()
|
|
422
|
+
vault?.dispose()
|
|
423
|
+
}, 'dsh-hardssh: engine')
|
|
424
|
+
|
|
425
|
+
// Canonical provider-neutral workspace runtime. It is the only in-process
|
|
426
|
+
// ledger/router used by fs, subprocess, routes, tools and host-delete guards.
|
|
427
|
+
const genericCore = mountWorkspaceCore(ctx, { engine, hosts: secureHosts })
|
|
428
|
+
const boot = bootstrapGenericWorkspaceCore(genericCore, {
|
|
429
|
+
legacyPath: ledgerPath(),
|
|
430
|
+
genericPath: genericLedgerPath(),
|
|
431
|
+
// The atomically published report is also the permanent cutover marker.
|
|
432
|
+
reportPath: genericMigrationReportPath(),
|
|
433
|
+
})
|
|
434
|
+
// A failed startup must not become an unhandled rejection: consumers await
|
|
435
|
+
// this same promise and fail closed; there is no in-process legacy fallback.
|
|
436
|
+
void boot.catch((error: unknown) => {
|
|
437
|
+
console.error('[dsh-hardssh] generic workspace runtime failed to initialize — workspace consumers will fail closed (no silent local fallback):', error instanceof Error ? error.message : String(error))
|
|
438
|
+
})
|
|
439
|
+
// Preserve the established SSH anchor layout across the one-time import.
|
|
440
|
+
const workspaces: WorkspaceStoreView = new GenericWorkspaceStore(genericCore, boot, anchorRoot())
|
|
441
|
+
|
|
442
|
+
// SSH-specific integrations retain only the shared host store and engine.
|
|
443
|
+
// dsh-workbench consumes `workspaceCore` directly; no sshWorkspaceCore or
|
|
444
|
+
// synchronous compatibility runner alias remains.
|
|
445
|
+
const core: HardsshCore = { hosts: secureHosts, engine }
|
|
446
|
+
ctx.provide('hardsshCore', core)
|
|
447
|
+
|
|
448
|
+
// SSH operations capability (host manager, ssh_* tools, /api/dsh-ssh,
|
|
449
|
+
// terminal, settings, prompt) — mounted against the SAME engine/store and
|
|
450
|
+
// kept independent of the workspace `enabled` switch below (its own
|
|
451
|
+
// `dsh-ssh` settings namespace toggles it). The host-delete reference guard
|
|
452
|
+
// reads the same record source as the workspace surfaces.
|
|
453
|
+
mountSshCapability(ctx, { store: secureHosts, engine, knownHosts, vault, ledger: workspaces })
|
|
454
|
+
|
|
455
|
+
// C-04: secretStorage is a construction-time decision (vault + store above).
|
|
456
|
+
// The dsh-ssh settings namespace exposes the same key for the settings UI,
|
|
457
|
+
// but its resolve() only reads enabled/announceToAgent, so editing it cannot
|
|
458
|
+
// switch storage mode. Report the disagreement instead of pretending it
|
|
459
|
+
// applied: once at mount, then on every committed settings change. The
|
|
460
|
+
// namespace is registered by mountSshCapability above, so the first read
|
|
461
|
+
// already sees the resolved value.
|
|
462
|
+
watchSecretStorageDrift(ctx, resolved.secretStorage)
|
|
463
|
+
|
|
464
|
+
if (!resolved.enabled) return
|
|
465
|
+
|
|
466
|
+
// Host workspace registration hooks (make the anchor a real sidebar
|
|
467
|
+
// workspace). workspaceRegistry is optional — headless profiles lack it.
|
|
468
|
+
const registerHostWorkspace = async (anchorPath: string, title: string): Promise<void> => {
|
|
469
|
+
const registry = ctx.get('workspaceRegistry') as { resolveByPath?: (path: string) => Promise<{ id: string } | undefined>; create?: (path: string, title?: string) => Promise<{ id: string }> } | undefined
|
|
470
|
+
if (registry?.create === undefined) return
|
|
471
|
+
const existing = registry.resolveByPath !== undefined ? await registry.resolveByPath(anchorPath) : undefined
|
|
472
|
+
if (existing !== undefined) return
|
|
473
|
+
await registry.create(anchorPath, title)
|
|
474
|
+
}
|
|
475
|
+
const unregisterHostWorkspace = async (anchorPath: string): Promise<void> => {
|
|
476
|
+
const registry = ctx.get('workspaceRegistry') as { resolveByPath?: (path: string) => Promise<{ id: string } | undefined>; delete?: (id: string) => Promise<boolean> } | undefined
|
|
477
|
+
if (registry?.resolveByPath === undefined || registry.delete === undefined) return
|
|
478
|
+
const existing = await registry.resolveByPath(anchorPath)
|
|
479
|
+
if (existing !== undefined) {
|
|
480
|
+
const removed = await registry.delete(existing.id)
|
|
481
|
+
if (!removed) throw new Error(`host workspace registry refused to delete '${existing.id}'`)
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
const routes = makeRoutes({
|
|
486
|
+
hosts: secureHosts,
|
|
487
|
+
engine,
|
|
488
|
+
workspaces,
|
|
489
|
+
registerHostWorkspace,
|
|
490
|
+
unregisterHostWorkspace,
|
|
491
|
+
})
|
|
492
|
+
// webServer is optional (headless profiles lack it): dynamic inject keeps
|
|
493
|
+
// this plugin loadable everywhere, mounting routes only when it appears.
|
|
494
|
+
// The inject callback receives a scoped Context with the service available.
|
|
495
|
+
ctx.inject(['webServer'], (scoped) => {
|
|
496
|
+
const disposers = routes.map(route => scoped.webServer.register(route))
|
|
497
|
+
return () => {
|
|
498
|
+
for (const dispose of disposers) dispose()
|
|
499
|
+
}
|
|
500
|
+
})
|
|
501
|
+
|
|
502
|
+
// A-04 startup half: a restart does not replay sidebar registration, and a
|
|
503
|
+
// create whose registration failed before compensation existed leaves a
|
|
504
|
+
// binding with no host workspace. Reconcile once, after the record source is
|
|
505
|
+
// wired (the generic store gates on its boot). The helper never rejects; the
|
|
506
|
+
// catch is belt-and-braces so a future change cannot break plugin load.
|
|
507
|
+
void reconcileHostWorkspacesOnStartup({ workspaces, registerHostWorkspace }).catch((error: unknown) => {
|
|
508
|
+
console.warn('[dsh-hardssh] host-workspace startup reconciliation failed:', error instanceof Error ? error.message : String(error))
|
|
509
|
+
})
|
|
510
|
+
|
|
511
|
+
// Bound-workspace ops always open the generic logical connection and use its
|
|
512
|
+
// workspace.fs/workspace.search capabilities.
|
|
513
|
+
const toolOps = capabilityToolOpsResolver(workspaces, genericCore)
|
|
514
|
+
const tools = makeWorkspaceTools({ workspaces, ops: toolOps })
|
|
515
|
+
ctx.effect(() => {
|
|
516
|
+
const disposers = tools.map(tool => ctx.tools.register(tool))
|
|
517
|
+
return () => {
|
|
518
|
+
for (const dispose of disposers) dispose()
|
|
519
|
+
}
|
|
520
|
+
}, 'dsh-hardssh: tools')
|
|
521
|
+
|
|
522
|
+
// Tool routing for SSH-bound sessions is handled by the fs/subprocess
|
|
523
|
+
// seams, not by tool guards: glob/grep operate through the routed fs
|
|
524
|
+
// (server data remote, declared client roots local — skills readable),
|
|
525
|
+
// and client-native binaries (pwsh.exe etc.) spawn locally via the
|
|
526
|
+
// subprocess switch. See switch-fs.ts / switch-subprocess.ts.
|
|
527
|
+
|
|
528
|
+
if (resolved.announceToAgent) {
|
|
529
|
+
ctx.systemPrompt.section({
|
|
530
|
+
name: 'plugin:dsh-hardssh',
|
|
531
|
+
order: SECTION_ORDER,
|
|
532
|
+
text: (context) => renderWorkspaceGuidance(workspaces, context),
|
|
533
|
+
})
|
|
534
|
+
}
|
|
535
|
+
}
|