dsh-over-ssh 0.2.1 → 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/README.md +12 -3
- package/agents/agent-manifest.json +4 -4
- package/agents/linux-amd64/dsh-over-ssh-agent +0 -0
- package/agents/linux-arm64/dsh-over-ssh-agent +0 -0
- package/lib/client.js +111 -40
- package/lib/file-references.js +17 -0
- package/lib/fs.js +87 -18
- package/lib/index.js +550 -145
- package/lib/terminal-wrapper.js +4 -3
- package/lib/types/src/host/app/session-admin/session-admin-contract.d.ts +11 -2
- package/lib/types/src/host/infra/transport/ssh/ssh2-transport.d.ts +8 -0
- package/lib/types/src/host/integration/fs/fs-provider.d.ts +9 -3
- package/lib/types/src/host/integration/session/session-header.d.ts +200 -0
- package/lib/types/src/wiring/helpers.d.ts +1 -1
- package/package.json +1 -1
package/lib/terminal-wrapper.js
CHANGED
|
@@ -216,8 +216,9 @@ async function loadInputs(options) {
|
|
|
216
216
|
return { manifest, sessionState: stateRaw === void 0 ? void 0 : parseSessionState(stateRaw) };
|
|
217
217
|
}
|
|
218
218
|
var STATE_DIR_NAMES = ["over-ssh", "remote-workspace", "ssh-dev"];
|
|
219
|
-
function defaultStateDir() {
|
|
220
|
-
const
|
|
219
|
+
function defaultStateDir(env) {
|
|
220
|
+
const configured = env["DSH_HOME"];
|
|
221
|
+
const base = typeof configured === "string" && configured.trim() !== "" ? configured.trim() : join(homedir(), ".dsh");
|
|
221
222
|
for (const name of STATE_DIR_NAMES) {
|
|
222
223
|
const dir = join(base, name);
|
|
223
224
|
if (existsSync(join(dir, "roots.json"))) return dir;
|
|
@@ -226,7 +227,7 @@ function defaultStateDir() {
|
|
|
226
227
|
}
|
|
227
228
|
async function decide(input) {
|
|
228
229
|
const flags = parseWrapperArgs(input.argv);
|
|
229
|
-
const stateDir = flags.get("state-dir") ?? input.env["DSH_OVER_SSH_STATE_DIR"] ?? input.env["DSH_SSH_DEV_STATE_DIR"] ?? defaultStateDir();
|
|
230
|
+
const stateDir = flags.get("state-dir") ?? input.env["DSH_OVER_SSH_STATE_DIR"] ?? input.env["DSH_SSH_DEV_STATE_DIR"] ?? defaultStateDir(input.env);
|
|
230
231
|
const sessionId = flags.get("session") ?? input.env["DSH_SESSION_ID"];
|
|
231
232
|
const localShell = flags.get("local-shell") ?? defaultLocalShell(input.platform ?? process.platform, input.env);
|
|
232
233
|
const inputs = await loadInputs({ stateDir, sessionId });
|
|
@@ -25,8 +25,17 @@ export interface SessionAdminDeps {
|
|
|
25
25
|
readonly binRoot: string;
|
|
26
26
|
/** List one directory's entries; a missing directory yields an empty list, not a failure. */
|
|
27
27
|
readonly listDir: (path: string) => Promise<readonly SessionDirEntry[]>;
|
|
28
|
-
/**
|
|
29
|
-
|
|
28
|
+
/**
|
|
29
|
+
* Read a session header. `undefined` means the log is unreadable (corrupt/unsupported).
|
|
30
|
+
*
|
|
31
|
+
* `listing` is the directory contents the caller already read (for the byte sum): passing it lets the
|
|
32
|
+
* production reader use the log's `(size, mtime)` as a cache key without a second `readdir` + `stat`.
|
|
33
|
+
*/
|
|
34
|
+
readonly readHeader: (sessionDir: string, listing?: readonly {
|
|
35
|
+
readonly name: string;
|
|
36
|
+
readonly bytes: number;
|
|
37
|
+
readonly mtimeMs: number;
|
|
38
|
+
}[]) => Promise<SessionHeader | undefined>;
|
|
30
39
|
/**
|
|
31
40
|
* Display title of the workspace that owns a session directory, or `undefined` when none does.
|
|
32
41
|
*
|
|
@@ -160,6 +160,14 @@ export interface Ssh2Session {
|
|
|
160
160
|
forwardOut(sourceAddress: string, sourcePort: number, destinationAddress: string, destinationPort: number, callback: (error?: Error, stream?: unknown) => void): void;
|
|
161
161
|
end(): void;
|
|
162
162
|
destroy(): void;
|
|
163
|
+
/**
|
|
164
|
+
* Lifecycle events of the underlying connection, when the implementation exposes them.
|
|
165
|
+
*
|
|
166
|
+
* Optional because the type is a structural minimum: tests' fakes and the alias transport need not implement
|
|
167
|
+
* it. The real `ssh2` `Client` does.
|
|
168
|
+
*/
|
|
169
|
+
on?(event: 'close' | 'error' | 'end', listener: (...args: unknown[]) => void): unknown;
|
|
170
|
+
off?(event: 'close' | 'error' | 'end', listener: (...args: unknown[]) => void): unknown;
|
|
163
171
|
/**
|
|
164
172
|
* Intermediate jump-host sessions this session rides on, so `dispose()` can close them too.
|
|
165
173
|
*
|
|
@@ -84,7 +84,10 @@ export interface RoutedFsProviderOptions {
|
|
|
84
84
|
* here so a test can drive the sync without a Cordis host. Returning `[]` is always safe: it disables the
|
|
85
85
|
* sync and leaves every existing behaviour untouched.
|
|
86
86
|
*/
|
|
87
|
-
readonly sessionWorkspaceCwds?: () => readonly string
|
|
87
|
+
readonly sessionWorkspaceCwds?: () => readonly (string | {
|
|
88
|
+
readonly sessionId: string;
|
|
89
|
+
readonly cwd: string;
|
|
90
|
+
})[];
|
|
88
91
|
/**
|
|
89
92
|
* The fork lineage of one session, nearest first (ADR-0013).
|
|
90
93
|
*
|
|
@@ -104,8 +107,11 @@ export declare class RoutedFsProvider extends FileSystem {
|
|
|
104
107
|
static inject: string[];
|
|
105
108
|
protected readonly rangeReader: BackendRangeReader | undefined;
|
|
106
109
|
protected readonly rangeChunkBytes: number;
|
|
107
|
-
/** Reads the live sessions' working directories; see {@link RoutedFsProviderOptions.sessionWorkspaceCwds}. */
|
|
108
|
-
protected readonly sessionWorkspaceCwds: () => readonly string
|
|
110
|
+
/** Reads the live sessions' working directories (with ids when available); see {@link RoutedFsProviderOptions.sessionWorkspaceCwds}. */
|
|
111
|
+
protected readonly sessionWorkspaceCwds: () => readonly (string | {
|
|
112
|
+
readonly sessionId: string;
|
|
113
|
+
readonly cwd: string;
|
|
114
|
+
})[];
|
|
109
115
|
/** Reads one session's fork lineage; see {@link RoutedFsProviderOptions.sessionAncestors}. */
|
|
110
116
|
protected readonly sessionAncestors: (sessionId: string) => readonly string[];
|
|
111
117
|
/**
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read a session log's header line, with a **bounded** memory footprint.
|
|
3
|
+
*
|
|
4
|
+
* Why this is its own module: the reader used to live in the composition root (`src/index.ts`), where no
|
|
5
|
+
* test can reach it (审计 0046 记录了同款教训). A file-system reader is exactly the kind of code that needs a
|
|
6
|
+
* test to pin its *bounds*, and the bound is the whole point here.
|
|
7
|
+
*
|
|
8
|
+
* ## What went wrong before
|
|
9
|
+
*
|
|
10
|
+
* The old reader claimed to "decompress only the head" but read the **whole file** first:
|
|
11
|
+
*
|
|
12
|
+
* ```ts
|
|
13
|
+
* const handle = await readFile(path); // 10.9 MiB for one real session
|
|
14
|
+
* text = zstdDecompressSync(handle.subarray(0, 256 * 1024)).toString(); // cap applies to the SLICE only
|
|
15
|
+
* } catch {
|
|
16
|
+
* text = zstdDecompressSync(await readFile(path)).toString(); // and the fallback reads it AGAIN
|
|
17
|
+
* }
|
|
18
|
+
* ```
|
|
19
|
+
*
|
|
20
|
+
* `readFile` has no `maxBytes`, so the 256 KiB constant bounded the decompressor's *input*, never the read.
|
|
21
|
+
* Measured on the reference machine's real store (73 sessions): **105.3 MiB** read into memory to extract
|
|
22
|
+
* 73 first lines (19 KiB of output), worst single session **10.9 MiB**, and 51 of 73 logs exceeded the
|
|
23
|
+
* "cap" that was supposedly in force. See `doc/06-audits/0050`.
|
|
24
|
+
*
|
|
25
|
+
* ## What went wrong the second time (2026-10-31)
|
|
26
|
+
*
|
|
27
|
+
* The fallback decompressed the head with `zstdDecompressSync`, which requires a **complete frame** and answers
|
|
28
|
+
* `Z_BUF_ERROR` for anything shorter. That was true of the store it was written against (one small frame per
|
|
29
|
+
* record) and is **not** true of the current one: a 13.2 MB session compresses its head into a frame far larger
|
|
30
|
+
* than any bounded read, so no cap could ever be "complete". Measured on this machine's real store: 8 of 19
|
|
31
|
+
* sessions in one workspace were classified `unreadable`, and every one had an intact header line — 64 KiB of a
|
|
32
|
+
* 13.2 MB log stream-decodes to 184 KB of text whose first line is the header. Users saw it as
|
|
33
|
+
* 「该会话无法读取,不能删除」 on a perfectly healthy session.
|
|
34
|
+
*
|
|
35
|
+
* The fix is to decode **incrementally**: feed the bounded prefix to a stream decoder, keep the output until the
|
|
36
|
+
* first newline, then stop. A truncated frame ending in an `error` event is the normal case here.
|
|
37
|
+
*
|
|
38
|
+
* ## The contract this module implements
|
|
39
|
+
*
|
|
40
|
+
* 1. **Never read more than `cap` bytes of any one file.** The read is a positioned `read(2)` off a
|
|
41
|
+
* descriptor, so the file's size is irrelevant to peak memory.
|
|
42
|
+
* 2. **The fallback is bounded too.** A slice that ends inside a zstd frame can be rejected by the
|
|
43
|
+
* decoder; the retry asks for a larger head (`retryBytes`), never for the whole file.
|
|
44
|
+
* 3. **Failure is `undefined`, never a throw.** A directory with no log, a corrupt frame, or an
|
|
45
|
+
* unparsable first line all yield `undefined`, so the inventory classifies the session as
|
|
46
|
+
* `unreadable` instead of guessing from the lossy directory name (ADR-0009).
|
|
47
|
+
*
|
|
48
|
+
* @module dsh-over-ssh/integration/session-header
|
|
49
|
+
*/
|
|
50
|
+
import type { SessionHeader } from '../../app/session-admin/session-admin-service.ts';
|
|
51
|
+
/**
|
|
52
|
+
* Bytes read from the head of a session log on the first attempt.
|
|
53
|
+
*
|
|
54
|
+
* Chosen from measurement, not taste. On the reference machine's real store (73 sessions, `0050`):
|
|
55
|
+
*
|
|
56
|
+
* - the largest **header line** is **309 B** of UTF-8 (p50 279 B);
|
|
57
|
+
* - the largest compressed frame carrying a lone header is **253 B**;
|
|
58
|
+
* - the smallest head that recovered **all 73** headers is **4 KiB**.
|
|
59
|
+
*
|
|
60
|
+
* 64 KiB is therefore **16x** the empirically sufficient head and **212x** the largest header line, while
|
|
61
|
+
* being 4x smaller than the previous (and, as it turned out, entirely non-binding) 256 KiB constant.
|
|
62
|
+
*/
|
|
63
|
+
export declare const SESSION_HEADER_READ_BYTES: number;
|
|
64
|
+
/**
|
|
65
|
+
* Bytes read on the retry, when the first slice ended inside the frame.
|
|
66
|
+
*
|
|
67
|
+
* Still bounded, and still independent of the file's real size — that is the property the old fallback
|
|
68
|
+
* broke. A log whose header genuinely cannot be recovered from 1 MiB is reported `unreadable`.
|
|
69
|
+
*/
|
|
70
|
+
export declare const SESSION_HEADER_RETRY_BYTES: number;
|
|
71
|
+
/** The I/O this reader needs; injected so a test can pin the byte bounds exactly (AGENTS.md 红线 3). */
|
|
72
|
+
export interface SessionHeaderIo {
|
|
73
|
+
/** List one directory's entry names. */
|
|
74
|
+
readonly listDir: (dir: string) => Promise<readonly string[]>;
|
|
75
|
+
/**
|
|
76
|
+
* Read at most `maxBytes` from the **head** of one file.
|
|
77
|
+
*
|
|
78
|
+
* An implementation must not read the whole file and slice: that is precisely the defect this module
|
|
79
|
+
* exists to prevent. It may return fewer bytes (a short file, or a short read).
|
|
80
|
+
*/
|
|
81
|
+
readonly readHead: (path: string, maxBytes: number) => Promise<Uint8Array>;
|
|
82
|
+
/**
|
|
83
|
+
* Decompress a zstd frame **or frame prefix** to UTF-8 text.
|
|
84
|
+
*
|
|
85
|
+
* May be async: a prefix has to be decoded incrementally (see {@link decompressPrefix}), and a partial frame
|
|
86
|
+
* is expected rather than exceptional.
|
|
87
|
+
*/
|
|
88
|
+
readonly decompress: (bytes: Uint8Array) => string | Promise<string>;
|
|
89
|
+
/** Head caps to try, in order. Defaults to `[SESSION_HEADER_READ_BYTES, SESSION_HEADER_RETRY_BYTES]`. */
|
|
90
|
+
readonly caps?: readonly number[];
|
|
91
|
+
/**
|
|
92
|
+
* Size and mtime of one file, when the implementation can answer it cheaply.
|
|
93
|
+
*
|
|
94
|
+
* Only the cache below uses it: it lets a repeated inventory skip the 64 KiB head read **and** the zstd
|
|
95
|
+
* decode when the log has not changed. Optional so tests and other IO seams stay two-method simple.
|
|
96
|
+
*/
|
|
97
|
+
readonly stat?: (path: string) => Promise<{
|
|
98
|
+
readonly size: number;
|
|
99
|
+
readonly mtimeMs: number;
|
|
100
|
+
}>;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* A **signature-keyed** wrapper around {@link createSessionHeaderReader}.
|
|
104
|
+
*
|
|
105
|
+
* Why: an inventory refreshes on every panel open and after every delete/restore, and each refresh used to read
|
|
106
|
+
* 64 KiB of every session log plus a zstd decode — 31 live sessions measured **13.96 ms** of header work per
|
|
107
|
+
* refresh, against **1.35 ms** for the `stat` calls alone. Reusing a header while the log's `(size, mtime)` is
|
|
108
|
+
* unchanged turns that into the stat cost; a real change (new turn, compaction) changes the signature and the
|
|
109
|
+
* cache misses for that one session.
|
|
110
|
+
*
|
|
111
|
+
* Bounded as this repository requires of every cache: at most `maxEntries` sessions (LRU, the same rule as the
|
|
112
|
+
* ancestor cache) and no timers. The value is a header object plus a short signature, so the worst case at
|
|
113
|
+
* {@link SESSION_HEADER_CACHE_MAX} is ≈256 KB — small enough that a separate byte budget would be accounting
|
|
114
|
+
* theatre, and stated here so the bound can be re-derived. Negative answers are cached too: an unreadable session
|
|
115
|
+
* stays unreadable until its log changes, and re-decoding a corrupt 4 MiB head on every refresh is the exact cost
|
|
116
|
+
* this removes.
|
|
117
|
+
*
|
|
118
|
+
* @param io - the same IO seam {@link createSessionHeaderReader} takes; `stat` is required for the cache to work
|
|
119
|
+
* (without it this returns the uncached reader, so a partial seam stays correct rather than slower).
|
|
120
|
+
* @param options - `maxEntries` (default {@link SESSION_HEADER_CACHE_MAX}).
|
|
121
|
+
* @returns a reader from one session directory to its header.
|
|
122
|
+
*/
|
|
123
|
+
export interface HeaderListingEntry {
|
|
124
|
+
readonly name: string;
|
|
125
|
+
/** File size; the cache signature uses it instead of a fresh `stat` when the caller already has it. */
|
|
126
|
+
readonly size: number;
|
|
127
|
+
readonly mtimeMs: number;
|
|
128
|
+
}
|
|
129
|
+
export declare function createCachedSessionHeaderReader(io: SessionHeaderIo, options?: {
|
|
130
|
+
readonly maxEntries?: number;
|
|
131
|
+
}): (dir: string, listing?: readonly HeaderListingEntry[]) => Promise<SessionHeader | undefined>;
|
|
132
|
+
/**
|
|
133
|
+
* Upper bound on the header cache.
|
|
134
|
+
*
|
|
135
|
+
* **Why not 256**: one inventory pass touches every session in order, so a capacity smaller than the session
|
|
136
|
+
* count makes the pass evict its own entries — the cache then pays a `readdir` + `stat` and still misses, which
|
|
137
|
+
* measured *worse* than no cache at all (300 sessions: 43.89 ms with a 256-entry cache vs 32.21 ms without;
|
|
138
|
+
* 400 sessions: 49.29 vs 35.89). 2048 covers a large real store (2 × 1024 sessions) and costs at most
|
|
139
|
+
* ≈2 MB of header objects; past that the LRU still bounds growth.
|
|
140
|
+
*/
|
|
141
|
+
export declare const SESSION_HEADER_CACHE_MAX = 2048;
|
|
142
|
+
/**
|
|
143
|
+
* Parse the first JSONL line into the header fields this feature needs.
|
|
144
|
+
*
|
|
145
|
+
* @param text - decompressed log text (only the head is present).
|
|
146
|
+
* @returns the header, or `undefined` when the first line is absent or not a session record.
|
|
147
|
+
*/
|
|
148
|
+
export declare function parseSessionHeaderLine(text: string): SessionHeader | undefined;
|
|
149
|
+
/**
|
|
150
|
+
* Build the header reader.
|
|
151
|
+
*
|
|
152
|
+
* Every failure is reported as `undefined` rather than thrown: an inventory must survive one unreadable
|
|
153
|
+
* session.
|
|
154
|
+
*
|
|
155
|
+
* @param io - directory listing, bounded head read and decompressor.
|
|
156
|
+
* @returns a reader from one session directory to its header, or `undefined` when unreadable.
|
|
157
|
+
*/
|
|
158
|
+
export declare function createSessionHeaderReader(io: SessionHeaderIo): (dir: string) => Promise<SessionHeader | undefined>;
|
|
159
|
+
/**
|
|
160
|
+
* Decode a zstd frame **prefix** to text, stopping at the first complete line.
|
|
161
|
+
*
|
|
162
|
+
* Why not `zstdDecompressSync`: it requires a complete frame and answers `Z_BUF_ERROR` for a prefix. The current
|
|
163
|
+
* session store writes frames far larger than any bounded read, so every such log was classified `unreadable`
|
|
164
|
+
* (see the module note). The stream decoder emits what it can from the prefix it was given; a truncated frame
|
|
165
|
+
* ends in an `error` event with the partial output already delivered, which is the **normal** case here.
|
|
166
|
+
*
|
|
167
|
+
* Bounded on purpose: collection stops at the first newline (the header is one line) and the stream is
|
|
168
|
+
* destroyed, so decoding a 13 MB log's head never materialises the body.
|
|
169
|
+
*
|
|
170
|
+
* @param bytes - the bounded head read from the log.
|
|
171
|
+
* @returns the decoded text (possibly a prefix, possibly empty), never a throw.
|
|
172
|
+
*/
|
|
173
|
+
export declare function decompressPrefix(bytes: Uint8Array): Promise<string>;
|
|
174
|
+
/**
|
|
175
|
+
* Read at most `maxBytes` from the head of a file, off a descriptor.
|
|
176
|
+
*
|
|
177
|
+
* A positioned read is what makes the bound real: `readFile` would materialise the entire log first.
|
|
178
|
+
*
|
|
179
|
+
* @param path - file to read.
|
|
180
|
+
* @param maxBytes - hard ceiling on the bytes returned.
|
|
181
|
+
* @returns the bytes actually read (possibly fewer, possibly none).
|
|
182
|
+
*/
|
|
183
|
+
export declare function readHeadFromDisk(path: string, maxBytes: number): Promise<Uint8Array>;
|
|
184
|
+
/**
|
|
185
|
+
* The production I/O: real `readdir`, a **bounded** head read and the real zstd decoder.
|
|
186
|
+
*
|
|
187
|
+
* Exported so a test can pin the one property the reader's whole contract rests on — that the production
|
|
188
|
+
* `readHead` respects its cap. Exercising the assembled reader through an *injected* fake cannot catch a
|
|
189
|
+
* regression in this wiring, because the fake stays bounded either way; a mutation experiment during
|
|
190
|
+
* audit 0050 exposed exactly that gap.
|
|
191
|
+
*
|
|
192
|
+
* @returns the production I/O bundle.
|
|
193
|
+
*/
|
|
194
|
+
export declare function createDiskSessionHeaderIo(): SessionHeaderIo;
|
|
195
|
+
/**
|
|
196
|
+
* The production reader: real `readdir`, a bounded head read and the real zstd decoder.
|
|
197
|
+
*
|
|
198
|
+
* @returns a reader from one session directory to its header, or `undefined` when unreadable.
|
|
199
|
+
*/
|
|
200
|
+
export declare function createDiskSessionHeaderReader(): (dir: string, listing?: readonly HeaderListingEntry[]) => Promise<SessionHeader | undefined>;
|
|
@@ -149,4 +149,4 @@ export interface CredentialsLike {
|
|
|
149
149
|
} | undefined;
|
|
150
150
|
}
|
|
151
151
|
/** 会话日志头读取器(原先在 index.ts 底部,被 tools 段的会话管理使用)。 */
|
|
152
|
-
export declare const readSessionHeader: (dir: string) => Promise<import("../host/app/session-admin/session-admin-contract.ts").SessionHeader | undefined>;
|
|
152
|
+
export declare const readSessionHeader: (dir: string, listing?: readonly import("../host/integration/session/session-header.ts").HeaderListingEntry[]) => Promise<import("../host/app/session-admin/session-admin-contract.ts").SessionHeader | undefined>;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-over-ssh",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"description": "SSH remote development for DeepSeek Harness: connections (key/password/ProxyJump/ssh config), remote files without sync, remote terminal, remote workspaces, remote @-completion, upload/download.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|