@tiphareth/dsh-hardssh 0.1.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/README.md +50 -0
- package/cordis.patch.yml +26 -0
- package/lib/client.js +17780 -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 +134 -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 +96 -0
- package/src/client/workspace-gate.ts +225 -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
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Incremental UTF-8 output collector with a strict BYTE budget.
|
|
3
|
+
*
|
|
4
|
+
* The previous implementation compared `text.length` (UTF-16 code units)
|
|
5
|
+
* against `chunk.length` (bytes) and decoded each chunk in isolation, so
|
|
6
|
+
* multi-byte characters split across chunks turned into U+FFFD and the
|
|
7
|
+
* budget was never truly enforced. This collector counts raw input bytes,
|
|
8
|
+
* decodes across chunk boundaries with StringDecoder, and truncates on a
|
|
9
|
+
* complete UTF-8 sequence boundary.
|
|
10
|
+
* @module dsh-ssh/exec/output
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { StringDecoder } from 'node:string_decoder'
|
|
14
|
+
|
|
15
|
+
/** Marker appended when the captured output hits the byte budget. */
|
|
16
|
+
export const TRUNCATION_MARKER = '\n[output truncated]'
|
|
17
|
+
|
|
18
|
+
/** One captured stream (stdout or stderr). */
|
|
19
|
+
export class BoundedUtf8Output {
|
|
20
|
+
private readonly decoder = new StringDecoder('utf8')
|
|
21
|
+
private readonly parts: string[] = []
|
|
22
|
+
private _bytesAccepted = 0
|
|
23
|
+
private _truncated = false
|
|
24
|
+
private _ended = false
|
|
25
|
+
|
|
26
|
+
constructor(private readonly maxBytes: number) {}
|
|
27
|
+
|
|
28
|
+
/** Raw bytes accepted so far (before truncation). */
|
|
29
|
+
get bytesAccepted(): number {
|
|
30
|
+
return this._bytesAccepted
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** True once the byte budget was hit and output was cut. */
|
|
34
|
+
get truncated(): boolean {
|
|
35
|
+
return this._truncated
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
append(chunk: Buffer): void {
|
|
39
|
+
if (this._truncated || chunk.length === 0) return
|
|
40
|
+
const remaining = this.maxBytes - this._bytesAccepted
|
|
41
|
+
if (chunk.length <= remaining) {
|
|
42
|
+
this._bytesAccepted += chunk.length
|
|
43
|
+
this.parts.push(this.decoder.write(chunk))
|
|
44
|
+
return
|
|
45
|
+
}
|
|
46
|
+
// Budget exhausted mid-chunk: keep the longest prefix that ends on a
|
|
47
|
+
// complete UTF-8 sequence, then stop accepting input. Back off from a
|
|
48
|
+
// continuation byte (0b10xxxxxx) to the sequence lead, and drop the
|
|
49
|
+
// lead itself when its continuations were cut away.
|
|
50
|
+
let cut = remaining
|
|
51
|
+
while (cut > 0 && (chunk[cut] & 0xc0) === 0x80) cut -= 1
|
|
52
|
+
if (cut > 0 && (chunk[cut - 1] & 0xc0) === 0xc0) cut -= 1
|
|
53
|
+
const kept = chunk.subarray(0, cut)
|
|
54
|
+
this._bytesAccepted += kept.length
|
|
55
|
+
if (kept.length > 0) this.parts.push(this.decoder.write(kept))
|
|
56
|
+
this.parts.push(this.decoder.end())
|
|
57
|
+
this._ended = true
|
|
58
|
+
this._truncated = true
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The captured text (truncation marker appended when cut). Idempotent. */
|
|
62
|
+
finish(): string {
|
|
63
|
+
if (!this._ended) {
|
|
64
|
+
this._ended = true
|
|
65
|
+
this.parts.push(this.decoder.end())
|
|
66
|
+
}
|
|
67
|
+
const text = this.parts.join('')
|
|
68
|
+
return this._truncated ? text + TRUNCATION_MARKER : text
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSH host-key trust store (TOFU): first-connection trust-on-first-use.
|
|
3
|
+
*
|
|
4
|
+
* When enabled, the engine refuses to connect to a host whose public-key
|
|
5
|
+
* fingerprint has not been explicitly trusted: the first encounter records a
|
|
6
|
+
* `pending` entry and the connection is REJECTED (credentials are never sent
|
|
7
|
+
* to an unverified host), the operator confirms the fingerprint through the
|
|
8
|
+
* GUI, and every later connection re-checks the stored fingerprint with a
|
|
9
|
+
* constant-time comparison.
|
|
10
|
+
*
|
|
11
|
+
* The store is deliberately independent of the credentials vault: a
|
|
12
|
+
* fingerprint is not a secret, so verifying the host needs no unlock, and
|
|
13
|
+
* Host-Key protectio n can ship (and be tested) before the vault lands.
|
|
14
|
+
*
|
|
15
|
+
* @module dsh-hardssh/known-hosts
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { createHash, timingSafeEqual } from 'node:crypto'
|
|
19
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
20
|
+
import { homedir } from 'node:os'
|
|
21
|
+
import { dirname, join } from 'node:path'
|
|
22
|
+
|
|
23
|
+
/** File format version. */
|
|
24
|
+
const FORMAT_VERSION = 1
|
|
25
|
+
|
|
26
|
+
/** One known-host record. */
|
|
27
|
+
export interface KnownHostRecord {
|
|
28
|
+
alias: string
|
|
29
|
+
host: string
|
|
30
|
+
port: number
|
|
31
|
+
/** Server public-key algorithm (ssh-ed25519, ssh-rsa, …) for display. */
|
|
32
|
+
keyType: string
|
|
33
|
+
/** Canonical fingerprint: 'SHA256:' + base64 (no '=' padding). */
|
|
34
|
+
fingerprint: string
|
|
35
|
+
/** 'pending' = seen once, waiting for explicit trust; 'trusted' = confirmed. */
|
|
36
|
+
status: 'pending' | 'trusted'
|
|
37
|
+
firstSeenAt: number
|
|
38
|
+
confirmedAt: number | null
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface KnownHostsFile {
|
|
42
|
+
version: number
|
|
43
|
+
hosts: KnownHostRecord[]
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Default file location: <home>/.dsh/ssh-known-hosts.json. */
|
|
47
|
+
export function knownHostsPath(): string {
|
|
48
|
+
return join(homedir(), '.dsh', 'ssh-known-hosts.json')
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Normalize a fingerprint to the canonical 'SHA256:<b64-no-padding>' form. */
|
|
52
|
+
export function normalizeFingerprint(input: string): string {
|
|
53
|
+
const trimmed = input.trim().replace(/^SHA256:/iu, '')
|
|
54
|
+
return 'SHA256:' + trimmed.replace(/=+$/u, '')
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Constant-time comparison of two fingerprints (canonical form, length-safe). */
|
|
58
|
+
export function fingerprintsEqual(a: string, b: string): boolean {
|
|
59
|
+
const bufA = Buffer.from(normalizeFingerprint(a).slice('SHA256:'.length))
|
|
60
|
+
const bufB = Buffer.from(normalizeFingerprint(b).slice('SHA256:'.length))
|
|
61
|
+
return bufA.length === bufB.length && timingSafeEqual(bufA, bufB)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Compute the canonical OpenSSH-style fingerprint of a server key blob. */
|
|
65
|
+
export function fingerprintOf(rawKey: Buffer): string {
|
|
66
|
+
const digest = createHash('sha256').update(rawKey).digest('base64').replace(/=+$/u, '')
|
|
67
|
+
return 'SHA256:' + digest
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Host-key check outcome. */
|
|
71
|
+
export type HostKeyCheck =
|
|
72
|
+
| { kind: 'trusted' }
|
|
73
|
+
| { kind: 'unknown'; fingerprintSha256: string }
|
|
74
|
+
| { kind: 'mismatch'; expected: string; actual: string }
|
|
75
|
+
|
|
76
|
+
/** Persisted host-key trust store (atomic write, 0600). */
|
|
77
|
+
export class KnownHostsStore {
|
|
78
|
+
private readonly path: string
|
|
79
|
+
private records = new Map<string, KnownHostRecord>()
|
|
80
|
+
|
|
81
|
+
constructor(filePath?: string) {
|
|
82
|
+
this.path = filePath ?? knownHostsPath()
|
|
83
|
+
this.load()
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
private load(): void {
|
|
87
|
+
if (!existsSync(this.path)) return
|
|
88
|
+
try {
|
|
89
|
+
const parsed = JSON.parse(readFileSync(this.path, 'utf8')) as KnownHostsFile
|
|
90
|
+
if (typeof parsed !== 'object' || parsed === null || !Array.isArray(parsed.hosts)) return
|
|
91
|
+
for (const record of parsed.hosts) {
|
|
92
|
+
if (isRecord(record)) this.records.set(record.alias, record)
|
|
93
|
+
}
|
|
94
|
+
} catch {
|
|
95
|
+
// Corrupt / unreadable trust file: fail open (treat as empty) rather
|
|
96
|
+
// than brick every SSH connection; the first connect re-records pending.
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
private save(): void {
|
|
101
|
+
const dir = dirname(this.path)
|
|
102
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 })
|
|
103
|
+
const file: KnownHostsFile = { version: FORMAT_VERSION, hosts: [...this.records.values()] }
|
|
104
|
+
const tmp = this.path + '.tmp'
|
|
105
|
+
writeFileSync(tmp, JSON.stringify(file, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 })
|
|
106
|
+
renameSync(tmp, this.path)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
lookup(alias: string): KnownHostRecord | undefined {
|
|
110
|
+
return this.records.get(alias)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** First encounter: record as pending (non-destructive re-observe keeps status). */
|
|
114
|
+
observe(alias: string, meta: { host: string; port: number; keyType: string; fingerprintSha256: string }): void {
|
|
115
|
+
const existing = this.records.get(alias)
|
|
116
|
+
if (existing !== undefined) return // keep pending/trusted as-is
|
|
117
|
+
this.records.set(alias, {
|
|
118
|
+
alias,
|
|
119
|
+
host: meta.host,
|
|
120
|
+
port: meta.port,
|
|
121
|
+
keyType: meta.keyType,
|
|
122
|
+
fingerprint: meta.fingerprintSha256,
|
|
123
|
+
status: 'pending',
|
|
124
|
+
firstSeenAt: Date.now(),
|
|
125
|
+
confirmedAt: null,
|
|
126
|
+
})
|
|
127
|
+
this.save()
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** pending → trusted (must match the recorded fingerprint). */
|
|
131
|
+
trust(alias: string): void {
|
|
132
|
+
const record = this.records.get(alias)
|
|
133
|
+
if (record === undefined) throw new Error(`no host-key record for '${alias}'`)
|
|
134
|
+
record.status = 'trusted'
|
|
135
|
+
record.confirmedAt = Date.now()
|
|
136
|
+
this.save()
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Forget a host (key rotation /误信 reset → next connect re-TOFU). */
|
|
140
|
+
forget(alias: string): void {
|
|
141
|
+
this.records.delete(alias)
|
|
142
|
+
this.save()
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
list(): KnownHostRecord[] {
|
|
146
|
+
return [...this.records.values()]
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Per-alias host-key policy: check a server key against the store. */
|
|
151
|
+
export class HostKeyPolicy {
|
|
152
|
+
constructor(private readonly knownHosts: KnownHostsStore) {}
|
|
153
|
+
|
|
154
|
+
/** Evaluate one server key: trusted / unknown (records pending) / mismatch. */
|
|
155
|
+
check(alias: string, rawKey: Buffer): HostKeyCheck {
|
|
156
|
+
const recorded = this.knownHosts.lookup(alias)
|
|
157
|
+
const actual = fingerprintOf(rawKey)
|
|
158
|
+
if (recorded === undefined) {
|
|
159
|
+
// First encounter: remember as pending, but NOT trusted — the caller
|
|
160
|
+
// must refuse the connection until the operator confirms.
|
|
161
|
+
this.knownHosts.observe(alias, {
|
|
162
|
+
host: '', // host/port are filled by the engine's richer record on confirm
|
|
163
|
+
port: 0,
|
|
164
|
+
keyType: keyTypeOf(rawKey),
|
|
165
|
+
fingerprintSha256: actual,
|
|
166
|
+
})
|
|
167
|
+
return { kind: 'unknown', fingerprintSha256: actual }
|
|
168
|
+
}
|
|
169
|
+
if (recorded.status !== 'trusted') return { kind: 'unknown', fingerprintSha256: recorded.fingerprint }
|
|
170
|
+
if (fingerprintsEqual(recorded.fingerprint, actual)) return { kind: 'trusted' }
|
|
171
|
+
return { kind: 'mismatch', expected: recorded.fingerprint, actual }
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Best-effort key type label from the raw blob's base64 header. */
|
|
176
|
+
function keyTypeOf(rawKey: Buffer): string {
|
|
177
|
+
try {
|
|
178
|
+
const base64 = rawKey.toString('base64')
|
|
179
|
+
const decoded = Buffer.from(base64, 'base64').toString('utf8')
|
|
180
|
+
const end = decoded.indexOf(' ')
|
|
181
|
+
if (end > 0) return decoded.slice(0, end)
|
|
182
|
+
} catch { /* fall through */ }
|
|
183
|
+
return 'ssh-unknown'
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function isRecord(value: unknown): value is KnownHostRecord {
|
|
187
|
+
if (typeof value !== 'object' || value === null) return false
|
|
188
|
+
const record = value as Record<string, unknown>
|
|
189
|
+
return typeof record.alias === 'string'
|
|
190
|
+
&& typeof record.fingerprint === 'string'
|
|
191
|
+
&& (record.status === 'pending' || record.status === 'trusted')
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Error: the host key is not trusted yet (first encounter, awaiting confirm). */
|
|
195
|
+
export class HostKeyUnknownError extends Error {
|
|
196
|
+
readonly fingerprintSha256: string
|
|
197
|
+
constructor(alias: string, fingerprintSha256: string) {
|
|
198
|
+
super(`host key for '${alias}' 尚未信任 — 请先确认 SHA256 指纹 ${fingerprintSha256}`)
|
|
199
|
+
this.name = 'HostKeyUnknownError'
|
|
200
|
+
this.fingerprintSha256 = fingerprintSha256
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Error: the host key changed since it was trusted (possible MITM / key rotation). */
|
|
205
|
+
export class HostKeyMismatchError extends Error {
|
|
206
|
+
readonly expected: string
|
|
207
|
+
readonly actual: string
|
|
208
|
+
constructor(alias: string, expected: string, actual: string) {
|
|
209
|
+
super(`host key for '${alias}' 已变化(期望 ${expected},实际 ${actual})—— 请先人工核验服务器后 forget 重置信任`)
|
|
210
|
+
this.name = 'HostKeyMismatchError'
|
|
211
|
+
this.expected = expected
|
|
212
|
+
this.actual = actual
|
|
213
|
+
}
|
|
214
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The SSH operations capability, now hosted inside dsh-hardssh (migrated
|
|
3
|
+
* from the legacy dsh-ssh package): host config store
|
|
4
|
+
* (~/.dsh/dsh-ssh.json), the shared ssh2 engine, /api/dsh-ssh routes, the
|
|
5
|
+
* six ssh_* agent tools, the `dsh-ssh` settings namespace, and the SSH
|
|
6
|
+
* system-prompt section.
|
|
7
|
+
*
|
|
8
|
+
* This module deliberately does NOT create or dispose the engine/store:
|
|
9
|
+
* ownership belongs to src/index.ts (one instance, one dispose point). It
|
|
10
|
+
* only mounts/unmounts surfaces against the shared instances.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
14
|
+
import z from '@deepseek-ai/schemastery'
|
|
15
|
+
import type {} from '@deepseek-ai/dsh-host-webserver'
|
|
16
|
+
import type {} from '@deepseek-ai/dsh-system-prompt'
|
|
17
|
+
import type {} from '@deepseek-ai/dsh-tools'
|
|
18
|
+
import type { SshEngine } from './engine.ts'
|
|
19
|
+
import type { HostStore } from './store.ts'
|
|
20
|
+
import { makeRoutes } from './routes.ts'
|
|
21
|
+
import { invalidateRemoteEnvironment } from '../remote/environment.ts'
|
|
22
|
+
import { sshClusterTool, sshDownloadTool, sshExecTool, sshListTool, sshTunnelTool, sshUploadTool } from './tools.ts'
|
|
23
|
+
|
|
24
|
+
/** Settings namespace of the SSH capability. Kept as 'dsh-ssh' so existing
|
|
25
|
+
* user settings survive the package merge unchanged. */
|
|
26
|
+
export const SSH_SETTINGS_NAMESPACE = 'dsh-ssh'
|
|
27
|
+
|
|
28
|
+
/** SSH-capability config, validated by the same-named schemastery schema. */
|
|
29
|
+
export interface SshConfig {
|
|
30
|
+
/** When true (default), a system-prompt section announces the SSH capability. */
|
|
31
|
+
announceToAgent?: boolean
|
|
32
|
+
/** Master switch for the SSH surfaces (routes, tools, prompt section). */
|
|
33
|
+
enabled?: boolean
|
|
34
|
+
/**
|
|
35
|
+
* Secret storage mode:
|
|
36
|
+
* - 'none' (default): passwords/passphrases are NEVER persisted; each
|
|
37
|
+
* session's first connection prompts for them (VSCode Remote-SSH style)
|
|
38
|
+
* and they live in memory (session password table) for the connection
|
|
39
|
+
* pool lifetime (idle 30 min auto-disconnect; new terminals reuse it).
|
|
40
|
+
* - 'vault': store secrets encrypted at rest (AES-256-GCM) under a master
|
|
41
|
+
* password / DSH_CREDENTIAL_PASSWORD; for headless agents that must run
|
|
42
|
+
* password hosts unattended.
|
|
43
|
+
*/
|
|
44
|
+
secretStorage?: 'none' | 'vault'
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const SshConfig: z<SshConfig> = z.object({
|
|
48
|
+
announceToAgent: z.boolean().default(true),
|
|
49
|
+
enabled: z.boolean().default(true),
|
|
50
|
+
secretStorage: z.union([z.const('none'), z.const('vault')]).default('none'),
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
/** Schema default, re-read for hand-built test contexts. */
|
|
54
|
+
const DEFAULT_ANNOUNCE = true
|
|
55
|
+
|
|
56
|
+
/** Order of the announcement section within the tool-guidance band. */
|
|
57
|
+
const SECTION_ORDER = 150
|
|
58
|
+
|
|
59
|
+
/** Model-facing announcement: SSH operations capability, capabilities, limits. */
|
|
60
|
+
export const SSH_GUIDANCE = '本机已安装 dsh-hardssh 插件(内建 DSH 远程 SSH 运维):侧边栏「SSH」入口;SSH 运维与 SSH 工作区在 dsh-hardssh 中统一维护。能力:主机配置存 ~/.dsh/dsh-ssh.json(可从 ~/.ssh/config 导入);持久连接池复用长连接(空闲 30 分钟自动断开);ssh_list 列出主机、ssh_exec 执行远程命令、ssh_upload/ssh_download 传输文件、ssh_tunnel 本地端口转发(访问远程数据库/内网服务)、ssh_cluster 集群并发执行;支持密钥/密码认证、passphrase 密钥与 ProxyJump 跳板机;Web 终端走 WebSocket。限制:主机操作由用户在 GUI 中配置后 agent 方可使用;密码以明文存在用户主目录私有文件(权限 0600);命令输出原样返回、可能含敏感信息;连接建立失败会自动重连,但命令或文件操作一旦提交就不会自动重试——结果不明确时应先核实远端状态,再决定是否人工重试;传输/执行消耗真实远程资源,先确认再操作。用户提到「SSH / 远程服务器 / 服务器操作 / 跳板机 / 隧道 / 部署 / 上传下载」时即指本插件,请据此协作。'
|
|
61
|
+
|
|
62
|
+
/** Shared instances the SSH surfaces run on. */
|
|
63
|
+
export interface SshCapabilityDeps {
|
|
64
|
+
store: import('./store.ts').SecureHostStore
|
|
65
|
+
engine: SshEngine
|
|
66
|
+
/** Host-key TOFU trust store; absent → host-key verification disabled. */
|
|
67
|
+
knownHosts?: import('./known-hosts.ts').KnownHostsStore
|
|
68
|
+
/** Credential vault; absent → vault endpoints disabled. */
|
|
69
|
+
vault?: import('./vault.ts').Vault
|
|
70
|
+
/** SSH-bound workspace ledger (host-delete reference guard). */
|
|
71
|
+
ledger?: import('../ledger.ts').SshWorkspaceLedger
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Mount the SSH operations surfaces (routes, tools, settings, prompt) against
|
|
76
|
+
* the shared engine/store. Called from src/index.ts BEFORE the workspace
|
|
77
|
+
* `enabled` early-return, so the SSH capability stays independently togglable
|
|
78
|
+
* via its own `dsh-ssh` settings namespace.
|
|
79
|
+
*/
|
|
80
|
+
export function mountSshCapability(ctx: Context, deps: SshCapabilityDeps): void {
|
|
81
|
+
const { store, engine } = deps
|
|
82
|
+
let current: () => SshConfig = () => ({})
|
|
83
|
+
const resolve = (): SshConfig => {
|
|
84
|
+
const value = current()
|
|
85
|
+
return {
|
|
86
|
+
announceToAgent: value.announceToAgent ?? DEFAULT_ANNOUNCE,
|
|
87
|
+
enabled: value.enabled ?? true,
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// The /api/dsh-ssh route family + terminal upgrade. Registered through a
|
|
92
|
+
// dynamic webServer inject (headless profiles lack the service — mirroring
|
|
93
|
+
// the workspace routes in src/index.ts). The enabled switch is honored at
|
|
94
|
+
// registration time; toggling it later takes effect on reload.
|
|
95
|
+
const { routes, upgrade } = makeRoutes({
|
|
96
|
+
store,
|
|
97
|
+
engine,
|
|
98
|
+
knownHosts: deps.knownHosts,
|
|
99
|
+
vault: deps.vault,
|
|
100
|
+
ledger: deps.ledger,
|
|
101
|
+
// Host PATCH/DELETE also invalidates the per-alias environment cache so
|
|
102
|
+
// a re-pointed alias cannot keep serving the old host's env.
|
|
103
|
+
onHostInvalidated: (alias) => invalidateRemoteEnvironment(engine, alias),
|
|
104
|
+
})
|
|
105
|
+
ctx.inject(['webServer'], (scoped) => {
|
|
106
|
+
if (!resolve().enabled) return () => { /* disabled: nothing registered */ }
|
|
107
|
+
const disposers = routes.map(route => scoped.webServer.register(route))
|
|
108
|
+
const upgradeDisposer = scoped.webServer.registerUpgrade(upgrade)
|
|
109
|
+
return () => {
|
|
110
|
+
for (const dispose of disposers) dispose()
|
|
111
|
+
upgradeDisposer()
|
|
112
|
+
}
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
// Agent tools + their prompt sections.
|
|
116
|
+
const tools = [
|
|
117
|
+
sshListTool(engine),
|
|
118
|
+
sshExecTool(engine),
|
|
119
|
+
sshUploadTool(engine),
|
|
120
|
+
sshDownloadTool(engine),
|
|
121
|
+
sshTunnelTool(engine),
|
|
122
|
+
sshClusterTool(engine),
|
|
123
|
+
]
|
|
124
|
+
let disposeTools: (() => void) | undefined
|
|
125
|
+
|
|
126
|
+
// System-prompt announcement.
|
|
127
|
+
let disposeSection: (() => void) | undefined
|
|
128
|
+
|
|
129
|
+
// Register (or drop) tools/prompt to match the current settings source.
|
|
130
|
+
// Routes are managed by the dynamic inject above.
|
|
131
|
+
const sync = (): void => {
|
|
132
|
+
const value = resolve()
|
|
133
|
+
if (disposeSection !== undefined) {
|
|
134
|
+
disposeSection()
|
|
135
|
+
disposeSection = undefined
|
|
136
|
+
}
|
|
137
|
+
if (disposeTools !== undefined) {
|
|
138
|
+
disposeTools()
|
|
139
|
+
disposeTools = undefined
|
|
140
|
+
}
|
|
141
|
+
if (!value.enabled) return
|
|
142
|
+
if (value.announceToAgent) {
|
|
143
|
+
disposeSection = ctx.systemPrompt.section({
|
|
144
|
+
name: 'plugin:dsh-ssh',
|
|
145
|
+
order: SECTION_ORDER,
|
|
146
|
+
text: SSH_GUIDANCE,
|
|
147
|
+
})
|
|
148
|
+
}
|
|
149
|
+
disposeTools = ctx.effect(
|
|
150
|
+
() => {
|
|
151
|
+
const disposers = tools.map(tool => ctx.tools.register(tool))
|
|
152
|
+
return () => { for (const dispose of disposers) dispose() }
|
|
153
|
+
},
|
|
154
|
+
'dsh-ssh: tools',
|
|
155
|
+
)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Settings-backed configuration (dsh-settings service; optional in
|
|
159
|
+
// headless profiles). The service was rewritten in dsh 0.1.2: the old
|
|
160
|
+
// `installSettingsSection`/`settingsNamespace` exports are gone, replaced
|
|
161
|
+
// by ctx.settings.installSection(owner, ns, schema, entry, hooks). The
|
|
162
|
+
// default entry is the resolved config defaults; setSource/onChange keep
|
|
163
|
+
// the sync() wiring equivalent for both generations.
|
|
164
|
+
type SettingsService = {
|
|
165
|
+
installSection?(owner: Context, ns: string, schema: unknown, entry: unknown, hooks: {
|
|
166
|
+
setSource(current: () => SshConfig): void
|
|
167
|
+
onChange(): void
|
|
168
|
+
}): void
|
|
169
|
+
}
|
|
170
|
+
const settings = ctx.get('settings') as SettingsService | undefined
|
|
171
|
+
if (settings?.installSection !== undefined) {
|
|
172
|
+
settings.installSection(ctx, 'dsh-ssh', SshConfig, {}, {
|
|
173
|
+
setSource: (source) => {
|
|
174
|
+
current = source
|
|
175
|
+
sync()
|
|
176
|
+
},
|
|
177
|
+
onChange: sync,
|
|
178
|
+
})
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Initial registration (covers deployments with no settings service, whose
|
|
182
|
+
// installSection never fires its hooks).
|
|
183
|
+
sync()
|
|
184
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wire contract between the host half (routes.ts) and the browser half
|
|
3
|
+
* (client/api.ts). Pure types only — imported by both halves, bundled into
|
|
4
|
+
* each, no runtime identity to share.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** Authentication flavors a host entry may carry. */
|
|
8
|
+
export type SshAuthKind = 'key' | 'password'
|
|
9
|
+
|
|
10
|
+
/** One stored host entry (the ~/.dsh/dsh-ssh.json store shape). */
|
|
11
|
+
export interface SshHostEntry {
|
|
12
|
+
/** Stable, user-chosen identifier used by every operation. */
|
|
13
|
+
alias: string
|
|
14
|
+
/** Hostname or IP of the target. */
|
|
15
|
+
host: string
|
|
16
|
+
/** SSH port (default 22). */
|
|
17
|
+
port: number
|
|
18
|
+
/** Login user. */
|
|
19
|
+
user: string
|
|
20
|
+
/** Authentication. */
|
|
21
|
+
auth: {
|
|
22
|
+
kind: SshAuthKind
|
|
23
|
+
/** Absolute path to the private key for 'key' auth. */
|
|
24
|
+
keyPath?: string
|
|
25
|
+
/** Passphrase for an encrypted key. */
|
|
26
|
+
passphrase?: string
|
|
27
|
+
/** Password for 'password' auth. */
|
|
28
|
+
password?: string
|
|
29
|
+
/** Vault ref to the encrypted password/passphrase (v2 secure store). */
|
|
30
|
+
secretRef?: string
|
|
31
|
+
}
|
|
32
|
+
/** Jump chain: local aliases connected through in order (ProxyJump). */
|
|
33
|
+
proxyJump: string[]
|
|
34
|
+
/** Free-form note. */
|
|
35
|
+
description?: string
|
|
36
|
+
/** Deployment environment label (development / production / ...). */
|
|
37
|
+
environment?: string
|
|
38
|
+
/** Free-form tags. */
|
|
39
|
+
tags: string[]
|
|
40
|
+
/** Physical location note. */
|
|
41
|
+
location?: string
|
|
42
|
+
createdAt: number
|
|
43
|
+
updatedAt: number
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Public (secret-free) projection of an entry, safe for the browser/agent. */
|
|
47
|
+
export interface SshHostSummary {
|
|
48
|
+
alias: string
|
|
49
|
+
host: string
|
|
50
|
+
port: number
|
|
51
|
+
user: string
|
|
52
|
+
auth: SshAuthKind
|
|
53
|
+
/** Whether the key path exists on the host machine (key auth only). */
|
|
54
|
+
keyReady: boolean
|
|
55
|
+
proxyJump: string[]
|
|
56
|
+
description?: string
|
|
57
|
+
environment?: string
|
|
58
|
+
tags: string[]
|
|
59
|
+
location?: string
|
|
60
|
+
createdAt: number
|
|
61
|
+
updatedAt: number
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Result of one non-interactive command execution. */
|
|
65
|
+
export interface ExecResult {
|
|
66
|
+
success: boolean
|
|
67
|
+
/** Remote exit code, or null when the channel died without one. */
|
|
68
|
+
exitCode: number | null
|
|
69
|
+
timedOut: boolean
|
|
70
|
+
stdout: string
|
|
71
|
+
stderr: string
|
|
72
|
+
/** Wall-clock duration of the round trip in ms. */
|
|
73
|
+
durationMs: number
|
|
74
|
+
/** Connection error message when the command never ran. */
|
|
75
|
+
error?: string
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** One server entry in a cluster run. */
|
|
79
|
+
export interface ClusterResult {
|
|
80
|
+
alias: string
|
|
81
|
+
ok: boolean
|
|
82
|
+
exitCode?: number | null
|
|
83
|
+
timedOut?: boolean
|
|
84
|
+
stdout?: string
|
|
85
|
+
stderr?: string
|
|
86
|
+
durationMs?: number
|
|
87
|
+
error?: string
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** SFTP transfer progress frame (upload stream). */
|
|
91
|
+
export interface TransferProgress {
|
|
92
|
+
phase: 'connecting' | 'transferring' | 'done' | 'error'
|
|
93
|
+
file: string
|
|
94
|
+
transferred: number
|
|
95
|
+
total: number
|
|
96
|
+
percent: number
|
|
97
|
+
speedBps?: number
|
|
98
|
+
error?: string
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** One active local port-forward tunnel. */
|
|
102
|
+
export interface TunnelInfo {
|
|
103
|
+
id: string
|
|
104
|
+
alias: string
|
|
105
|
+
localPort: number
|
|
106
|
+
remoteHost: string
|
|
107
|
+
remotePort: number
|
|
108
|
+
state: 'forwarding' | 'connecting' | 'failed'
|
|
109
|
+
error?: string
|
|
110
|
+
startedAt: number
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** One directory listing entry (remote file browser). */
|
|
114
|
+
export interface RemoteDirEntry {
|
|
115
|
+
name: string
|
|
116
|
+
type: 'dir' | 'file' | 'other'
|
|
117
|
+
size: number
|
|
118
|
+
mtimeMs: number
|
|
119
|
+
mode?: number
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Test-connection outcome. */
|
|
123
|
+
export interface TestResult {
|
|
124
|
+
ok: boolean
|
|
125
|
+
latencyMs?: number
|
|
126
|
+
error?: string
|
|
127
|
+
/** Stable machine code for host-key routing (HOST_KEY_UNKNOWN / HOST_KEY_MISMATCH / NEEDS_PASSWORD). */
|
|
128
|
+
code?: string
|
|
129
|
+
/** Which secret a connection needs when code === 'NEEDS_PASSWORD'. */
|
|
130
|
+
secret?: 'password' | 'passphrase'
|
|
131
|
+
/** The pending/expected fingerprint when a host key was refused. */
|
|
132
|
+
hostKeyFingerprint?: string
|
|
133
|
+
/** Mismatch detail (expected vs actual) when the host key changed. */
|
|
134
|
+
hostKeyMismatch?: { expected: string; actual: string }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Host edit payload (create/update); 'alias' comes from the URL for updates. */
|
|
138
|
+
export interface HostPayload {
|
|
139
|
+
alias?: string
|
|
140
|
+
host: string
|
|
141
|
+
port?: number
|
|
142
|
+
user: string
|
|
143
|
+
/**
|
|
144
|
+
* Authentication. Required on create; on update an omitted auth keeps the
|
|
145
|
+
* stored secrets (the browser never receives them back).
|
|
146
|
+
*/
|
|
147
|
+
auth?: SshHostEntry['auth']
|
|
148
|
+
proxyJump?: string[]
|
|
149
|
+
description?: string
|
|
150
|
+
environment?: string
|
|
151
|
+
tags?: string[]
|
|
152
|
+
location?: string
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Import outcome from ~/.ssh/config. */
|
|
156
|
+
export interface ImportResult {
|
|
157
|
+
parsed: number
|
|
158
|
+
added: number
|
|
159
|
+
skipped: number
|
|
160
|
+
/** Aliases that failed to map (wildcard patterns, missing HostName, ...). */
|
|
161
|
+
skippedNames: string[]
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** JSON error body used by every route. */
|
|
165
|
+
export interface ApiErrorBody {
|
|
166
|
+
error: string
|
|
167
|
+
/** Stable machine code (HOST_KEY_UNKNOWN / HOST_KEY_MISMATCH / HOST_IN_USE / ...). */
|
|
168
|
+
code?: string
|
|
169
|
+
/** Which secret a connection needs when code === 'NEEDS_PASSWORD'. */
|
|
170
|
+
secret?: 'password' | 'passphrase'
|
|
171
|
+
/** The fingerprint when a host key was refused. */
|
|
172
|
+
hostKeyFingerprint?: string
|
|
173
|
+
/** Mismatch detail (expected vs actual) when the host key changed. */
|
|
174
|
+
hostKeyMismatch?: { expected: string; actual: string }
|
|
175
|
+
/** Workspace titles referencing a host that cannot be deleted (HOST_IN_USE). */
|
|
176
|
+
workspaces?: Array<{ id: string; title: string }>
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** WebSocket terminal protocol frames (host -> client and client -> host). */
|
|
180
|
+
export type TerminalServerFrame =
|
|
181
|
+
| { type: 'ready'; alias: string }
|
|
182
|
+
| { type: 'output'; data: string }
|
|
183
|
+
| { type: 'exit'; code: number | null; error?: string }
|
|
184
|
+
|
|
185
|
+
export type TerminalClientFrame =
|
|
186
|
+
| { type: 'input'; data: string }
|
|
187
|
+
| { type: 'resize'; cols: number; rows: number }
|
|
188
|
+
|
|
189
|
+
/** Route paths the client calls (shared literals). */
|
|
190
|
+
export const SSH_API_BASE = '/api/dsh-ssh' as const
|
|
191
|
+
|
|
192
|
+
export const SSH_API = {
|
|
193
|
+
hosts: SSH_API_BASE + '/hosts',
|
|
194
|
+
importSshConfig: SSH_API_BASE + '/hosts/import-ssh-config',
|
|
195
|
+
test: SSH_API_BASE + '/test',
|
|
196
|
+
exec: SSH_API_BASE + '/exec',
|
|
197
|
+
cluster: SSH_API_BASE + '/cluster',
|
|
198
|
+
upload: SSH_API_BASE + '/upload',
|
|
199
|
+
download: SSH_API_BASE + '/download',
|
|
200
|
+
ls: SSH_API_BASE + '/ls',
|
|
201
|
+
tunnel: SSH_API_BASE + '/tunnel',
|
|
202
|
+
terminal: SSH_API_BASE + '/terminal',
|
|
203
|
+
knownHosts: SSH_API_BASE + '/known-hosts',
|
|
204
|
+
vault: SSH_API_BASE + '/vault',
|
|
205
|
+
sessionSecret: SSH_API_BASE + '/session-secret',
|
|
206
|
+
connections: SSH_API_BASE + '/connections',
|
|
207
|
+
} as const
|
|
208
|
+
|
|
209
|
+
/** One known-host record as seen by the browser (no secrets). */
|
|
210
|
+
export interface KnownHostView {
|
|
211
|
+
alias: string
|
|
212
|
+
host: string
|
|
213
|
+
port: number
|
|
214
|
+
keyType: string
|
|
215
|
+
fingerprint: string
|
|
216
|
+
status: 'pending' | 'trusted'
|
|
217
|
+
firstSeenAt: number
|
|
218
|
+
confirmedAt: number | null
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Host-key trust actions the browser can perform. */
|
|
222
|
+
export type KnownHostAction = 'trust' | 'forget'
|
|
223
|
+
|
|
224
|
+
/** NDJSON transfer stream line shapes (upload). */
|
|
225
|
+
export type TransferStreamLine =
|
|
226
|
+
| { type: 'progress'; progress: TransferProgress }
|
|
227
|
+
| { type: 'result'; ok: boolean; transferredBytes?: number; error?: string }
|