@johnhenry/wsh 0.17.0 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +186 -19
- package/package.json +21 -4
- package/src/client.mjs +70 -4
- package/src/file-transfer.mjs +45 -156
- package/src/index.d.ts +149 -6
- package/src/index.mjs +5 -1
- package/src/known-hosts.mjs +139 -0
- package/src/messages.gen.mjs +22 -9
- package/src/qmux-connection.mjs +21 -0
- package/src/server/auth.mjs +75 -0
- package/src/server/connection.mjs +381 -0
- package/src/server/exec.mjs +60 -0
- package/src/server/fs.mjs +134 -0
- package/src/server/index.d.ts +130 -0
- package/src/server/index.mjs +95 -0
- package/src/server/pty.mjs +25 -0
- package/src/transport-ws.mjs +4 -0
package/src/file-transfer.mjs
CHANGED
|
@@ -3,38 +3,36 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Uploads and downloads delegate to the underlying client's upload()/
|
|
5
5
|
* download() (FileChunk control messages over a 'file'-kind channel —
|
|
6
|
-
* see client.mjs). list()
|
|
7
|
-
* a
|
|
6
|
+
* see client.mjs). list() (wsh #59) delegates to the client's fileList(),
|
|
7
|
+
* which sends a structured FileOp{op:"list"} and reads the typed
|
|
8
|
+
* FileResult.entries -- the same wire path upload/download already use
|
|
9
|
+
* for moving bytes, now extended to directory listings.
|
|
10
|
+
*
|
|
11
|
+
* Before wsh #59, list() ran `ls -la` over a plain exec channel and
|
|
12
|
+
* parsed the text output client-side -- a private, JS-only convention
|
|
13
|
+
* the Rust client had no equivalent of at all (see wsh #58/#59, and the
|
|
14
|
+
* FileEntry nested type in spec/wsh-v1.yaml this now shares with Rust).
|
|
8
15
|
*/
|
|
9
16
|
|
|
10
|
-
import { MSG, open, CHANNEL_KIND } from './messages.mjs';
|
|
11
|
-
import { waitForControlMessage } from './control-listener.mjs';
|
|
12
|
-
|
|
13
17
|
/** Default timeout for waiting on control messages (30 seconds). */
|
|
14
18
|
const RESPONSE_TIMEOUT_MS = 30_000;
|
|
15
19
|
|
|
16
20
|
export class WshFileTransfer {
|
|
17
21
|
/**
|
|
18
22
|
* @type {{
|
|
19
|
-
* upload: function, download: function,
|
|
20
|
-
* sendControl: function, openStream: function, onControl: function,
|
|
23
|
+
* upload: function, download: function, fileList: function,
|
|
21
24
|
* }}
|
|
22
25
|
*/
|
|
23
26
|
#client;
|
|
24
27
|
|
|
25
28
|
/**
|
|
26
|
-
* @param {object} client - A WshClient (or any object exposing
|
|
27
|
-
* download()
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
* (or a method to add a listener — see _waitForMessage)
|
|
34
|
-
*
|
|
35
|
-
* Only `list()` needs the control-channel members, so they are checked
|
|
36
|
-
* there rather than here: uploading and downloading through a client
|
|
37
|
-
* that has `upload()`/`download()` and nothing else stays valid.
|
|
29
|
+
* @param {object} client - A WshClient (or any object exposing the same
|
|
30
|
+
* methods used below). `upload()`/`download()` are used directly for
|
|
31
|
+
* file transfer; `list()` uses `fileList()` (the structured
|
|
32
|
+
* FileOp/FileResult request-response helper already on WshClient).
|
|
33
|
+
* Each method checks for the members it specifically needs, so a
|
|
34
|
+
* client exposing only `upload()`/`download()` (and nothing else)
|
|
35
|
+
* stays valid for those two calls.
|
|
38
36
|
*/
|
|
39
37
|
constructor(client) {
|
|
40
38
|
if (!client) throw new Error('WshFileTransfer requires a client');
|
|
@@ -97,156 +95,47 @@ export class WshFileTransfer {
|
|
|
97
95
|
}
|
|
98
96
|
|
|
99
97
|
/**
|
|
100
|
-
* List files at a remote path
|
|
98
|
+
* List files at a remote path over the structured file channel (wsh #59):
|
|
99
|
+
* sends `FileOp{op:"list"}` via the client's `fileList()` and reads the
|
|
100
|
+
* typed `FileResult.entries` -- the same `FileEntry` shape
|
|
101
|
+
* `crates/wsh-core`'s Rust client is generated from, so a symlink can't
|
|
102
|
+
* be reported as a plain file here and correctly here-and-there.
|
|
101
103
|
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
+
* A refusal (unimplemented op, unauthorized fs capability, no such
|
|
105
|
+
* directory) throws -- it is never coerced into an empty array, so a
|
|
106
|
+
* caller can't mistake "refused" for "empty directory" (wsh #58).
|
|
104
107
|
*
|
|
105
108
|
* @param {string} remotePath - Directory path on the remote host
|
|
106
|
-
* @
|
|
109
|
+
* @param {object} [opts]
|
|
110
|
+
* @param {number} [opts.timeout] - Timeout in ms for the server response
|
|
111
|
+
* @returns {Promise<Array<{
|
|
112
|
+
* name: string, size: number, modified: Date,
|
|
113
|
+
* type: 'file'|'directory'|'symlink'|'device'|'pipe'|'socket',
|
|
114
|
+
* symlinkTarget?: string,
|
|
115
|
+
* }>>}
|
|
107
116
|
*/
|
|
108
|
-
async list(remotePath) {
|
|
117
|
+
async list(remotePath, { timeout = RESPONSE_TIMEOUT_MS } = {}) {
|
|
109
118
|
if (!remotePath || typeof remotePath !== 'string') {
|
|
110
119
|
throw new Error('remotePath is required');
|
|
111
120
|
}
|
|
112
|
-
if (typeof this.#client.
|
|
121
|
+
if (typeof this.#client.fileList !== 'function') {
|
|
113
122
|
throw new TypeError(
|
|
114
|
-
'WshFileTransfer.list() requires a client exposing
|
|
123
|
+
'WshFileTransfer.list() requires a client exposing fileList() (e.g. WshClient)'
|
|
115
124
|
);
|
|
116
125
|
}
|
|
117
126
|
|
|
118
|
-
|
|
119
|
-
const openMsg = open({
|
|
120
|
-
kind: CHANNEL_KIND.EXEC,
|
|
121
|
-
command: `ls -la ${this._shellEscape(remotePath)}`,
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
await this.#client.sendControl(openMsg);
|
|
125
|
-
|
|
126
|
-
const response = await this._waitForMessage(
|
|
127
|
-
(msg) => msg.type === MSG.OPEN_OK || msg.type === MSG.OPEN_FAIL,
|
|
128
|
-
RESPONSE_TIMEOUT_MS
|
|
129
|
-
);
|
|
130
|
-
|
|
131
|
-
if (response.type === MSG.OPEN_FAIL) {
|
|
132
|
-
throw new Error(`List failed: ${response.reason || 'unknown reason'}`);
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
// Read the output stream
|
|
136
|
-
const stream = await this.#client.openStream();
|
|
137
|
-
const reader = stream.readable.getReader();
|
|
138
|
-
const chunks = [];
|
|
139
|
-
|
|
140
|
-
try {
|
|
141
|
-
while (true) {
|
|
142
|
-
const { done, value } = await reader.read();
|
|
143
|
-
if (done) break;
|
|
144
|
-
chunks.push(value);
|
|
145
|
-
}
|
|
146
|
-
} finally {
|
|
147
|
-
reader.releaseLock();
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
const output = new TextDecoder().decode(this._concatChunks(chunks));
|
|
151
|
-
return this._parseLsOutput(output);
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
// ── Internal helpers ──────────────────────────────────────────────
|
|
155
|
-
|
|
156
|
-
/**
|
|
157
|
-
* Wait for a control message matching a predicate.
|
|
158
|
-
*
|
|
159
|
-
* Temporarily hooks into the client's control message flow and resolves
|
|
160
|
-
* when a matching message arrives (or rejects on timeout).
|
|
161
|
-
*
|
|
162
|
-
* @param {function(object): boolean} predicate
|
|
163
|
-
* @param {number} timeoutMs
|
|
164
|
-
* @returns {Promise<object>}
|
|
165
|
-
*/
|
|
166
|
-
_waitForMessage(predicate, timeoutMs) {
|
|
167
|
-
return waitForControlMessage(this.#client, predicate, timeoutMs, 'File transfer response');
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
/**
|
|
171
|
-
* Concatenate an array of Uint8Arrays into one.
|
|
172
|
-
*
|
|
173
|
-
* @param {Uint8Array[]} chunks
|
|
174
|
-
* @param {number} [totalLength] - Pre-computed total, avoids re-summing
|
|
175
|
-
* @returns {Uint8Array}
|
|
176
|
-
*/
|
|
177
|
-
_concatChunks(chunks, totalLength) {
|
|
178
|
-
if (chunks.length === 0) return new Uint8Array(0);
|
|
179
|
-
if (chunks.length === 1) return chunks[0];
|
|
180
|
-
|
|
181
|
-
const total = totalLength ?? chunks.reduce((sum, c) => sum + c.length, 0);
|
|
182
|
-
const result = new Uint8Array(total);
|
|
183
|
-
let offset = 0;
|
|
184
|
-
for (const chunk of chunks) {
|
|
185
|
-
result.set(chunk, offset);
|
|
186
|
-
offset += chunk.length;
|
|
187
|
-
}
|
|
188
|
-
return result;
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
/**
|
|
192
|
-
* Parse the output of `ls -la` into structured entries.
|
|
193
|
-
*
|
|
194
|
-
* Expected format per line:
|
|
195
|
-
* drwxr-xr-x 2 user group 4096 Jan 15 12:00 dirname
|
|
196
|
-
* -rw-r--r-- 1 user group 12345 Jan 15 12:00 filename.txt
|
|
197
|
-
*
|
|
198
|
-
* @param {string} output
|
|
199
|
-
* @returns {Array<{ name: string, size: number, modified: string, type: string }>}
|
|
200
|
-
*/
|
|
201
|
-
_parseLsOutput(output) {
|
|
202
|
-
const lines = output.split('\n').filter((l) => l.trim());
|
|
203
|
-
const entries = [];
|
|
204
|
-
|
|
205
|
-
for (const line of lines) {
|
|
206
|
-
// Skip the "total" header line
|
|
207
|
-
if (line.startsWith('total ')) continue;
|
|
208
|
-
|
|
209
|
-
// ls -la columns: perms links owner group size month day time name
|
|
210
|
-
const parts = line.split(/\s+/);
|
|
211
|
-
if (parts.length < 9) continue;
|
|
212
|
-
|
|
213
|
-
const perms = parts[0];
|
|
214
|
-
const size = parseInt(parts[4], 10) || 0;
|
|
215
|
-
const month = parts[5];
|
|
216
|
-
const day = parts[6];
|
|
217
|
-
const time = parts[7];
|
|
218
|
-
// Name may contain spaces — rejoin everything from column 8 onward
|
|
219
|
-
const name = parts.slice(8).join(' ');
|
|
220
|
-
|
|
221
|
-
// Skip . and .. entries
|
|
222
|
-
if (name === '.' || name === '..') continue;
|
|
127
|
+
const result = await this.#client.fileList(remotePath, timeout);
|
|
223
128
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
if (perms.startsWith('d')) type = 'directory';
|
|
227
|
-
else if (perms.startsWith('l')) type = 'symlink';
|
|
228
|
-
else if (perms.startsWith('c') || perms.startsWith('b')) type = 'device';
|
|
229
|
-
else if (perms.startsWith('p')) type = 'pipe';
|
|
230
|
-
else if (perms.startsWith('s')) type = 'socket';
|
|
231
|
-
|
|
232
|
-
entries.push({
|
|
233
|
-
name,
|
|
234
|
-
size,
|
|
235
|
-
modified: `${month} ${day} ${time}`,
|
|
236
|
-
type,
|
|
237
|
-
});
|
|
129
|
+
if (!result.success) {
|
|
130
|
+
throw new Error(result.error_message || `List failed: refused for ${remotePath}`);
|
|
238
131
|
}
|
|
239
132
|
|
|
240
|
-
return entries
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
*/
|
|
248
|
-
_shellEscape(str) {
|
|
249
|
-
// Wrap in single quotes, escaping any embedded single quotes
|
|
250
|
-
return "'" + str.replace(/'/g, "'\\''") + "'";
|
|
133
|
+
return (result.entries || []).map((entry) => ({
|
|
134
|
+
name: entry.name,
|
|
135
|
+
size: entry.size,
|
|
136
|
+
modified: new Date(entry.modified * 1000),
|
|
137
|
+
type: entry.type,
|
|
138
|
+
...(entry.symlink_target !== undefined ? { symlinkTarget: entry.symlink_target } : {}),
|
|
139
|
+
}));
|
|
251
140
|
}
|
|
252
141
|
}
|
package/src/index.d.ts
CHANGED
|
@@ -238,6 +238,13 @@ export const PROTOCOL_VERSION: 'wsh-v1';
|
|
|
238
238
|
*/
|
|
239
239
|
export const MCP_CALL_ID_FEATURE: 'mcp-call-id';
|
|
240
240
|
|
|
241
|
+
/**
|
|
242
|
+
* ServerHello feature: the host discovers a client-opened exec data stream
|
|
243
|
+
* without help (the transport announces it), so the client writes no
|
|
244
|
+
* one-byte primer against it.
|
|
245
|
+
*/
|
|
246
|
+
export const STREAM_ANNOUNCE_FEATURE: 'stream-announce';
|
|
247
|
+
|
|
241
248
|
/** A wsh protocol control message (all messages have a numeric `type`). */
|
|
242
249
|
export interface WshMessage {
|
|
243
250
|
type: number;
|
|
@@ -778,6 +785,14 @@ export function fileResult(opts?: {
|
|
|
778
785
|
channelId?: number;
|
|
779
786
|
success?: boolean;
|
|
780
787
|
metadata?: Record<string, unknown>;
|
|
788
|
+
/** wsh #59: typed directory entries for a "list" op (see WshFileListEntry-shaped wire fields). */
|
|
789
|
+
entries?: Array<{
|
|
790
|
+
name: string;
|
|
791
|
+
size: number;
|
|
792
|
+
modified: number;
|
|
793
|
+
type: 'file' | 'directory' | 'symlink' | 'device' | 'pipe' | 'socket';
|
|
794
|
+
symlink_target?: string;
|
|
795
|
+
}>;
|
|
781
796
|
errorMessage?: string;
|
|
782
797
|
}): WshMessage;
|
|
783
798
|
|
|
@@ -789,6 +804,18 @@ export function fileChunk(opts?: {
|
|
|
789
804
|
totalSize?: number;
|
|
790
805
|
}): WshMessage;
|
|
791
806
|
|
|
807
|
+
/** wsh #59: install a public key into the remote's authorized_keys (see WshClient.addAuthorizedKey). */
|
|
808
|
+
export function authorizedKeyAdd(opts?: {
|
|
809
|
+
publicKey?: Uint8Array;
|
|
810
|
+
comment?: string;
|
|
811
|
+
}): WshMessage;
|
|
812
|
+
|
|
813
|
+
export function authorizedKeyResult(opts?: {
|
|
814
|
+
success?: boolean;
|
|
815
|
+
added?: boolean;
|
|
816
|
+
errorMessage?: string;
|
|
817
|
+
}): WshMessage;
|
|
818
|
+
|
|
792
819
|
export function policyEval(opts?: {
|
|
793
820
|
requestId?: string;
|
|
794
821
|
action?: string;
|
|
@@ -875,15 +902,19 @@ export function exportPrivateKeyPKCS8(privateKey: CryptoKey): Promise<Uint8Array
|
|
|
875
902
|
export function importPrivateKeyPKCS8(pkcs8: Uint8Array, extractable?: boolean): Promise<CryptoKey>;
|
|
876
903
|
|
|
877
904
|
/**
|
|
878
|
-
* Sign data with an Ed25519 private key.
|
|
905
|
+
* Sign data with an Ed25519 private key. Positional order mirrors WebCrypto
|
|
906
|
+
* (`sign(privateKey, message)`) and is the canonical form across wsh, raijin
|
|
907
|
+
* and browsermesh.
|
|
879
908
|
* @returns 64-byte signature
|
|
880
909
|
*/
|
|
881
|
-
export function sign(privateKey: CryptoKey,
|
|
910
|
+
export function sign(privateKey: CryptoKey, message: Uint8Array): Promise<Uint8Array>;
|
|
882
911
|
|
|
883
912
|
/**
|
|
884
|
-
* Verify an Ed25519 signature.
|
|
913
|
+
* Verify an Ed25519 signature. Positional order mirrors WebCrypto
|
|
914
|
+
* (`verify(publicKey, signature, message)`) and is the canonical form across
|
|
915
|
+
* wsh, raijin and browsermesh.
|
|
885
916
|
*/
|
|
886
|
-
export function verify(publicKey: CryptoKey, signature: Uint8Array,
|
|
917
|
+
export function verify(publicKey: CryptoKey, signature: Uint8Array, message: Uint8Array): Promise<boolean>;
|
|
887
918
|
|
|
888
919
|
/** Options binding extra context into the auth transcript. */
|
|
889
920
|
export interface WshTranscriptOptions {
|
|
@@ -1541,6 +1572,13 @@ export interface WshOpenSessionOptions {
|
|
|
1541
1572
|
rows?: number;
|
|
1542
1573
|
env?: Record<string, string>;
|
|
1543
1574
|
timeout?: number;
|
|
1575
|
+
/**
|
|
1576
|
+
* Stream-mode `exec` only (default `true`): write the one-byte "primer"
|
|
1577
|
+
* that older hosts need to discover the client-opened data stream. Skipped
|
|
1578
|
+
* automatically against a host advertising `stream-announce`; `false`
|
|
1579
|
+
* never sends it.
|
|
1580
|
+
*/
|
|
1581
|
+
primer?: boolean;
|
|
1544
1582
|
}
|
|
1545
1583
|
|
|
1546
1584
|
/** Options for WshClient.attachSession(). */
|
|
@@ -1591,6 +1629,8 @@ export interface WshExecOptions {
|
|
|
1591
1629
|
keyPair?: CryptoKeyPair;
|
|
1592
1630
|
password?: string;
|
|
1593
1631
|
timeout?: number;
|
|
1632
|
+
/** See {@link WshOpenSessionOptions.primer}. */
|
|
1633
|
+
primer?: boolean;
|
|
1594
1634
|
}
|
|
1595
1635
|
|
|
1596
1636
|
/** Result from WshClient.exec(). */
|
|
@@ -1649,6 +1689,28 @@ export interface WshE2EResult {
|
|
|
1649
1689
|
hybrid: boolean;
|
|
1650
1690
|
}
|
|
1651
1691
|
|
|
1692
|
+
/**
|
|
1693
|
+
* Raw `FileResult` response to a structured file-channel operation
|
|
1694
|
+
* (`WshClient.fileList`/`fileRemove`/etc.), as received off the wire --
|
|
1695
|
+
* wire field names (snake_case), not the camelCase shape the `fileResult()`
|
|
1696
|
+
* message constructor takes as input. `entries` is only populated for a
|
|
1697
|
+
* `"list"` op; `error_message` is only present on failure.
|
|
1698
|
+
*/
|
|
1699
|
+
export interface WshFileOperationResult {
|
|
1700
|
+
type: number;
|
|
1701
|
+
channel_id: number;
|
|
1702
|
+
success: boolean;
|
|
1703
|
+
metadata: Record<string, unknown>;
|
|
1704
|
+
entries: Array<{
|
|
1705
|
+
name: string;
|
|
1706
|
+
size: number;
|
|
1707
|
+
modified: number;
|
|
1708
|
+
type: 'file' | 'directory' | 'symlink' | 'device' | 'pipe' | 'socket';
|
|
1709
|
+
symlink_target?: string;
|
|
1710
|
+
}>;
|
|
1711
|
+
error_message?: string;
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1652
1714
|
/**
|
|
1653
1715
|
* WshClient -- manages a wsh connection, authentication, and multiple sessions.
|
|
1654
1716
|
*
|
|
@@ -1764,6 +1826,36 @@ export class WshClient {
|
|
|
1764
1826
|
*/
|
|
1765
1827
|
revokeSessionAccess(sessionId: string, principal: string, reason?: string): Promise<void>;
|
|
1766
1828
|
|
|
1829
|
+
/**
|
|
1830
|
+
* List a remote directory over the structured file channel (`FileOp`
|
|
1831
|
+
* op `"list"`). Prefer `WshFileTransfer.list()` for a typed
|
|
1832
|
+
* `WshFileListEntry[]` result (with `modified` as a `Date`) -- this is
|
|
1833
|
+
* the raw wire-shaped `FileResult` response `fileOperation()` waits on.
|
|
1834
|
+
*/
|
|
1835
|
+
fileList(path: string, timeout?: number): Promise<WshFileOperationResult>;
|
|
1836
|
+
|
|
1837
|
+
/**
|
|
1838
|
+
* Remove a remote file or directory (`FileOp` op `"remove"`). Refused
|
|
1839
|
+
* today by every `wsh-server` release ("not yet implemented" -- see
|
|
1840
|
+
* README's file-op parity table); wired here so clients built against
|
|
1841
|
+
* this SDK don't need to wait on the server to type-check against it.
|
|
1842
|
+
*/
|
|
1843
|
+
fileRemove(path: string, timeout?: number): Promise<WshFileOperationResult>;
|
|
1844
|
+
|
|
1845
|
+
/**
|
|
1846
|
+
* Install a raw 32-byte Ed25519 public key into the remote host's
|
|
1847
|
+
* `~/.wsh/authorized_keys` (wsh #59's `AuthorizedKeyAdd` protocol
|
|
1848
|
+
* message -- replaces `wsh copy-id`'s previous CLI-only shell-command
|
|
1849
|
+
* approach with something every implementation, including this one,
|
|
1850
|
+
* can call directly). Idempotent: installing an already-present key
|
|
1851
|
+
* resolves `added: false` rather than duplicating the line or erroring.
|
|
1852
|
+
*/
|
|
1853
|
+
addAuthorizedKey(
|
|
1854
|
+
publicKeyRaw: Uint8Array,
|
|
1855
|
+
comment?: string,
|
|
1856
|
+
timeout?: number,
|
|
1857
|
+
): Promise<{ added: boolean }>;
|
|
1858
|
+
|
|
1767
1859
|
/**
|
|
1768
1860
|
* Gracefully disconnect: close all sessions and the transport.
|
|
1769
1861
|
*/
|
|
@@ -1993,6 +2085,48 @@ export class WshKeyStore {
|
|
|
1993
2085
|
restore(passphrase: string): Promise<WshRestoreResult>;
|
|
1994
2086
|
}
|
|
1995
2087
|
|
|
2088
|
+
// ============================================================================
|
|
2089
|
+
// known-hosts.mjs -- WshKnownHosts (wsh #59)
|
|
2090
|
+
// ============================================================================
|
|
2091
|
+
|
|
2092
|
+
/** Minimal storage interface `WshKnownHosts` needs -- `localStorage` already satisfies it. */
|
|
2093
|
+
export interface WshKnownHostsStorage {
|
|
2094
|
+
getItem(key: string): string | null;
|
|
2095
|
+
setItem(key: string, value: string): void;
|
|
2096
|
+
removeItem(key: string): void;
|
|
2097
|
+
}
|
|
2098
|
+
|
|
2099
|
+
/** Result of verifying a host's fingerprint against the stored record. */
|
|
2100
|
+
export interface WshHostVerifyResult {
|
|
2101
|
+
status: 'known' | 'unknown' | 'changed';
|
|
2102
|
+
/** Previously stored fingerprint, present only when status === 'changed'. */
|
|
2103
|
+
expected?: string;
|
|
2104
|
+
}
|
|
2105
|
+
|
|
2106
|
+
/**
|
|
2107
|
+
* WshKnownHosts -- trust-on-first-use (TOFU) host-identity store, mirroring
|
|
2108
|
+
* Rust's `KnownHosts`/`HostStatus` (`crates/wsh-client/src/known_hosts.rs`).
|
|
2109
|
+
* Pins `ServerHello.host_fingerprint` (spec/wsh-v1.yaml, wsh #59) -- no
|
|
2110
|
+
* `wsh-server` release populates that field yet, so this has nothing
|
|
2111
|
+
* authoritative to verify against for any real server today. See the
|
|
2112
|
+
* class doc comment in `known-hosts.mjs` for the full reasoning.
|
|
2113
|
+
*/
|
|
2114
|
+
export class WshKnownHosts {
|
|
2115
|
+
constructor(opts?: { storage?: WshKnownHostsStorage; storageKey?: string });
|
|
2116
|
+
|
|
2117
|
+
/** Verify a host's fingerprint against the stored record. */
|
|
2118
|
+
verifyHost(host: string, fingerprint: string): WshHostVerifyResult;
|
|
2119
|
+
|
|
2120
|
+
/** Add or update a host's fingerprint. */
|
|
2121
|
+
addHost(host: string, fingerprint: string): void;
|
|
2122
|
+
|
|
2123
|
+
/** Remove a host entry. Returns true if an entry was removed. */
|
|
2124
|
+
removeHost(host: string): boolean;
|
|
2125
|
+
|
|
2126
|
+
/** List all known hosts and their fingerprints. */
|
|
2127
|
+
list(): Array<{ host: string; fingerprint: string }>;
|
|
2128
|
+
}
|
|
2129
|
+
|
|
1996
2130
|
// ============================================================================
|
|
1997
2131
|
// file-transfer.mjs -- WshFileTransfer
|
|
1998
2132
|
// ============================================================================
|
|
@@ -2015,12 +2149,21 @@ export interface WshFileUploadResult {
|
|
|
2015
2149
|
bytesTransferred: number;
|
|
2016
2150
|
}
|
|
2017
2151
|
|
|
2018
|
-
/**
|
|
2152
|
+
/**
|
|
2153
|
+
* File listing entry (wsh #59: generated from spec/wsh-v1.yaml's
|
|
2154
|
+
* `FileEntry` nested type -- the same shape `crates/wsh-core`'s Rust
|
|
2155
|
+
* `FileEntry` struct is generated from, so a directory entry can no longer
|
|
2156
|
+
* describe a symlink as a plain file in one implementation and not the
|
|
2157
|
+
* other). `modified` is converted from the wire's Unix epoch seconds into
|
|
2158
|
+
* a `Date` for JS ergonomics. `symlinkTarget` is only present when
|
|
2159
|
+
* `type === 'symlink'`.
|
|
2160
|
+
*/
|
|
2019
2161
|
export interface WshFileListEntry {
|
|
2020
2162
|
name: string;
|
|
2021
2163
|
size: number;
|
|
2022
|
-
modified:
|
|
2164
|
+
modified: Date;
|
|
2023
2165
|
type: 'file' | 'directory' | 'symlink' | 'device' | 'pipe' | 'socket';
|
|
2166
|
+
symlinkTarget?: string;
|
|
2024
2167
|
}
|
|
2025
2168
|
|
|
2026
2169
|
/**
|
package/src/index.mjs
CHANGED
|
@@ -35,6 +35,7 @@ export {
|
|
|
35
35
|
nodeAnnounce, nodeRedirect,
|
|
36
36
|
sessionGrant, sessionRevoke,
|
|
37
37
|
fileOp, fileResult, fileChunk,
|
|
38
|
+
authorizedKeyAdd, authorizedKeyResult,
|
|
38
39
|
policyEval, policyResult, policyUpdate,
|
|
39
40
|
terminalConfig,
|
|
40
41
|
msgName, isValidMessage,
|
|
@@ -72,11 +73,14 @@ export {
|
|
|
72
73
|
// Session + Client
|
|
73
74
|
export { WshSession } from './session.mjs';
|
|
74
75
|
export { WshVirtualSessionBackend, normalizeSessionData } from './virtual-session.mjs';
|
|
75
|
-
export { WshClient, MCP_CALL_ID_FEATURE } from './client.mjs';
|
|
76
|
+
export { WshClient, MCP_CALL_ID_FEATURE, STREAM_ANNOUNCE_FEATURE } from './client.mjs';
|
|
76
77
|
|
|
77
78
|
// Key storage
|
|
78
79
|
export { WshKeyStore } from './keystore.mjs';
|
|
79
80
|
|
|
81
|
+
// Host identity (TOFU) -- wsh #59
|
|
82
|
+
export { WshKnownHosts } from './known-hosts.mjs';
|
|
83
|
+
|
|
80
84
|
// File transfer
|
|
81
85
|
export { WshFileTransfer } from './file-transfer.mjs';
|
|
82
86
|
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WshKnownHosts — trust-on-first-use (TOFU) host-identity store for the
|
|
3
|
+
* browser SDK (wsh #59).
|
|
4
|
+
*
|
|
5
|
+
* ## Why this exists, and why it is not yet load-bearing
|
|
6
|
+
*
|
|
7
|
+
* wsh #59 asked for a decision on host identity: TOFU *policy* (when to
|
|
8
|
+
* prompt, how long to trust) is legitimately per-implementation, but the
|
|
9
|
+
* *host identity record* -- what a fingerprint covers, how it is computed,
|
|
10
|
+
* what counts as "the same host" -- must not be invented twice. This class
|
|
11
|
+
* mirrors Rust's `KnownHosts`/`HostStatus`
|
|
12
|
+
* (`crates/wsh-client/src/known_hosts.rs`) in shape and semantics --
|
|
13
|
+
* `verifyHost`/`addHost`/`removeHost`/`list`, keyed by a host label,
|
|
14
|
+
* storing one fingerprint per host -- so the browser gets the same
|
|
15
|
+
* mechanism rather than a second, differently-shaped answer arrived at
|
|
16
|
+
* later under pressure.
|
|
17
|
+
*
|
|
18
|
+
* What it pins is `ServerHello.host_fingerprint` (spec/wsh-v1.yaml, added
|
|
19
|
+
* by this same change): the server's own persistent Ed25519 host-identity
|
|
20
|
+
* key, SHA-256-fingerprinted the same way `fingerprint()` in `auth.mjs`
|
|
21
|
+
* already fingerprints user/peer keys. That field is new spec surface,
|
|
22
|
+
* not new server behavior -- no `wsh-server` release populates it yet.
|
|
23
|
+
* Minting and persisting a server host keypair is a separate,
|
|
24
|
+
* security-sensitive feature this change does not implement (see the
|
|
25
|
+
* wsh #59 PR description for the full reasoning). Until a server
|
|
26
|
+
* populates it, `verifyHost()` has nothing authoritative to check for any
|
|
27
|
+
* real deployment -- exactly the same degraded state Rust's own
|
|
28
|
+
* `verify_host_key` is in today when it falls back to `fingerprints[0]`
|
|
29
|
+
* (an authorized *client* key, not a host identity -- see that fallback's
|
|
30
|
+
* comment in `client.rs`). This store does not paper over that; a caller
|
|
31
|
+
* that gets `'unknown'` back from every real server today is seeing the
|
|
32
|
+
* honest state of host verification in this protocol version, not a bug
|
|
33
|
+
* in this class.
|
|
34
|
+
*
|
|
35
|
+
* The README's `serverCertificateHashes` pinning
|
|
36
|
+
* (see "Pinning a Self-Signed Certificate") is a different mechanism: it
|
|
37
|
+
* pins a certificate supplied *per connection*, decided by the caller in
|
|
38
|
+
* advance -- it is not a persisted record of what a host presented last
|
|
39
|
+
* time, so it does not already solve TOFU. `WshKnownHosts` is the
|
|
40
|
+
* missing persisted-record half.
|
|
41
|
+
*
|
|
42
|
+
* ## Storage
|
|
43
|
+
*
|
|
44
|
+
* Pluggable (defaults to `localStorage` when available, else an
|
|
45
|
+
* in-memory `Map` that does not survive a page reload) since "persist
|
|
46
|
+
* trust across sessions" needs a browser storage API this package has
|
|
47
|
+
* never assumed before. Pass `{ storage }` with a `getItem`/`setItem`/
|
|
48
|
+
* `removeItem` interface (the `Storage` interface `localStorage`
|
|
49
|
+
* already implements) to use IndexedDB-via-adapter, `sessionStorage`,
|
|
50
|
+
* or anything else.
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
const STORAGE_KEY = 'wsh:known-hosts:v1';
|
|
54
|
+
|
|
55
|
+
/** In-memory fallback storage, used when no persistent storage is available or injected. */
|
|
56
|
+
class MemoryStorage {
|
|
57
|
+
#map = new Map();
|
|
58
|
+
getItem(key) { return this.#map.has(key) ? this.#map.get(key) : null; }
|
|
59
|
+
setItem(key, value) { this.#map.set(key, value); }
|
|
60
|
+
removeItem(key) { this.#map.delete(key); }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function defaultStorage() {
|
|
64
|
+
if (typeof localStorage !== 'undefined' && localStorage) return localStorage;
|
|
65
|
+
return new MemoryStorage();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export class WshKnownHosts {
|
|
69
|
+
#storage;
|
|
70
|
+
#storageKey;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* @param {object} [opts]
|
|
74
|
+
* @param {{getItem: function, setItem: function, removeItem: function}} [opts.storage]
|
|
75
|
+
* Storage backend (defaults to `localStorage` if present, else an
|
|
76
|
+
* in-memory `Map` scoped to this instance).
|
|
77
|
+
* @param {string} [opts.storageKey] - Override the storage key (useful
|
|
78
|
+
* for isolating multiple instances/tests under one storage backend).
|
|
79
|
+
*/
|
|
80
|
+
constructor({ storage, storageKey = STORAGE_KEY } = {}) {
|
|
81
|
+
this.#storage = storage || defaultStorage();
|
|
82
|
+
this.#storageKey = storageKey;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Verify a host's fingerprint against the stored record.
|
|
87
|
+
*
|
|
88
|
+
* @param {string} host - Host label (e.g. "example.com:4422")
|
|
89
|
+
* @param {string} fingerprint - Hex SHA-256 fingerprint to check
|
|
90
|
+
* @returns {{ status: 'known' | 'unknown' | 'changed', expected?: string }}
|
|
91
|
+
*/
|
|
92
|
+
verifyHost(host, fingerprint) {
|
|
93
|
+
const entries = this.#load();
|
|
94
|
+
if (!(host in entries)) {
|
|
95
|
+
return { status: 'unknown' };
|
|
96
|
+
}
|
|
97
|
+
if (entries[host] === fingerprint) {
|
|
98
|
+
return { status: 'known' };
|
|
99
|
+
}
|
|
100
|
+
return { status: 'changed', expected: entries[host] };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Add or update a host's fingerprint. */
|
|
104
|
+
addHost(host, fingerprint) {
|
|
105
|
+
const entries = this.#load();
|
|
106
|
+
entries[host] = fingerprint;
|
|
107
|
+
this.#save(entries);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Remove a host entry. Returns true if an entry was removed. */
|
|
111
|
+
removeHost(host) {
|
|
112
|
+
const entries = this.#load();
|
|
113
|
+
if (!(host in entries)) return false;
|
|
114
|
+
delete entries[host];
|
|
115
|
+
this.#save(entries);
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** List all known hosts and their fingerprints. */
|
|
120
|
+
list() {
|
|
121
|
+
const entries = this.#load();
|
|
122
|
+
return Object.entries(entries).map(([host, fingerprint]) => ({ host, fingerprint }));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
#load() {
|
|
126
|
+
const raw = this.#storage.getItem(this.#storageKey);
|
|
127
|
+
if (!raw) return {};
|
|
128
|
+
try {
|
|
129
|
+
const parsed = JSON.parse(raw);
|
|
130
|
+
return parsed && typeof parsed === 'object' ? parsed : {};
|
|
131
|
+
} catch {
|
|
132
|
+
return {};
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
#save(entries) {
|
|
137
|
+
this.#storage.setItem(this.#storageKey, JSON.stringify(entries));
|
|
138
|
+
}
|
|
139
|
+
}
|