@tiphareth/dsh-hardssh 0.1.2 → 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 +1171 -1321
- 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 +58 -25
- 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 -96
- 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 -225
- package/src/remote-runner.ts +0 -201
- package/src/seam-state.ts +0 -185
package/src/client/api.ts
CHANGED
|
@@ -5,25 +5,25 @@
|
|
|
5
5
|
* origin 鈥?the only data path the panel components use.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { HttpApiError, buildQuery, readJson } from '../client-http.ts'
|
|
8
|
+
import { HttpApiError, buildQuery, readJson, type HttpErrorBody } from '../client-http.ts'
|
|
9
9
|
import {
|
|
10
10
|
WORKSPACE_API,
|
|
11
|
-
type DirListing,
|
|
12
|
-
type FileRead,
|
|
13
|
-
type FileWriteResult,
|
|
14
11
|
type RemoteDirEntry,
|
|
15
|
-
type SearchView,
|
|
16
12
|
type SshWorkspaceRecord,
|
|
17
13
|
} from '../protocol.ts'
|
|
18
14
|
|
|
19
|
-
/**
|
|
15
|
+
/**
|
|
16
|
+
* Workspace-route error: the shared transport error under this family's name.
|
|
17
|
+
* It adds no parsing — `readJson` in ../client-http.ts owns status/body
|
|
18
|
+
* decoding for both route families.
|
|
19
|
+
*/
|
|
20
20
|
export class WorkspaceApiError extends HttpApiError {
|
|
21
21
|
constructor(
|
|
22
22
|
message: string,
|
|
23
|
-
code?: string,
|
|
24
23
|
status?: number,
|
|
24
|
+
body?: HttpErrorBody,
|
|
25
25
|
) {
|
|
26
|
-
super(message,
|
|
26
|
+
super(message, status, body)
|
|
27
27
|
this.name = 'WorkspaceApiError'
|
|
28
28
|
}
|
|
29
29
|
}
|
|
@@ -80,34 +80,6 @@ export class WorkspaceApi {
|
|
|
80
80
|
const body = await readJson<{ path: string; entries: RemoteDirEntry[] }>(response)
|
|
81
81
|
return body
|
|
82
82
|
}
|
|
83
|
-
|
|
84
|
-
async list(root: string, path: string): Promise<DirListing> {
|
|
85
|
-
const response = await fetch(WORKSPACE_API.tree + buildQuery({ root, path }))
|
|
86
|
-
const body = await readJson<{ listing: DirListing }>(response)
|
|
87
|
-
return body.listing
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
async read(root: string, path: string): Promise<FileRead> {
|
|
91
|
-
const response = await fetch(WORKSPACE_API.file + buildQuery({ root, path }))
|
|
92
|
-
const body = await readJson<{ file: FileRead }>(response)
|
|
93
|
-
return body.file
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
async write(root: string, path: string, content: string, expectedMtime?: number): Promise<FileWriteResult> {
|
|
97
|
-
const response = await fetch(WORKSPACE_API.file, {
|
|
98
|
-
method: 'PUT',
|
|
99
|
-
headers: { 'content-type': 'application/json' },
|
|
100
|
-
body: JSON.stringify({ root, path, content, expectedMtime }),
|
|
101
|
-
})
|
|
102
|
-
const body = await readJson<{ result: FileWriteResult }>(response)
|
|
103
|
-
return body.result
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
async search(root: string, queryText: string): Promise<SearchView> {
|
|
107
|
-
const response = await fetch(WORKSPACE_API.search + buildQuery({ root, query: queryText }))
|
|
108
|
-
const body = await readJson<{ search: SearchView }>(response)
|
|
109
|
-
return body.search
|
|
110
|
-
}
|
|
111
83
|
}
|
|
112
84
|
|
|
113
85
|
/** The two /api/dsh-ssh endpoints the config dialog needs (host create/test). */
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Probe-and-prompt for an SSH alias, with a browser-session pass cache.
|
|
3
|
+
*
|
|
4
|
+
* This is the interactive connection gate on its own — no row, no banner, no
|
|
5
|
+
* DOM decoration. Flows that surface their own errors (the workspace creation
|
|
6
|
+
* directory browser) call `connectHost` and get a plain boolean.
|
|
7
|
+
*
|
|
8
|
+
* The probe is the engine's `/test` route (a real, lightweight connect):
|
|
9
|
+
* - `HOST_KEY_UNKNOWN` / `HOST_KEY_MISMATCH` → the host fingerprint dialog
|
|
10
|
+
* (TOFU first encounter / key rotation);
|
|
11
|
+
* - `NEEDS_PASSWORD` / `NEEDS_PASSPHRASE` → the VSCode-style session password
|
|
12
|
+
* dialog (never persisted, session-only);
|
|
13
|
+
* - ok → the alias is marked passed for a TTL (trust is durable and the session
|
|
14
|
+
* password survives in the engine's session table until the process exits).
|
|
15
|
+
*
|
|
16
|
+
* @module dsh-hardssh/client/connect-host
|
|
17
|
+
*/
|
|
18
|
+
import { createElement, type ReactElement } from 'react'
|
|
19
|
+
import { createRoot } from 'react-dom/client'
|
|
20
|
+
import type { SshApi } from './ssh/api.ts'
|
|
21
|
+
import { SshApiError } from './ssh/api.ts'
|
|
22
|
+
import { ConnectionErrorDialog } from './ssh/panel/ConnectionErrorDialog.tsx'
|
|
23
|
+
import { HostFingerprintDialog } from './ssh/panel/HostFingerprintDialog.tsx'
|
|
24
|
+
import { SessionSecretDialog } from './ssh/panel/SessionSecretDialog.tsx'
|
|
25
|
+
import { tt } from './text.ts'
|
|
26
|
+
|
|
27
|
+
/** A passed alias stays valid this long (trust is durable; the session password
|
|
28
|
+
* table lives in the host process for the session). */
|
|
29
|
+
const GATE_TTL_MS = 5 * 60 * 1000
|
|
30
|
+
/** Cap on interactive retry rounds (wrong password loops etc.). */
|
|
31
|
+
const MAX_ATTEMPTS = 3
|
|
32
|
+
|
|
33
|
+
/** alias → last successful probe timestamp (browser-session scope). */
|
|
34
|
+
const passedAt = new Map<string, number>()
|
|
35
|
+
/** alias → in-flight probe promise (no duplicate dialogs on multi-click). */
|
|
36
|
+
const pending = new Map<string, Promise<boolean>>()
|
|
37
|
+
/** alias → SSH login name (fetched once for the user@alias prompt). */
|
|
38
|
+
let hostsCache: Map<string, string> | null = null
|
|
39
|
+
/** At most one visible non-interactive failure dialog per alias. */
|
|
40
|
+
const failureDialogs = new Map<string, () => void>()
|
|
41
|
+
|
|
42
|
+
async function hostsUser(api: SshApi, alias: string): Promise<string | undefined> {
|
|
43
|
+
if (hostsCache === null) {
|
|
44
|
+
try {
|
|
45
|
+
const list = await api.listHosts()
|
|
46
|
+
hostsCache = new Map(list.map(host => [host.alias, host.user]))
|
|
47
|
+
} catch {
|
|
48
|
+
// Deliberately NOT cached: a transient list failure must not pin an empty
|
|
49
|
+
// map for the rest of the process (the secret prompt would then never
|
|
50
|
+
// show the login name again). The next call retries the list.
|
|
51
|
+
return undefined
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return hostsCache.get(alias)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Whether the alias passed the gate within its TTL. */
|
|
58
|
+
function gateActive(alias: string, now: number): boolean {
|
|
59
|
+
const at = passedAt.get(alias)
|
|
60
|
+
return at !== undefined && now - at < GATE_TTL_MS
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Mount a dialog component into a fresh root; returns its closer. The dialog's
|
|
65
|
+
* `onClose` prop is CHAINED: the caller's handler (resolving its promise) runs
|
|
66
|
+
* first, then the root is torn down. Without the chaining a cancel would
|
|
67
|
+
* unmount the dialog but never resolve the caller's promise, leaving the alias
|
|
68
|
+
* stuck "in flight" so later calls stop prompting.
|
|
69
|
+
*/
|
|
70
|
+
function mountModal(node: ReactElement): () => void {
|
|
71
|
+
const host = document.createElement('div')
|
|
72
|
+
host.dataset.sshGateDialog = ''
|
|
73
|
+
document.body.appendChild(host)
|
|
74
|
+
const root = createRoot(host)
|
|
75
|
+
const close = (): void => {
|
|
76
|
+
root.unmount()
|
|
77
|
+
host.remove()
|
|
78
|
+
}
|
|
79
|
+
const callerOnClose = (node.props as { onClose?: () => void }).onClose
|
|
80
|
+
const chainedOnClose = callerOnClose !== undefined
|
|
81
|
+
? () => { callerOnClose(); close() }
|
|
82
|
+
: close
|
|
83
|
+
root.render(createElement(node.type, { ...node.props, onClose: chainedOnClose }))
|
|
84
|
+
return close
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Log and surface one concrete non-interactive connection failure. */
|
|
88
|
+
function reportConnectionFailure(alias: string, detail: string): false {
|
|
89
|
+
console.warn(`[dsh-hardssh] SSH probe failed for '${alias}': ${detail}`)
|
|
90
|
+
if (typeof document === 'undefined') return false
|
|
91
|
+
failureDialogs.get(alias)?.()
|
|
92
|
+
const close = mountModal(createElement(ConnectionErrorDialog, {
|
|
93
|
+
alias,
|
|
94
|
+
detail,
|
|
95
|
+
onClose: () => { failureDialogs.delete(alias) },
|
|
96
|
+
}))
|
|
97
|
+
failureDialogs.set(alias, close)
|
|
98
|
+
return false
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Interactive prompts, resolving true when the user finished the action. */
|
|
102
|
+
function promptFingerprint(api: SshApi, alias: string, fingerprintSha256: string, mismatch: boolean): Promise<boolean> {
|
|
103
|
+
return new Promise((resolve) => {
|
|
104
|
+
const close = mountModal(createElement(HostFingerprintDialog, {
|
|
105
|
+
api,
|
|
106
|
+
alias,
|
|
107
|
+
fingerprintSha256,
|
|
108
|
+
mismatch,
|
|
109
|
+
onClose: () => { resolve(false) },
|
|
110
|
+
onTrusted: () => { close(); resolve(true) },
|
|
111
|
+
}))
|
|
112
|
+
})
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function promptSecret(api: SshApi, alias: string, secret: 'password' | 'passphrase', reason?: string): Promise<boolean> {
|
|
116
|
+
return new Promise((resolve) => {
|
|
117
|
+
let closed = false
|
|
118
|
+
const finish = (ok: boolean): void => {
|
|
119
|
+
if (closed) return
|
|
120
|
+
closed = true
|
|
121
|
+
resolve(ok)
|
|
122
|
+
}
|
|
123
|
+
void hostsUser(api, alias).then((user) => {
|
|
124
|
+
const close = mountModal(createElement(SessionSecretDialog, {
|
|
125
|
+
api,
|
|
126
|
+
alias,
|
|
127
|
+
user,
|
|
128
|
+
secret,
|
|
129
|
+
reason,
|
|
130
|
+
onClose: () => { finish(false) },
|
|
131
|
+
onProvided: () => { close(); finish(true) },
|
|
132
|
+
}))
|
|
133
|
+
})
|
|
134
|
+
})
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Probe + prompt until connected. Resolves true when the alias may proceed;
|
|
139
|
+
* false when the user cancelled or the failure is not interactive. When a
|
|
140
|
+
* credential was already supplied and the retry still fails (wrong password,
|
|
141
|
+
* unreachable host, network drop, …), the password dialog re-opens WITH the
|
|
142
|
+
* failure reason shown, VSCode style — no silent "stuck" states.
|
|
143
|
+
*
|
|
144
|
+
* @param api - the SSH API client.
|
|
145
|
+
* @param alias - the host alias to gate.
|
|
146
|
+
*/
|
|
147
|
+
export function connectHost(api: SshApi, alias: string): Promise<boolean> {
|
|
148
|
+
const now = Date.now()
|
|
149
|
+
if (gateActive(alias, now)) return Promise.resolve(true)
|
|
150
|
+
const inFlight = pending.get(alias)
|
|
151
|
+
if (inFlight !== undefined) return inFlight
|
|
152
|
+
|
|
153
|
+
const attempt = (async (): Promise<boolean> => {
|
|
154
|
+
let secretKind: 'password' | 'passphrase' | undefined
|
|
155
|
+
let lastFailure: string | null = null
|
|
156
|
+
for (let i = 0; i < MAX_ATTEMPTS; i++) {
|
|
157
|
+
try {
|
|
158
|
+
const result = await api.testHost(alias)
|
|
159
|
+
if (result.ok) {
|
|
160
|
+
passedAt.set(alias, Date.now())
|
|
161
|
+
return true
|
|
162
|
+
}
|
|
163
|
+
if (result.code === 'NEEDS_PASSWORD' && (result.secret === 'password' || result.secret === 'passphrase')) {
|
|
164
|
+
secretKind = result.secret
|
|
165
|
+
if (await promptSecret(api, alias, result.secret, lastFailure ?? undefined)) {
|
|
166
|
+
lastFailure = null
|
|
167
|
+
continue
|
|
168
|
+
}
|
|
169
|
+
return false
|
|
170
|
+
}
|
|
171
|
+
lastFailure = result.error ?? 'connection failed'
|
|
172
|
+
return reportConnectionFailure(alias, lastFailure)
|
|
173
|
+
} catch (cause) {
|
|
174
|
+
if (cause instanceof SshApiError && (cause.code === 'HOST_KEY_UNKNOWN' || cause.code === 'HOST_KEY_MISMATCH')) {
|
|
175
|
+
const fingerprint = cause.hostKeyFingerprint ?? ''
|
|
176
|
+
if (fingerprint !== '' && await promptFingerprint(api, alias, fingerprint, cause.code === 'HOST_KEY_MISMATCH')) continue
|
|
177
|
+
if (fingerprint === '') return reportConnectionFailure(alias, cause.message)
|
|
178
|
+
return false
|
|
179
|
+
}
|
|
180
|
+
lastFailure = cause instanceof Error ? cause.message : String(cause)
|
|
181
|
+
if (secretKind !== undefined) {
|
|
182
|
+
// Credential already entered but this attempt failed — re-prompt with
|
|
183
|
+
// the concrete reason instead of a bare failure.
|
|
184
|
+
if (await promptSecret(api, alias, secretKind, lastFailure)) {
|
|
185
|
+
lastFailure = null
|
|
186
|
+
continue
|
|
187
|
+
}
|
|
188
|
+
return false
|
|
189
|
+
}
|
|
190
|
+
return reportConnectionFailure(alias, lastFailure)
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return reportConnectionFailure(alias, lastFailure ?? tt('gate.retryLimit'))
|
|
194
|
+
})()
|
|
195
|
+
|
|
196
|
+
pending.set(alias, attempt)
|
|
197
|
+
void attempt.finally(() => { pending.delete(alias) })
|
|
198
|
+
return attempt
|
|
199
|
+
}
|
|
@@ -137,8 +137,16 @@ export function DirectoryFlow(props: DirectoryFlowOwnerProps & DirectoryFlowInje
|
|
|
137
137
|
const [addingHost, setAddingHost] = useState(false)
|
|
138
138
|
const [newHost, setNewHost] = useState({ alias: '', host: '', port: '22', user: '', password: '' })
|
|
139
139
|
const [savingHost, setSavingHost] = useState(false)
|
|
140
|
+
const openRef = useRef(open)
|
|
141
|
+
openRef.current = open
|
|
142
|
+
const localPickRequestSequence = useRef(0)
|
|
143
|
+
const hostsRequestSequence = useRef(0)
|
|
144
|
+
const directoryRequestSequence = useRef(0)
|
|
145
|
+
const createWorkspaceRequestSequence = useRef(0)
|
|
146
|
+
const createHostRequestSequence = useRef(0)
|
|
140
147
|
|
|
141
|
-
// Reset and re-locate on each open edge.
|
|
148
|
+
// Reset and re-locate on each open edge. Every async data source owns a
|
|
149
|
+
// request sequence; cleanup invalidates outstanding work from this opening.
|
|
142
150
|
useEffect(() => {
|
|
143
151
|
if (!open) return
|
|
144
152
|
setChoice('menu')
|
|
@@ -146,12 +154,31 @@ export function DirectoryFlow(props: DirectoryFlowOwnerProps & DirectoryFlowInje
|
|
|
146
154
|
setDirPath('')
|
|
147
155
|
setTitle('')
|
|
148
156
|
setDirEntries([])
|
|
157
|
+
setLoading(false)
|
|
158
|
+
setCreating(false)
|
|
159
|
+
setSavingHost(false)
|
|
149
160
|
setError(null)
|
|
150
161
|
setAddingHost(false)
|
|
151
162
|
setNewHost({ alias: '', host: '', port: '22', user: '', password: '' })
|
|
152
163
|
setAnchorRect()
|
|
153
|
-
// Lazy-load the host list for the SSH branch.
|
|
154
|
-
|
|
164
|
+
// Lazy-load the host list for the SSH branch. A later refresh (for
|
|
165
|
+
// example after adding a host) supersedes this request.
|
|
166
|
+
const request = ++hostsRequestSequence.current
|
|
167
|
+
void props.listHosts().then(
|
|
168
|
+
(list) => {
|
|
169
|
+
if (request === hostsRequestSequence.current && openRef.current) setHosts(list)
|
|
170
|
+
},
|
|
171
|
+
() => {
|
|
172
|
+
if (request === hostsRequestSequence.current && openRef.current) setHosts([])
|
|
173
|
+
},
|
|
174
|
+
)
|
|
175
|
+
return () => {
|
|
176
|
+
localPickRequestSequence.current += 1
|
|
177
|
+
hostsRequestSequence.current += 1
|
|
178
|
+
directoryRequestSequence.current += 1
|
|
179
|
+
createWorkspaceRequestSequence.current += 1
|
|
180
|
+
createHostRequestSequence.current += 1
|
|
181
|
+
}
|
|
155
182
|
}, [open])
|
|
156
183
|
|
|
157
184
|
/** Locate the trigger element that just opened the flow and anchor the menu
|
|
@@ -196,16 +223,22 @@ export function DirectoryFlow(props: DirectoryFlowOwnerProps & DirectoryFlowInje
|
|
|
196
223
|
|
|
197
224
|
/** Choose "Local workspace": drive the native chooser once. */
|
|
198
225
|
const pickLocal = (): void => {
|
|
226
|
+
const request = ++localPickRequestSequence.current
|
|
199
227
|
setChoice('picking-local')
|
|
200
228
|
props.pickDirectory().then(
|
|
201
229
|
(path) => {
|
|
230
|
+
if (request !== localPickRequestSequence.current || !openRef.current) return
|
|
202
231
|
if (path === null) {
|
|
203
232
|
outcome.current.onCancel()
|
|
204
233
|
} else {
|
|
205
234
|
outcome.current.onPicked(path)
|
|
206
235
|
}
|
|
207
236
|
},
|
|
208
|
-
(reason) =>
|
|
237
|
+
(reason) => {
|
|
238
|
+
if (request === localPickRequestSequence.current && openRef.current) {
|
|
239
|
+
outcome.current.onError(reason instanceof Error ? reason.message : String(reason))
|
|
240
|
+
}
|
|
241
|
+
},
|
|
209
242
|
)
|
|
210
243
|
}
|
|
211
244
|
|
|
@@ -213,32 +246,39 @@ export function DirectoryFlow(props: DirectoryFlowOwnerProps & DirectoryFlowInje
|
|
|
213
246
|
* a credential (host key untrusted / session password), run the interactive
|
|
214
247
|
* gate first, then retry — the operator never sees a raw "connect" error. */
|
|
215
248
|
const browse = async (alias: string, path?: string): Promise<void> => {
|
|
249
|
+
const request = ++directoryRequestSequence.current
|
|
216
250
|
if (alias === '') {
|
|
217
251
|
setError(tt('create.needHost'))
|
|
218
252
|
return
|
|
219
253
|
}
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
} catch (e: unknown) {
|
|
231
|
-
const code = (e as { code?: string })?.code
|
|
232
|
-
const interactive = code === 'NEEDS_PASSWORD' || code === 'HOST_KEY_UNKNOWN' || code === 'HOST_KEY_MISMATCH'
|
|
233
|
-
setLoading(false)
|
|
234
|
-
if (!interactive) {
|
|
235
|
-
setError(e instanceof Error ? e.message : String(e))
|
|
254
|
+
setLoading(true)
|
|
255
|
+
setError(null)
|
|
256
|
+
try {
|
|
257
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
258
|
+
try {
|
|
259
|
+
const result = await props.listRemoteDir(alias, path)
|
|
260
|
+
if (request !== directoryRequestSequence.current || !openRef.current) return
|
|
261
|
+
setDirPath(result.path)
|
|
262
|
+
setDirEntries(result.entries)
|
|
263
|
+
setChoice('browsing')
|
|
236
264
|
return
|
|
265
|
+
} catch (e: unknown) {
|
|
266
|
+
if (request !== directoryRequestSequence.current || !openRef.current) return
|
|
267
|
+
const code = (e as { code?: string })?.code
|
|
268
|
+
const interactive = code === 'NEEDS_PASSWORD' || code === 'HOST_KEY_UNKNOWN' || code === 'HOST_KEY_MISMATCH'
|
|
269
|
+
if (!interactive) {
|
|
270
|
+
setError(e instanceof Error ? e.message : String(e))
|
|
271
|
+
return
|
|
272
|
+
}
|
|
273
|
+
const connected = await props.ensureConnected(alias)
|
|
274
|
+
if (request !== directoryRequestSequence.current || !openRef.current) return
|
|
275
|
+
if (!connected) return // user cancelled
|
|
237
276
|
}
|
|
238
|
-
if (!await props.ensureConnected(alias)) return // user cancelled
|
|
239
277
|
}
|
|
278
|
+
if (request === directoryRequestSequence.current && openRef.current) setError(tt('create.needConnect'))
|
|
279
|
+
} finally {
|
|
280
|
+
if (request === directoryRequestSequence.current && openRef.current) setLoading(false)
|
|
240
281
|
}
|
|
241
|
-
setError(tt('create.needConnect'))
|
|
242
282
|
}
|
|
243
283
|
|
|
244
284
|
/** Create the SSH workspace; on success hand the anchor to the owner. */
|
|
@@ -247,20 +287,23 @@ export function DirectoryFlow(props: DirectoryFlowOwnerProps & DirectoryFlowInje
|
|
|
247
287
|
setError(host === '' ? tt('create.needHost') : dirPath === '' ? tt('create.needDir') : tt('create.needTitle'))
|
|
248
288
|
return
|
|
249
289
|
}
|
|
290
|
+
const request = ++createWorkspaceRequestSequence.current
|
|
250
291
|
setCreating(true)
|
|
251
292
|
setError(null)
|
|
252
293
|
try {
|
|
253
294
|
const record = await props.createSshWorkspace({ title: title.trim(), alias: host, remoteRoot: dirPath })
|
|
295
|
+
if (request !== createWorkspaceRequestSequence.current || !openRef.current) return
|
|
254
296
|
// The anchor is a REAL local dir (host workspace registration happens on
|
|
255
297
|
// the server); adopt it so the host workspace list refreshes.
|
|
256
298
|
outcome.current.onPicked(record.anchorPath)
|
|
257
299
|
} catch (e: unknown) {
|
|
258
|
-
|
|
300
|
+
if (request === createWorkspaceRequestSequence.current && openRef.current) {
|
|
301
|
+
setError(e instanceof Error ? e.message : String(e))
|
|
302
|
+
}
|
|
259
303
|
} finally {
|
|
260
|
-
// Always reset: the owner may keep the flow open
|
|
261
|
-
// a host-side adoption error)
|
|
262
|
-
|
|
263
|
-
setCreating(false)
|
|
304
|
+
// Always reset for the active opening: the owner may keep the flow open
|
|
305
|
+
// after onPicked (e.g. a host-side adoption error).
|
|
306
|
+
if (request === createWorkspaceRequestSequence.current && openRef.current) setCreating(false)
|
|
264
307
|
}
|
|
265
308
|
}
|
|
266
309
|
|
|
@@ -272,6 +315,7 @@ export function DirectoryFlow(props: DirectoryFlowOwnerProps & DirectoryFlowInje
|
|
|
272
315
|
setError(tt('host.needFields'))
|
|
273
316
|
return
|
|
274
317
|
}
|
|
318
|
+
const request = ++createHostRequestSequence.current
|
|
275
319
|
setSavingHost(true)
|
|
276
320
|
setError(null)
|
|
277
321
|
try {
|
|
@@ -282,15 +326,24 @@ export function DirectoryFlow(props: DirectoryFlowOwnerProps & DirectoryFlowInje
|
|
|
282
326
|
user: newHost.user.trim(),
|
|
283
327
|
auth: { kind: 'password', password: newHost.password },
|
|
284
328
|
})
|
|
329
|
+
if (request !== createHostRequestSequence.current || !openRef.current) return
|
|
330
|
+
const hostsRequest = ++hostsRequestSequence.current
|
|
285
331
|
const list = await props.listHosts()
|
|
332
|
+
if (
|
|
333
|
+
request !== createHostRequestSequence.current
|
|
334
|
+
|| hostsRequest !== hostsRequestSequence.current
|
|
335
|
+
|| !openRef.current
|
|
336
|
+
) return
|
|
286
337
|
setHosts(list)
|
|
287
338
|
setHost(created.alias)
|
|
288
339
|
setAddingHost(false)
|
|
289
340
|
setNewHost({ alias: '', host: '', port: '22', user: '', password: '' })
|
|
290
341
|
} catch (e: unknown) {
|
|
291
|
-
|
|
342
|
+
if (request === createHostRequestSequence.current && openRef.current) {
|
|
343
|
+
setError(e instanceof Error ? e.message : String(e))
|
|
344
|
+
}
|
|
292
345
|
} finally {
|
|
293
|
-
setSavingHost(false)
|
|
346
|
+
if (request === createHostRequestSequence.current && openRef.current) setSavingHost(false)
|
|
294
347
|
}
|
|
295
348
|
}
|
|
296
349
|
|
|
@@ -392,7 +445,7 @@ export function DirectoryFlow(props: DirectoryFlowOwnerProps & DirectoryFlowInje
|
|
|
392
445
|
<>
|
|
393
446
|
<label className={css.field} style={{ gridColumn: '1 / -1' }}>
|
|
394
447
|
<span>{tt('create.host')}</span>
|
|
395
|
-
<select value={host} onChange={(event) => setHost(event.target.value)}>
|
|
448
|
+
<select value={host} disabled={loading} onChange={(event) => setHost(event.target.value)}>
|
|
396
449
|
<option value="">{hosts.length === 0 ? tt('create.hostEmpty') : '—'}</option>
|
|
397
450
|
{hosts.map((item) => (
|
|
398
451
|
<option key={item.alias} value={item.alias}>{item.alias} ({item.user}@{item.host}:{item.port})</option>
|
|
@@ -403,6 +456,7 @@ export function DirectoryFlow(props: DirectoryFlowOwnerProps & DirectoryFlowInje
|
|
|
403
456
|
type="button"
|
|
404
457
|
className={css.menuItem}
|
|
405
458
|
style={{ gridColumn: '1 / -1', padding: '5px 8px' }}
|
|
459
|
+
disabled={loading}
|
|
406
460
|
onClick={() => setAddingHost(true)}
|
|
407
461
|
>
|
|
408
462
|
<span className={css.menuItemIcon}>+</span>
|
|
@@ -410,7 +464,7 @@ export function DirectoryFlow(props: DirectoryFlowOwnerProps & DirectoryFlowInje
|
|
|
410
464
|
</button>
|
|
411
465
|
<label className={css.field} style={{ gridColumn: '1 / -1' }}>
|
|
412
466
|
<span>{tt('create.dir')}</span>
|
|
413
|
-
<input value={dirPath} onChange={(event) => setDirPath(event.target.value)} placeholder={tt('create.dirPlaceholder')} spellCheck={false} />
|
|
467
|
+
<input value={dirPath} disabled={loading} onChange={(event) => setDirPath(event.target.value)} placeholder={tt('create.dirPlaceholder')} spellCheck={false} />
|
|
414
468
|
</label>
|
|
415
469
|
<div className={css.dialogActions} style={{ gridColumn: '1 / -1' }}>
|
|
416
470
|
<button type="button" className={css.button} disabled={busy || loading} onClick={() => void browse(host)}>
|
|
@@ -425,8 +479,8 @@ export function DirectoryFlow(props: DirectoryFlowOwnerProps & DirectoryFlowInje
|
|
|
425
479
|
)}
|
|
426
480
|
{!addingHost && (
|
|
427
481
|
<div className={css.dialogActions} style={{ gridColumn: '1 / -1' }}>
|
|
428
|
-
<button type="button" className={css.button} onClick={() => setChoice('menu')} disabled={busy || creating}>{tt('create.cancel')}</button>
|
|
429
|
-
<button type="button" className={`${css.button} ${css.buttonPrimary}`} disabled={busy || creating} onClick={() => void createSsh()}>
|
|
482
|
+
<button type="button" className={css.button} onClick={() => setChoice('menu')} disabled={busy || creating || loading}>{tt('create.cancel')}</button>
|
|
483
|
+
<button type="button" className={`${css.button} ${css.buttonPrimary}`} disabled={busy || creating || loading} onClick={() => void createSsh()}>
|
|
430
484
|
{creating ? tt('create.connecting') : tt('create.submit')}
|
|
431
485
|
</button>
|
|
432
486
|
</div>
|
|
@@ -460,7 +514,8 @@ export function DirectoryFlow(props: DirectoryFlowOwnerProps & DirectoryFlowInje
|
|
|
460
514
|
key={entry.name}
|
|
461
515
|
type="button"
|
|
462
516
|
className={css.hostRow}
|
|
463
|
-
|
|
517
|
+
disabled={loading}
|
|
518
|
+
style={{ width: '100%', textAlign: 'left', display: 'flex', gap: 6, alignItems: 'center', border: 'none', background: 'transparent', cursor: loading ? 'default' : 'pointer', padding: '4px 6px' }}
|
|
464
519
|
onClick={() => { void browse(host, `${dirPath.replace(/\/+$/, '')}/${entry.name}`) }}
|
|
465
520
|
>
|
|
466
521
|
<span>📁</span>
|
|
@@ -469,8 +524,8 @@ export function DirectoryFlow(props: DirectoryFlowOwnerProps & DirectoryFlowInje
|
|
|
469
524
|
))}
|
|
470
525
|
</div>
|
|
471
526
|
<div className={css.dialogActions} style={{ gridColumn: '1' }}>
|
|
472
|
-
<button type="button" className={css.button} onClick={() => setChoice('ssh')}>{tt('create.cancel')}</button>
|
|
473
|
-
<button type="button" className={`${css.button} ${css.buttonPrimary}`} onClick={() => setChoice('ssh')}>
|
|
527
|
+
<button type="button" className={css.button} disabled={loading} onClick={() => setChoice('ssh')}>{tt('create.cancel')}</button>
|
|
528
|
+
<button type="button" className={`${css.button} ${css.buttonPrimary}`} disabled={loading} onClick={() => setChoice('ssh')}>
|
|
474
529
|
{tt('flow.useThisDir')}
|
|
475
530
|
</button>
|
|
476
531
|
</div>
|