dsh-remote-vs 0.0.0-stage → 0.8.3
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 +21 -0
- package/README.en.md +385 -0
- package/README.md +347 -2
- package/cordis.patch.yml +259 -0
- package/icon.svg +7 -0
- package/lib/client.js +62602 -0
- package/lib/index.js +4287 -0
- package/lib/p2-bridge-refs.js +10 -0
- package/lib/p2-bridge.js +16 -0
- package/lib/p2-router-fs.js +252 -0
- package/lib/p2-router-refs.js +466 -0
- package/lib/p2-router-spill.js +62 -0
- package/lib/p2-router-subprocess.js +64 -0
- package/lib/p2-shell-bash.js +24 -0
- package/lib/ssh-proc-arI5lm4W.js +632 -0
- package/lib/types/changes/recorder.d.ts +240 -0
- package/lib/types/changes/status.d.ts +201 -0
- package/lib/types/changes/text-diff.d.ts +65 -0
- package/lib/types/client/CodeEditor.d.ts +125 -0
- package/lib/types/client/Dialog.d.ts +14 -0
- package/lib/types/client/DirectoryFlow.d.ts +30 -0
- package/lib/types/client/EditorPane.d.ts +30 -0
- package/lib/types/client/HostsView.d.ts +10 -0
- package/lib/types/client/NativeExplorer.d.ts +39 -0
- package/lib/types/client/RemoteDirectoryPicker.d.ts +16 -0
- package/lib/types/client/RemoteEditorView.d.ts +20 -0
- package/lib/types/client/RemoteExplorer.d.ts +18 -0
- package/lib/types/client/RemoteRoot.d.ts +16 -0
- package/lib/types/client/SearchView.d.ts +14 -0
- package/lib/types/client/SettingsHosts.d.ts +15 -0
- package/lib/types/client/TerminalView.d.ts +16 -0
- package/lib/types/client/WorkspaceWizard.d.ts +16 -0
- package/lib/types/client/WorkspacesView.d.ts +14 -0
- package/lib/types/client/XTerm.d.ts +15 -0
- package/lib/types/client/api.d.ts +162 -0
- package/lib/types/client/changes/ChangesDock.d.ts +35 -0
- package/lib/types/client/changes/model.d.ts +36 -0
- package/lib/types/client/changes/undo-affordance.d.ts +48 -0
- package/lib/types/client/changes/undo-context.d.ts +25 -0
- package/lib/types/client/composer.d.ts +165 -0
- package/lib/types/client/i18n.d.ts +17 -0
- package/lib/types/client/index.d.ts +14 -0
- package/lib/types/client/remote-file-open-target.d.ts +247 -0
- package/lib/types/client/remote-file-open.d.ts +35 -0
- package/lib/types/client/reveal.d.ts +15 -0
- package/lib/types/client/scm/DiffView.d.ts +24 -0
- package/lib/types/client/scm/HistoryPanel.d.ts +12 -0
- package/lib/types/client/scm/RepoPicker.d.ts +24 -0
- package/lib/types/client/scm/ScmPanel.d.ts +15 -0
- package/lib/types/client/scm/actions.d.ts +170 -0
- package/lib/types/client/scm/diff-model.d.ts +29 -0
- package/lib/types/client/scm/stage-queue.d.ts +99 -0
- package/lib/types/client/scm/status-map.d.ts +157 -0
- package/lib/types/client/scm/untracked-dirs.d.ts +72 -0
- package/lib/types/client/selection-popup.d.ts +61 -0
- package/lib/types/client/store.d.ts +437 -0
- package/lib/types/client/theme.d.ts +15 -0
- package/lib/types/client/ui.d.ts +46 -0
- package/lib/types/client/upload-plan.d.ts +78 -0
- package/lib/types/client/upload-source.d.ts +46 -0
- package/lib/types/client/use-remote-chat-target.d.ts +17 -0
- package/lib/types/client/vsc/FindWidget.d.ts +81 -0
- package/lib/types/client/vsc/change-marks.d.ts +150 -0
- package/lib/types/client/vsc/editor-langs.d.ts +5 -0
- package/lib/types/client/vsc/editor-palette.d.ts +37 -0
- package/lib/types/client/vsc/editor-theme.d.ts +32 -0
- package/lib/types/client/vsc/fallback-highlight.d.ts +26 -0
- package/lib/types/client/vsc/kit.d.ts +195 -0
- package/lib/types/client/vsc/markdown.d.ts +11 -0
- package/lib/types/client/vsc/reveal-line.d.ts +38 -0
- package/lib/types/client/vsc/tokenize.d.ts +104 -0
- package/lib/types/deploy.d.ts +94 -0
- package/lib/types/fs-ops.d.ts +8 -0
- package/lib/types/git-format.d.ts +34 -0
- package/lib/types/git-ops.d.ts +183 -0
- package/lib/types/http.d.ts +14 -0
- package/lib/types/importers.d.ts +20 -0
- package/lib/types/index.d.ts +87 -0
- package/lib/types/keygen.d.ts +19 -0
- package/lib/types/loopback.d.ts +7 -0
- package/lib/types/p2/bridge-refs.d.ts +21 -0
- package/lib/types/p2/bridge.d.ts +24 -0
- package/lib/types/p2/file-refs.d.ts +73 -0
- package/lib/types/p2/helper-rpc.d.ts +63 -0
- package/lib/types/p2/in-flight-gate.d.ts +25 -0
- package/lib/types/p2/router-fs.d.ts +92 -0
- package/lib/types/p2/router-refs.d.ts +45 -0
- package/lib/types/p2/router-spill.d.ts +11 -0
- package/lib/types/p2/router-subprocess.d.ts +23 -0
- package/lib/types/p2/shell-bash.d.ts +16 -0
- package/lib/types/p2/ssh-proc.d.ts +22 -0
- package/lib/types/p2/worlds.d.ts +179 -0
- package/lib/types/paths.d.ts +12 -0
- package/lib/types/pool.d.ts +105 -0
- package/lib/types/protocol.d.ts +452 -0
- package/lib/types/routes.d.ts +27 -0
- package/lib/types/search-ops.d.ts +68 -0
- package/lib/types/sftp.d.ts +90 -0
- package/lib/types/store.d.ts +66 -0
- package/lib/types/terminal.d.ts +51 -0
- package/lib/types/timeouts.d.ts +83 -0
- package/lib/types/tools.d.ts +41 -0
- package/lib/worlds-PUumRIh7.js +18765 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +189 -4
- package/src/changes/recorder.ts +902 -0
- package/src/changes/status.ts +448 -0
- package/src/changes/text-diff.ts +377 -0
- package/src/client/CodeEditor.tsx +923 -0
- package/src/client/Dialog.tsx +76 -0
- package/src/client/DirectoryFlow.tsx +431 -0
- package/src/client/EditorPane.tsx +512 -0
- package/src/client/HostsView.tsx +312 -0
- package/src/client/NativeExplorer.tsx +121 -0
- package/src/client/RemoteDirectoryPicker.tsx +157 -0
- package/src/client/RemoteEditorView.tsx +54 -0
- package/src/client/RemoteExplorer.tsx +1398 -0
- package/src/client/RemoteRoot.tsx +389 -0
- package/src/client/SearchView.tsx +407 -0
- package/src/client/SettingsHosts.tsx +136 -0
- package/src/client/TerminalView.tsx +308 -0
- package/src/client/WorkspaceWizard.tsx +199 -0
- package/src/client/WorkspacesView.tsx +93 -0
- package/src/client/XTerm.tsx +308 -0
- package/src/client/api.ts +331 -0
- package/src/client/changes/ChangesDock.tsx +348 -0
- package/src/client/changes/changes.css +230 -0
- package/src/client/changes/model.ts +347 -0
- package/src/client/changes/undo-affordance.ts +389 -0
- package/src/client/changes/undo-context.ts +34 -0
- package/src/client/composer.ts +436 -0
- package/src/client/css.d.ts +7 -0
- package/src/client/i18n.ts +702 -0
- package/src/client/index.tsx +286 -0
- package/src/client/remote-file-open-target.ts +553 -0
- package/src/client/remote-file-open.ts +261 -0
- package/src/client/reveal.ts +33 -0
- package/src/client/scm/DiffView.tsx +570 -0
- package/src/client/scm/HistoryPanel.tsx +353 -0
- package/src/client/scm/RepoPicker.tsx +238 -0
- package/src/client/scm/ScmPanel.tsx +857 -0
- package/src/client/scm/actions.ts +460 -0
- package/src/client/scm/diff-model.ts +206 -0
- package/src/client/scm/stage-queue.ts +264 -0
- package/src/client/scm/status-map.ts +381 -0
- package/src/client/scm/untracked-dirs.ts +139 -0
- package/src/client/selection-popup.ts +157 -0
- package/src/client/store.tsx +1670 -0
- package/src/client/theme.ts +54 -0
- package/src/client/ui.tsx +136 -0
- package/src/client/upload-plan.ts +153 -0
- package/src/client/upload-source.ts +145 -0
- package/src/client/use-remote-chat-target.ts +153 -0
- package/src/client/vsc/FindWidget.tsx +446 -0
- package/src/client/vsc/change-marks.ts +586 -0
- package/src/client/vsc/editor-langs.ts +137 -0
- package/src/client/vsc/editor-palette.ts +107 -0
- package/src/client/vsc/editor-theme.ts +93 -0
- package/src/client/vsc/fallback-highlight.ts +145 -0
- package/src/client/vsc/kit.tsx +777 -0
- package/src/client/vsc/markdown.tsx +172 -0
- package/src/client/vsc/reveal-line.ts +142 -0
- package/src/client/vsc/tokenize.ts +569 -0
- package/src/client/vsc/vsc.css +2669 -0
- package/src/deploy.ts +481 -0
- package/src/fs-ops.ts +57 -0
- package/src/git-format.ts +205 -0
- package/src/git-ops.ts +693 -0
- package/src/http.ts +55 -0
- package/src/importers.ts +141 -0
- package/src/index.ts +442 -0
- package/src/keygen.ts +77 -0
- package/src/loopback.ts +47 -0
- package/src/p2/bridge-refs.ts +27 -0
- package/src/p2/bridge.ts +32 -0
- package/src/p2/file-refs.ts +392 -0
- package/src/p2/helper-rpc.ts +394 -0
- package/src/p2/in-flight-gate.ts +63 -0
- package/src/p2/router-fs.ts +327 -0
- package/src/p2/router-refs.ts +166 -0
- package/src/p2/router-spill.ts +75 -0
- package/src/p2/router-subprocess.ts +92 -0
- package/src/p2/shell-bash.ts +30 -0
- package/src/p2/ssh-proc.ts +802 -0
- package/src/p2/worlds.ts +735 -0
- package/src/paths.ts +50 -0
- package/src/pool.ts +811 -0
- package/src/protocol.ts +460 -0
- package/src/routes.ts +802 -0
- package/src/search-ops.ts +220 -0
- package/src/sftp.ts +677 -0
- package/src/store.ts +364 -0
- package/src/terminal.ts +291 -0
- package/src/timeouts.ts +122 -0
- package/src/tools.ts +320 -0
- package/vendor/dsh-remote-helpers/dsh-ssh-helper-0.2.1-alpha.2-linux-x64.helper.json +16 -0
- package/vendor/dsh-remote-helpers/dsh-ssh-helper-0.2.1-alpha.2-linux-x64.tar.gz +4 -0
- package/vendor/dsh-remote-helpers/dsh-ssh-helper-0.2.1-alpha.2-linux-x64.tar.gz.sha256 +1 -0
- package/vendor/dsh-remote-helpers/glibc-ceiling.patch +31 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,4287 @@
|
|
|
1
|
+
import { a as pushPipeline, c as require_lib, d as ensurePrivateDir, f as keysDir, i as probe, l as aliasRoot, o as seedHelperCache, p as storePath, r as helperAvailability, s as RemoteFiles, t as RemoteWorlds, u as dshHome } from "./worlds-PUumRIh7.js";
|
|
2
|
+
import z from "@deepseek-ai/schemastery";
|
|
3
|
+
import { chmodSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, statSync, writeSync } from "node:fs";
|
|
4
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { WebSocketServer } from "ws";
|
|
9
|
+
import { execFile } from "node:child_process";
|
|
10
|
+
import { promisify } from "node:util";
|
|
11
|
+
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
12
|
+
//#region src/store.ts
|
|
13
|
+
/**
|
|
14
|
+
* Durable store for dsh-remote-vs: hosts + workspace bindings in
|
|
15
|
+
* $DSH_HOME/dsh-remote.json (0600, atomic tmp+rename writes).
|
|
16
|
+
*/
|
|
17
|
+
/** Empty document. */
|
|
18
|
+
function emptyDoc() {
|
|
19
|
+
return {
|
|
20
|
+
version: 1,
|
|
21
|
+
hosts: [],
|
|
22
|
+
workspaces: [],
|
|
23
|
+
tombstones: []
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
/** Same file identity (both absent counts as same). */
|
|
27
|
+
function sameStamp(left, right) {
|
|
28
|
+
if (left === null || right === null) return left === right;
|
|
29
|
+
return left.mtimeMs === right.mtimeMs && left.size === right.size;
|
|
30
|
+
}
|
|
31
|
+
/** Alias grammar: 1-64 chars of letters/digits/._- */
|
|
32
|
+
const ALIAS_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
33
|
+
function assertAlias(alias) {
|
|
34
|
+
if (typeof alias !== "string" || !ALIAS_RE.test(alias)) throw new Error("alias must match [A-Za-z0-9._-]{1,64} and start with an alphanumeric");
|
|
35
|
+
}
|
|
36
|
+
var RemoteStore = class {
|
|
37
|
+
file;
|
|
38
|
+
doc;
|
|
39
|
+
/** File identity when `doc` was read from disk (undefined = `doc` is not known fresh). */
|
|
40
|
+
stamp;
|
|
41
|
+
constructor(file) {
|
|
42
|
+
this.file = file ?? storePath();
|
|
43
|
+
this.doc = emptyDoc();
|
|
44
|
+
this.reload();
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Reload from disk (picks up external edits).
|
|
48
|
+
*
|
|
49
|
+
* Re-reading on every access is deliberate: the root fs router asks this store
|
|
50
|
+
* 2-4 times per routed file operation, and an edit made by another process
|
|
51
|
+
* must be visible without a restart. A stat is orders of magnitude cheaper
|
|
52
|
+
* than read + parse, so the file's identity (mtime + size) decides instead —
|
|
53
|
+
* unchanged means the cached doc is already the file's content. The stamp is
|
|
54
|
+
* taken BEFORE the read: a write landing in between leaves the older stamp
|
|
55
|
+
* behind, so the next access re-reads rather than trusting a stale doc. The
|
|
56
|
+
* one edit this cannot see is a same-length rewrite inside the filesystem's
|
|
57
|
+
* mtime granularity (sub-ms on NTFS/ext4).
|
|
58
|
+
*/
|
|
59
|
+
reload() {
|
|
60
|
+
const stamp = this.stampOf();
|
|
61
|
+
if (stamp !== void 0 && this.stamp !== void 0 && sameStamp(stamp, this.stamp)) return;
|
|
62
|
+
this.doc = this.loadDoc();
|
|
63
|
+
this.stamp = stamp;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* File identity right now: `null` when the file is absent, `undefined` when it
|
|
67
|
+
* cannot be statted at all. Both cases fall through to a full load, so the
|
|
68
|
+
* missing-file and unreadable-file paths behave exactly as they did before.
|
|
69
|
+
*/
|
|
70
|
+
stampOf() {
|
|
71
|
+
try {
|
|
72
|
+
const stat = statSync(this.file, { throwIfNoEntry: false });
|
|
73
|
+
return stat === void 0 ? null : {
|
|
74
|
+
mtimeMs: stat.mtimeMs,
|
|
75
|
+
size: stat.size
|
|
76
|
+
};
|
|
77
|
+
} catch {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
loadDoc() {
|
|
82
|
+
if (!existsSync(this.file)) return emptyDoc();
|
|
83
|
+
let parsed;
|
|
84
|
+
try {
|
|
85
|
+
parsed = JSON.parse(readFileSync(this.file, "utf8"));
|
|
86
|
+
} catch (error) {
|
|
87
|
+
throw new Error(`cannot parse ${this.file}: ${error instanceof Error ? error.message : String(error)}`);
|
|
88
|
+
}
|
|
89
|
+
if (typeof parsed !== "object" || parsed === null) throw new Error(`${this.file}: root must be an object`);
|
|
90
|
+
const doc = parsed;
|
|
91
|
+
if (doc.version !== 1) throw new Error(`${this.file}: unsupported version ${doc.version}`);
|
|
92
|
+
if (!Array.isArray(doc.hosts) || !Array.isArray(doc.workspaces)) throw new Error(`${this.file}: hosts/workspaces must be arrays`);
|
|
93
|
+
return {
|
|
94
|
+
version: 1,
|
|
95
|
+
hosts: doc.hosts,
|
|
96
|
+
workspaces: doc.workspaces,
|
|
97
|
+
tombstones: Array.isArray(doc.tombstones) ? doc.tombstones.filter((t) => typeof t === "string") : []
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
save() {
|
|
101
|
+
this.stamp = void 0;
|
|
102
|
+
const payload = JSON.stringify(this.doc, null, 2);
|
|
103
|
+
const tmp = `${this.file}.${process.pid}.tmp`;
|
|
104
|
+
const fd = openSync(tmp, "w", 384);
|
|
105
|
+
try {
|
|
106
|
+
writeSync(fd, payload);
|
|
107
|
+
} finally {
|
|
108
|
+
closeSync(fd);
|
|
109
|
+
}
|
|
110
|
+
renameSync(tmp, this.file);
|
|
111
|
+
}
|
|
112
|
+
list() {
|
|
113
|
+
this.reload();
|
|
114
|
+
return [...this.doc.hosts];
|
|
115
|
+
}
|
|
116
|
+
get(alias) {
|
|
117
|
+
this.reload();
|
|
118
|
+
return this.doc.hosts.find((h) => h.alias === alias);
|
|
119
|
+
}
|
|
120
|
+
require(alias) {
|
|
121
|
+
const entry = this.get(alias);
|
|
122
|
+
if (entry === void 0) throw new Error(`unknown host alias: ${alias}`);
|
|
123
|
+
return entry;
|
|
124
|
+
}
|
|
125
|
+
create(payload) {
|
|
126
|
+
assertAlias(payload.alias);
|
|
127
|
+
if (this.get(payload.alias) !== void 0) throw new Error(`alias already exists: ${payload.alias}`);
|
|
128
|
+
const entry = this.normalize(payload, void 0);
|
|
129
|
+
this.doc.hosts.push(entry);
|
|
130
|
+
this.save();
|
|
131
|
+
return entry;
|
|
132
|
+
}
|
|
133
|
+
update(alias, patch) {
|
|
134
|
+
const current = this.require(alias);
|
|
135
|
+
let auth = patch.auth ?? current.auth;
|
|
136
|
+
if (patch.auth !== void 0) if (patch.auth.kind === "key") {
|
|
137
|
+
const keyPath = (patch.auth.keyPath ?? "").trim();
|
|
138
|
+
auth = {
|
|
139
|
+
kind: "key",
|
|
140
|
+
keyPath: keyPath !== "" ? keyPath : current.auth.kind === "key" ? current.auth.keyPath : "",
|
|
141
|
+
...patch.auth.passphrase !== void 0 ? { passphrase: patch.auth.passphrase } : current.auth.kind === "key" && current.auth.passphrase !== void 0 ? { passphrase: current.auth.passphrase } : {}
|
|
142
|
+
};
|
|
143
|
+
} else {
|
|
144
|
+
const password = patch.auth.password ?? "";
|
|
145
|
+
auth = {
|
|
146
|
+
kind: "password",
|
|
147
|
+
password: password !== "" ? password : current.auth.kind === "password" ? current.auth.password : ""
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
const merged = {
|
|
151
|
+
alias,
|
|
152
|
+
host: patch.host ?? current.host,
|
|
153
|
+
port: patch.port ?? current.port,
|
|
154
|
+
user: patch.user ?? current.user,
|
|
155
|
+
auth,
|
|
156
|
+
description: patch.description ?? current.description,
|
|
157
|
+
tags: patch.tags ?? current.tags
|
|
158
|
+
};
|
|
159
|
+
const next = this.normalize(merged, current);
|
|
160
|
+
const index = this.doc.hosts.findIndex((h) => h.alias === alias);
|
|
161
|
+
this.doc.hosts[index] = next;
|
|
162
|
+
this.save();
|
|
163
|
+
return next;
|
|
164
|
+
}
|
|
165
|
+
remove(alias) {
|
|
166
|
+
const before = this.doc.hosts.length;
|
|
167
|
+
this.doc.hosts = this.doc.hosts.filter((h) => h.alias !== alias);
|
|
168
|
+
this.doc.workspaces = this.doc.workspaces.filter((w) => w.alias !== alias);
|
|
169
|
+
if (this.doc.hosts.length === before) return false;
|
|
170
|
+
this.save();
|
|
171
|
+
return true;
|
|
172
|
+
}
|
|
173
|
+
normalize(payload, current) {
|
|
174
|
+
if (typeof payload.host !== "string" || payload.host.trim() === "") throw new Error("host must be a non-empty string");
|
|
175
|
+
const port = payload.port ?? 22;
|
|
176
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("port must be an integer 1-65535");
|
|
177
|
+
if (typeof payload.user !== "string" || payload.user.trim() === "") throw new Error("user must be a non-empty string");
|
|
178
|
+
const auth = payload.auth;
|
|
179
|
+
if (auth === void 0 || auth.kind !== "key" && auth.kind !== "password") throw new Error("auth.kind must be key or password");
|
|
180
|
+
if (auth.kind === "key") {
|
|
181
|
+
if (typeof auth.keyPath !== "string" || auth.keyPath.trim() === "") throw new Error("auth.keyPath is required for key auth");
|
|
182
|
+
if (auth.passphrase !== void 0 && typeof auth.passphrase !== "string") throw new Error("auth.passphrase must be a string when provided");
|
|
183
|
+
} else if (typeof auth.password !== "string" || auth.password === "") throw new Error("auth.password must be a non-empty string for password auth");
|
|
184
|
+
if (payload.description !== void 0 && typeof payload.description !== "string") throw new Error("description must be a string when provided");
|
|
185
|
+
if (payload.tags !== void 0 && (!Array.isArray(payload.tags) || payload.tags.some((t) => typeof t !== "string"))) throw new Error("tags must be string[] when provided");
|
|
186
|
+
const now = Date.now();
|
|
187
|
+
return {
|
|
188
|
+
alias: payload.alias,
|
|
189
|
+
host: payload.host.trim(),
|
|
190
|
+
port,
|
|
191
|
+
user: payload.user.trim(),
|
|
192
|
+
auth: {
|
|
193
|
+
kind: auth.kind,
|
|
194
|
+
...auth.keyPath !== void 0 ? { keyPath: auth.keyPath } : {},
|
|
195
|
+
...auth.passphrase !== void 0 ? { passphrase: auth.passphrase } : {},
|
|
196
|
+
...auth.password !== void 0 ? { password: auth.password } : {}
|
|
197
|
+
},
|
|
198
|
+
...payload.description !== void 0 ? { description: payload.description } : {},
|
|
199
|
+
tags: payload.tags ?? [],
|
|
200
|
+
...current?.deploy !== void 0 ? { deploy: current.deploy } : {},
|
|
201
|
+
createdAt: current?.createdAt ?? now,
|
|
202
|
+
updatedAt: now
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
/** Secret-free projection for browser/agent surfaces. */
|
|
206
|
+
summarize(entry) {
|
|
207
|
+
let keyReady = false;
|
|
208
|
+
if (entry.auth.kind === "key" && entry.auth.keyPath !== void 0) try {
|
|
209
|
+
keyReady = existsSync(entry.auth.keyPath);
|
|
210
|
+
} catch {
|
|
211
|
+
keyReady = false;
|
|
212
|
+
}
|
|
213
|
+
return {
|
|
214
|
+
alias: entry.alias,
|
|
215
|
+
host: entry.host,
|
|
216
|
+
port: entry.port,
|
|
217
|
+
user: entry.user,
|
|
218
|
+
auth: entry.auth.kind,
|
|
219
|
+
keyReady,
|
|
220
|
+
managedKey: entry.auth.kind === "key" && entry.auth.keyPath !== void 0 && entry.auth.keyPath.startsWith(keysDir() + (process.platform === "win32" ? "\\" : "/")),
|
|
221
|
+
...entry.description !== void 0 ? { description: entry.description } : {},
|
|
222
|
+
tags: [...entry.tags],
|
|
223
|
+
...entry.deploy !== void 0 ? { deploy: entry.deploy } : {},
|
|
224
|
+
createdAt: entry.createdAt,
|
|
225
|
+
updatedAt: entry.updatedAt
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
setDeploy(alias, state) {
|
|
229
|
+
const entry = this.require(alias);
|
|
230
|
+
entry.deploy = state;
|
|
231
|
+
entry.updatedAt = Date.now();
|
|
232
|
+
this.save();
|
|
233
|
+
}
|
|
234
|
+
listWorkspaces() {
|
|
235
|
+
this.reload();
|
|
236
|
+
return [...this.doc.workspaces];
|
|
237
|
+
}
|
|
238
|
+
createWorkspace(payload) {
|
|
239
|
+
if (typeof payload.name !== "string" || payload.name.trim() === "") throw new Error("name must be a non-empty string");
|
|
240
|
+
this.require(payload.alias);
|
|
241
|
+
if (typeof payload.remotePath !== "string" || !payload.remotePath.startsWith("/")) throw new Error("remotePath must be an absolute POSIX path");
|
|
242
|
+
const binding = {
|
|
243
|
+
id: randomUUID().slice(0, 8),
|
|
244
|
+
name: payload.name.trim(),
|
|
245
|
+
alias: payload.alias,
|
|
246
|
+
remotePath: payload.remotePath,
|
|
247
|
+
createdAt: Date.now()
|
|
248
|
+
};
|
|
249
|
+
this.doc.workspaces.push(binding);
|
|
250
|
+
this.save();
|
|
251
|
+
return binding;
|
|
252
|
+
}
|
|
253
|
+
removeWorkspace(id) {
|
|
254
|
+
this.reload();
|
|
255
|
+
if (this.doc.workspaces.find((w) => w.id === id) === void 0) return false;
|
|
256
|
+
this.addTombstone(join(aliasRoot(), id));
|
|
257
|
+
this.doc.workspaces = this.doc.workspaces.filter((w) => w.id !== id);
|
|
258
|
+
this.save();
|
|
259
|
+
return true;
|
|
260
|
+
}
|
|
261
|
+
/** Record a removed alias path (idempotent). */
|
|
262
|
+
addTombstone(aliasPath) {
|
|
263
|
+
if (!this.doc.tombstones.includes(aliasPath)) {
|
|
264
|
+
this.doc.tombstones.push(aliasPath);
|
|
265
|
+
this.save();
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
listTombstones() {
|
|
269
|
+
this.reload();
|
|
270
|
+
return [...this.doc.tombstones];
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
//#endregion
|
|
274
|
+
//#region src/pool.ts
|
|
275
|
+
/**
|
|
276
|
+
* Persistent ssh2 connection pool: one long-lived connection per host alias,
|
|
277
|
+
* dropped after 30 idle minutes. All remote ops (exec / shell / sftp) ride it.
|
|
278
|
+
*
|
|
279
|
+
* Trust model (P0): host keys are accepted on first use (TOFU); a changed key
|
|
280
|
+
* for a known host is logged loudly but the connection still proceeds. Strict
|
|
281
|
+
* known-hosts enforcement is P1 work.
|
|
282
|
+
*/
|
|
283
|
+
var import_lib = require_lib();
|
|
284
|
+
/** Idle time after which a pooled connection is closed. */
|
|
285
|
+
const IDLE_MS = 1800 * 1e3;
|
|
286
|
+
/**
|
|
287
|
+
* Connection establishment timeout, including authentication.
|
|
288
|
+
*
|
|
289
|
+
* This was 15s, which is not a handshake budget so much as a CPU budget: on a
|
|
290
|
+
* two-core host running a build, sshd could not complete a PASSWORD exchange
|
|
291
|
+
* inside it, and every call failed with "Timed out while waiting for handshake"
|
|
292
|
+
* while the banner was arriving in 15ms. Key auth fixed the common case (~1s),
|
|
293
|
+
* but a loaded host is still slow enough to be worth headroom.
|
|
294
|
+
*
|
|
295
|
+
* Override with DSH_REMOTE_VS_CONNECT_TIMEOUT_MS (milliseconds, >= 1000).
|
|
296
|
+
*/
|
|
297
|
+
const READY_TIMEOUT_MS = (() => {
|
|
298
|
+
const raw = Number(process.env.DSH_REMOTE_VS_CONNECT_TIMEOUT_MS);
|
|
299
|
+
return Number.isFinite(raw) && raw >= 1e3 ? raw : 45e3;
|
|
300
|
+
})();
|
|
301
|
+
/** Per-stream output cap (stdout/stderr each); overflow truncates with a marker. */
|
|
302
|
+
const OUTPUT_CAP = 2 * 1024 * 1024;
|
|
303
|
+
/**
|
|
304
|
+
* Wall-clock cap for one SFTP callback body. SSH gives no request timeout of
|
|
305
|
+
* its own, so a server that never answers would hold the session (and the
|
|
306
|
+
* pooled connection's hold count) open forever — the leak that eventually
|
|
307
|
+
* exhausts the server's channel budget. The timeout rejects so the caller's
|
|
308
|
+
* `finally` still ends the session and releases the connection.
|
|
309
|
+
*/
|
|
310
|
+
const SFTP_OP_TIMEOUT_MS = 6e4;
|
|
311
|
+
/**
|
|
312
|
+
* No-progress budget for a bulk upload. Deliberately an *idle* timeout rather
|
|
313
|
+
* than a wall clock: the point is to catch a stream that stopped moving, not
|
|
314
|
+
* to give up on a large file that is transferring fine on a slow link.
|
|
315
|
+
* `fastPut` resets it on every chunk via its `step` callback.
|
|
316
|
+
*/
|
|
317
|
+
const UPLOAD_IDLE_MS = 6e4;
|
|
318
|
+
const TRUNCATED_MARKER = "\n[... truncated: output exceeded 2 MiB ...]\n";
|
|
319
|
+
/** Leading line the session wrapper prints so the timeout can kill its group. */
|
|
320
|
+
const PGID_MARKER = "RV_PID=";
|
|
321
|
+
/** One-shot `setsid --wait` capability probe (see RemotePool.supportsSetsid). */
|
|
322
|
+
const SETSID_PROBE = "setsid --wait sh -c 'exit 7' 2>/dev/null; test $? -eq 7 && echo RV_SETSID=1";
|
|
323
|
+
const SETSID_PROBE_MARK = "RV_SETSID=1";
|
|
324
|
+
/** Deadline for the probe and for a group kill; both are one tiny command. */
|
|
325
|
+
const CONTROL_TIMEOUT_MS = 15e3;
|
|
326
|
+
/**
|
|
327
|
+
* How long a killed group gets to report its exit before TERM escalates to
|
|
328
|
+
* KILL. Without the escalation a command that ignores TERM would survive the
|
|
329
|
+
* timeout, which is the leak this kill exists to close (p2/ssh-proc.ts uses
|
|
330
|
+
* the same grace before its own escalation).
|
|
331
|
+
*/
|
|
332
|
+
const GROUP_KILL_GRACE_MS = 3e3;
|
|
333
|
+
/**
|
|
334
|
+
* Concurrent channels the pool opens per alias. The server's own `MaxSessions`
|
|
335
|
+
* (10 on the hosts this was measured against) is the real ceiling, and running
|
|
336
|
+
* past it fails the extra channels hard — "open failed / administratively
|
|
337
|
+
* prohibited" — with nobody retrying. Six is chosen to leave room for what is
|
|
338
|
+
* deliberately NOT counted here: PTY terminals, the processes the subprocess
|
|
339
|
+
* runtime owns, and the helper's channel.
|
|
340
|
+
*/
|
|
341
|
+
const CHANNEL_LIMIT = 6;
|
|
342
|
+
/**
|
|
343
|
+
* How long an operation waits for a channel slot before failing. Bounded on
|
|
344
|
+
* purpose: queueing forever behind a peer that never finishes is worse than a
|
|
345
|
+
* readable error that tells the user to retry.
|
|
346
|
+
*/
|
|
347
|
+
const CHANNEL_WAIT_MS = 3e4;
|
|
348
|
+
var RemotePool = class {
|
|
349
|
+
store;
|
|
350
|
+
conns = /* @__PURE__ */ new Map();
|
|
351
|
+
/** TOFU fingerprints observed this process (alias -> sha256). */
|
|
352
|
+
hostKeys = /* @__PURE__ */ new Map();
|
|
353
|
+
/** Per-alias `setsid --wait` capability, probed once (see supportsSetsid). */
|
|
354
|
+
setsidProbes = /* @__PURE__ */ new Map();
|
|
355
|
+
/** Per-alias channel slots and the queue waiting for one (see acquireSlot). */
|
|
356
|
+
slots = /* @__PURE__ */ new Map();
|
|
357
|
+
constructor(store) {
|
|
358
|
+
this.store = store;
|
|
359
|
+
}
|
|
360
|
+
/** Drop one pooled connection (call after host update/delete). */
|
|
361
|
+
drop(alias) {
|
|
362
|
+
const pooled = this.conns.get(alias);
|
|
363
|
+
this.setsidProbes.delete(alias);
|
|
364
|
+
if (pooled === void 0) return;
|
|
365
|
+
this.conns.delete(alias);
|
|
366
|
+
if (pooled.timer !== void 0) clearTimeout(pooled.timer);
|
|
367
|
+
try {
|
|
368
|
+
pooled.conn.end();
|
|
369
|
+
} catch {}
|
|
370
|
+
}
|
|
371
|
+
/** Close every pooled connection. */
|
|
372
|
+
dispose() {
|
|
373
|
+
for (const alias of [...this.conns.keys()]) this.drop(alias);
|
|
374
|
+
}
|
|
375
|
+
/** Test a connection without disturbing the pool. */
|
|
376
|
+
async test(alias) {
|
|
377
|
+
const started = Date.now();
|
|
378
|
+
try {
|
|
379
|
+
await this.acquire(alias);
|
|
380
|
+
this.hold(alias);
|
|
381
|
+
this.release(alias);
|
|
382
|
+
return {
|
|
383
|
+
ok: true,
|
|
384
|
+
durationMs: Date.now() - started
|
|
385
|
+
};
|
|
386
|
+
} catch (error) {
|
|
387
|
+
return {
|
|
388
|
+
ok: false,
|
|
389
|
+
error: error instanceof Error ? error.message : String(error),
|
|
390
|
+
durationMs: Date.now() - started
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* Run one non-interactive command.
|
|
396
|
+
*
|
|
397
|
+
* The command needs a channel slot: a burst of SCM or search calls is exactly
|
|
398
|
+
* how a host runs past `MaxSessions`, where the surplus channels fail instead
|
|
399
|
+
* of queueing. The pool's own housekeeping (`bare`: the setsid probe and the
|
|
400
|
+
* group kill) is exempt — a kill that waits behind user traffic would be a
|
|
401
|
+
* worse bug than the one it fixes.
|
|
402
|
+
*/
|
|
403
|
+
async exec(alias, command, opts = {}) {
|
|
404
|
+
if (opts.bare === true) return this.runExec(alias, command, opts);
|
|
405
|
+
const started = Date.now();
|
|
406
|
+
try {
|
|
407
|
+
await this.acquireSlot(alias);
|
|
408
|
+
} catch (error) {
|
|
409
|
+
return {
|
|
410
|
+
success: false,
|
|
411
|
+
exitCode: null,
|
|
412
|
+
timedOut: false,
|
|
413
|
+
stdout: "",
|
|
414
|
+
stderr: "",
|
|
415
|
+
durationMs: Date.now() - started,
|
|
416
|
+
error: error instanceof Error ? error.message : String(error)
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
try {
|
|
420
|
+
return await this.runExec(alias, command, opts);
|
|
421
|
+
} finally {
|
|
422
|
+
this.releaseSlot(alias);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
/** The exec body, run while the caller already holds a channel slot. */
|
|
426
|
+
async runExec(alias, command, opts = {}) {
|
|
427
|
+
const started = Date.now();
|
|
428
|
+
const timeoutMs = opts.timeoutMs ?? 6e4;
|
|
429
|
+
const session = opts.bare === true ? false : await this.supportsSetsid(alias);
|
|
430
|
+
let conn;
|
|
431
|
+
try {
|
|
432
|
+
conn = await this.acquire(alias);
|
|
433
|
+
this.hold(alias);
|
|
434
|
+
} catch (error) {
|
|
435
|
+
return {
|
|
436
|
+
success: false,
|
|
437
|
+
exitCode: null,
|
|
438
|
+
timedOut: false,
|
|
439
|
+
stdout: "",
|
|
440
|
+
stderr: "",
|
|
441
|
+
durationMs: Date.now() - started,
|
|
442
|
+
error: error instanceof Error ? error.message : String(error)
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
try {
|
|
446
|
+
return await new Promise((resolve) => {
|
|
447
|
+
let settled = false;
|
|
448
|
+
let timedOut = false;
|
|
449
|
+
/** Server-confirmed child exit; a bare channel close is not proof. */
|
|
450
|
+
let exited = false;
|
|
451
|
+
/** Session/process-group id, from the wrapper's leading marker line. */
|
|
452
|
+
let pgid;
|
|
453
|
+
let markerPending = session;
|
|
454
|
+
let warned = false;
|
|
455
|
+
/** Say once, and only once, that the timeout could not reach the command. */
|
|
456
|
+
const warnOnce = () => {
|
|
457
|
+
if (warned) return;
|
|
458
|
+
warned = true;
|
|
459
|
+
warnDetached(alias);
|
|
460
|
+
};
|
|
461
|
+
const finish = (result) => {
|
|
462
|
+
if (settled) return;
|
|
463
|
+
settled = true;
|
|
464
|
+
clearTimeout(timer);
|
|
465
|
+
this.release(alias);
|
|
466
|
+
resolve({
|
|
467
|
+
...result,
|
|
468
|
+
durationMs: Date.now() - started
|
|
469
|
+
});
|
|
470
|
+
};
|
|
471
|
+
/**
|
|
472
|
+
* Signal the command's process group over a fresh channel: the timed-out
|
|
473
|
+
* channel is closing, and `channel.signal()` would only reach the outer
|
|
474
|
+
* shell anyway. `bare` keeps the kill out of the wrapper — a kill that
|
|
475
|
+
* hung would otherwise try to kill its own group.
|
|
476
|
+
*/
|
|
477
|
+
const killGroup = (signal) => {
|
|
478
|
+
if (pgid === void 0) return;
|
|
479
|
+
this.exec(alias, `kill -${signal} -- -${pgid}`, {
|
|
480
|
+
timeoutMs: CONTROL_TIMEOUT_MS,
|
|
481
|
+
bare: true
|
|
482
|
+
}).catch(() => void 0);
|
|
483
|
+
};
|
|
484
|
+
const terminateGroup = () => {
|
|
485
|
+
killGroup("TERM");
|
|
486
|
+
setTimeout(() => {
|
|
487
|
+
if (!exited) killGroup("KILL");
|
|
488
|
+
}, GROUP_KILL_GRACE_MS).unref?.();
|
|
489
|
+
};
|
|
490
|
+
const timer = setTimeout(() => {
|
|
491
|
+
timedOut = true;
|
|
492
|
+
try {
|
|
493
|
+
stream?.close();
|
|
494
|
+
} catch {}
|
|
495
|
+
if (pgid !== void 0) terminateGroup();
|
|
496
|
+
else if (!session) warnOnce();
|
|
497
|
+
finish({
|
|
498
|
+
success: false,
|
|
499
|
+
exitCode: null,
|
|
500
|
+
timedOut: true,
|
|
501
|
+
stdout: out,
|
|
502
|
+
stderr: err
|
|
503
|
+
});
|
|
504
|
+
}, timeoutMs);
|
|
505
|
+
let stream;
|
|
506
|
+
let out = "";
|
|
507
|
+
let err = "";
|
|
508
|
+
conn.exec(session ? sessionLine(command) : command, (execError, channel) => {
|
|
509
|
+
if (execError !== void 0 && execError !== null) {
|
|
510
|
+
finish({
|
|
511
|
+
success: false,
|
|
512
|
+
exitCode: null,
|
|
513
|
+
timedOut: false,
|
|
514
|
+
stdout: "",
|
|
515
|
+
stderr: "",
|
|
516
|
+
error: execError.message
|
|
517
|
+
});
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
if (settled) setTimeout(() => {
|
|
521
|
+
if (pgid === void 0) warnOnce();
|
|
522
|
+
try {
|
|
523
|
+
channel.close();
|
|
524
|
+
} catch {}
|
|
525
|
+
}, GROUP_KILL_GRACE_MS).unref?.();
|
|
526
|
+
else stream = channel;
|
|
527
|
+
channel.on("close", (code, signalName) => {
|
|
528
|
+
if (code !== null && code !== void 0 || signalName !== null && signalName !== void 0) exited = true;
|
|
529
|
+
if (timedOut && session && pgid === void 0) warnOnce();
|
|
530
|
+
finish({
|
|
531
|
+
success: code === 0,
|
|
532
|
+
exitCode: code,
|
|
533
|
+
timedOut: false,
|
|
534
|
+
stdout: out,
|
|
535
|
+
stderr: err
|
|
536
|
+
});
|
|
537
|
+
});
|
|
538
|
+
channel.on("data", (chunk) => {
|
|
539
|
+
out = appendCapped(out, chunk.toString("utf8"));
|
|
540
|
+
if (pgid !== void 0 || !markerPending) return;
|
|
541
|
+
const marker = takePgid(out);
|
|
542
|
+
if (marker.pgid !== void 0) {
|
|
543
|
+
pgid = marker.pgid;
|
|
544
|
+
out = marker.out;
|
|
545
|
+
markerPending = false;
|
|
546
|
+
if (timedOut) terminateGroup();
|
|
547
|
+
} else if (out.includes("\n")) markerPending = false;
|
|
548
|
+
});
|
|
549
|
+
channel.stderr.on("data", (chunk) => {
|
|
550
|
+
err = appendCapped(err, chunk.toString("utf8"));
|
|
551
|
+
});
|
|
552
|
+
});
|
|
553
|
+
});
|
|
554
|
+
} catch (error) {
|
|
555
|
+
this.release(alias);
|
|
556
|
+
return {
|
|
557
|
+
success: false,
|
|
558
|
+
exitCode: null,
|
|
559
|
+
timedOut: false,
|
|
560
|
+
stdout: "",
|
|
561
|
+
stderr: "",
|
|
562
|
+
durationMs: Date.now() - started,
|
|
563
|
+
error: error instanceof Error ? error.message : String(error)
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
/** Open an interactive PTY shell. The caller owns the channel lifecycle. */
|
|
568
|
+
async shell(alias, opts = {}) {
|
|
569
|
+
const conn = await this.acquire(alias);
|
|
570
|
+
this.hold(alias);
|
|
571
|
+
try {
|
|
572
|
+
const channel = await new Promise((resolve, reject) => {
|
|
573
|
+
conn.shell({
|
|
574
|
+
cols: opts.cols ?? 80,
|
|
575
|
+
rows: opts.rows ?? 24,
|
|
576
|
+
term: "xterm-256color"
|
|
577
|
+
}, (error, stream) => {
|
|
578
|
+
if (error !== void 0 && error !== null) reject(error);
|
|
579
|
+
else resolve(stream);
|
|
580
|
+
});
|
|
581
|
+
});
|
|
582
|
+
channel.once("close", () => this.release(alias));
|
|
583
|
+
return channel;
|
|
584
|
+
} catch (error) {
|
|
585
|
+
this.release(alias);
|
|
586
|
+
throw error;
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
/** Open one exec channel. The caller owns the channel lifecycle. */
|
|
590
|
+
async execChannel(alias, command) {
|
|
591
|
+
const conn = await this.acquire(alias);
|
|
592
|
+
this.hold(alias);
|
|
593
|
+
try {
|
|
594
|
+
const channel = await new Promise((resolve, reject) => {
|
|
595
|
+
conn.exec(command, (error, stream) => {
|
|
596
|
+
if (error !== void 0 && error !== null) reject(error);
|
|
597
|
+
else resolve(stream);
|
|
598
|
+
});
|
|
599
|
+
});
|
|
600
|
+
channel.once("close", () => this.release(alias));
|
|
601
|
+
return channel;
|
|
602
|
+
} catch (error) {
|
|
603
|
+
this.release(alias);
|
|
604
|
+
throw error;
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
/** Run one callback with a fresh SFTP session (closed afterwards). */
|
|
608
|
+
async withSftp(alias, fn, opts = {}) {
|
|
609
|
+
await this.acquireSlot(alias);
|
|
610
|
+
let conn;
|
|
611
|
+
try {
|
|
612
|
+
conn = await this.acquire(alias);
|
|
613
|
+
} catch (error) {
|
|
614
|
+
this.releaseSlot(alias);
|
|
615
|
+
throw error;
|
|
616
|
+
}
|
|
617
|
+
this.hold(alias);
|
|
618
|
+
try {
|
|
619
|
+
const sftp = await withDeadline(new Promise((resolve, reject) => {
|
|
620
|
+
conn.sftp((error, session) => {
|
|
621
|
+
if (error !== void 0 && error !== null) reject(error);
|
|
622
|
+
else resolve(session);
|
|
623
|
+
});
|
|
624
|
+
}), SFTP_OP_TIMEOUT_MS, `SFTP session open timed out after ${SFTP_OP_TIMEOUT_MS / 1e3}s`);
|
|
625
|
+
try {
|
|
626
|
+
const limit = opts.timeoutMs ?? SFTP_OP_TIMEOUT_MS;
|
|
627
|
+
if (limit <= 0) return await fn(sftp);
|
|
628
|
+
return await withDeadline(fn(sftp), limit, `SFTP operation timed out after ${limit / 1e3}s`);
|
|
629
|
+
} finally {
|
|
630
|
+
try {
|
|
631
|
+
sftp.end();
|
|
632
|
+
} catch {}
|
|
633
|
+
}
|
|
634
|
+
} finally {
|
|
635
|
+
this.release(alias);
|
|
636
|
+
this.releaseSlot(alias);
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
/** Upload one local file to an absolute remote path (parallel fastPut). */
|
|
640
|
+
async putFile(alias, localPath, remotePath) {
|
|
641
|
+
await this.withSftp(alias, async (sftp) => {
|
|
642
|
+
await new Promise((resolve, reject) => {
|
|
643
|
+
let settled = false;
|
|
644
|
+
let timer;
|
|
645
|
+
const finish = (error) => {
|
|
646
|
+
if (settled) return;
|
|
647
|
+
settled = true;
|
|
648
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
649
|
+
if (error === void 0) resolve();
|
|
650
|
+
else reject(error);
|
|
651
|
+
};
|
|
652
|
+
const arm = () => {
|
|
653
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
654
|
+
timer = setTimeout(() => {
|
|
655
|
+
try {
|
|
656
|
+
sftp.end();
|
|
657
|
+
} catch {}
|
|
658
|
+
finish(/* @__PURE__ */ new Error(`SFTP upload stalled: no progress for ${UPLOAD_IDLE_MS / 1e3}s`));
|
|
659
|
+
}, UPLOAD_IDLE_MS);
|
|
660
|
+
timer.unref?.();
|
|
661
|
+
};
|
|
662
|
+
arm();
|
|
663
|
+
sftp.fastPut(localPath, remotePath, { step: () => {
|
|
664
|
+
arm();
|
|
665
|
+
} }, (error) => {
|
|
666
|
+
finish(error === void 0 || error === null ? void 0 : new Error(error.message));
|
|
667
|
+
});
|
|
668
|
+
});
|
|
669
|
+
}, { timeoutMs: 0 });
|
|
670
|
+
}
|
|
671
|
+
/**
|
|
672
|
+
* Take one channel slot for `alias`, waiting FIFO when the limit is reached.
|
|
673
|
+
* The wait is bounded: waiting forever behind a peer that never finishes is
|
|
674
|
+
* worse than a readable error naming the cause and what to do about it.
|
|
675
|
+
*/
|
|
676
|
+
async acquireSlot(alias) {
|
|
677
|
+
const state = this.slotState(alias);
|
|
678
|
+
if (state.active < CHANNEL_LIMIT) {
|
|
679
|
+
state.active += 1;
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
await new Promise((resolve, reject) => {
|
|
683
|
+
const waiter = {
|
|
684
|
+
granted: false,
|
|
685
|
+
resolve,
|
|
686
|
+
reject,
|
|
687
|
+
timer: void 0
|
|
688
|
+
};
|
|
689
|
+
waiter.timer = setTimeout(() => {
|
|
690
|
+
if (waiter.granted) return;
|
|
691
|
+
waiter.granted = true;
|
|
692
|
+
const index = state.queue.indexOf(waiter);
|
|
693
|
+
if (index >= 0) state.queue.splice(index, 1);
|
|
694
|
+
reject(/* @__PURE__ */ new Error(`remote host ${alias} is busy: ${CHANNEL_LIMIT} operations already hold channels (this one waited ${CHANNEL_WAIT_MS / 1e3}s — retry once the earlier work finishes)`));
|
|
695
|
+
}, CHANNEL_WAIT_MS);
|
|
696
|
+
waiter.timer.unref?.();
|
|
697
|
+
state.queue.push(waiter);
|
|
698
|
+
});
|
|
699
|
+
}
|
|
700
|
+
/** Give a slot back, handing it straight to the longest-waiting operation. */
|
|
701
|
+
releaseSlot(alias) {
|
|
702
|
+
const state = this.slots.get(alias);
|
|
703
|
+
if (state === void 0) return;
|
|
704
|
+
const waiter = state.queue.shift();
|
|
705
|
+
if (waiter !== void 0) {
|
|
706
|
+
if (waiter.timer !== void 0) clearTimeout(waiter.timer);
|
|
707
|
+
waiter.granted = true;
|
|
708
|
+
waiter.resolve();
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
if (state.active > 0) state.active -= 1;
|
|
712
|
+
if (state.active === 0) this.slots.delete(alias);
|
|
713
|
+
}
|
|
714
|
+
slotState(alias) {
|
|
715
|
+
const existing = this.slots.get(alias);
|
|
716
|
+
if (existing !== void 0) return existing;
|
|
717
|
+
const created = {
|
|
718
|
+
active: 0,
|
|
719
|
+
queue: []
|
|
720
|
+
};
|
|
721
|
+
this.slots.set(alias, created);
|
|
722
|
+
return created;
|
|
723
|
+
}
|
|
724
|
+
/**
|
|
725
|
+
* Whether this alias offers a util-linux-style `setsid --wait` — the only
|
|
726
|
+
* shape that both detaches the command into its own session AND reports its
|
|
727
|
+
* exit code (p2/ssh-proc.ts:7-21 learned that plain `setsid` double-forks and
|
|
728
|
+
* the channel then reports the wrapper's exit 0, which would turn every exit
|
|
729
|
+
* fact into fiction). The probe also verifies status propagation, so a broken
|
|
730
|
+
* setsid fails it instead of quietly corrupting exit codes.
|
|
731
|
+
*
|
|
732
|
+
* Probed once per alias: the probe is a whole extra channel, and exec is the
|
|
733
|
+
* hot path (SCM polling, search, agent tools). A remote without a compatible
|
|
734
|
+
* setsid keeps the pre-existing behaviour, where a timeout can only close the
|
|
735
|
+
* channel, and says so in the log.
|
|
736
|
+
*
|
|
737
|
+
* Only SUCCESS is remembered. A failed probe means "this host has no
|
|
738
|
+
* setsid OR this one call did not get through", and folding the second case
|
|
739
|
+
* into the first would be permanent: a single transient blip — a dropped
|
|
740
|
+
* connection, a 15 s probe timeout on a slow link — would silently switch the
|
|
741
|
+
* alias to the unwrapped path for the rest of the process, i.e. quietly
|
|
742
|
+
* disable the kill this wrapper exists to perform. Every other backoff in
|
|
743
|
+
* this codebase retries rather than blacklisting; so does this one now.
|
|
744
|
+
*/
|
|
745
|
+
supportsSetsid(alias) {
|
|
746
|
+
const cached = this.setsidProbes.get(alias);
|
|
747
|
+
if (cached !== void 0) return cached;
|
|
748
|
+
const probe = this.exec(alias, SETSID_PROBE, {
|
|
749
|
+
timeoutMs: CONTROL_TIMEOUT_MS,
|
|
750
|
+
bare: true
|
|
751
|
+
}).then((result) => result.stdout.includes(SETSID_PROBE_MARK), () => false);
|
|
752
|
+
this.setsidProbes.set(alias, probe);
|
|
753
|
+
probe.then((supported) => {
|
|
754
|
+
if (!supported && this.setsidProbes.get(alias) === probe) this.setsidProbes.delete(alias);
|
|
755
|
+
});
|
|
756
|
+
return probe;
|
|
757
|
+
}
|
|
758
|
+
/** Hold the connection against idle eviction (balanced by release). */
|
|
759
|
+
hold(alias) {
|
|
760
|
+
const pooled = this.conns.get(alias);
|
|
761
|
+
if (pooled === void 0) return;
|
|
762
|
+
pooled.held += 1;
|
|
763
|
+
pooled.lastUse = Date.now();
|
|
764
|
+
if (pooled.timer !== void 0) {
|
|
765
|
+
clearTimeout(pooled.timer);
|
|
766
|
+
pooled.timer = void 0;
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
release(alias) {
|
|
770
|
+
const pooled = this.conns.get(alias);
|
|
771
|
+
if (pooled === void 0) return;
|
|
772
|
+
if (pooled.held > 0) pooled.held -= 1;
|
|
773
|
+
pooled.lastUse = Date.now();
|
|
774
|
+
if (pooled.held > 0) return;
|
|
775
|
+
if (pooled.timer !== void 0) clearTimeout(pooled.timer);
|
|
776
|
+
pooled.timer = setTimeout(() => this.drop(alias), IDLE_MS);
|
|
777
|
+
if (typeof pooled.timer === "object" && "unref" in pooled.timer) pooled.timer.unref();
|
|
778
|
+
}
|
|
779
|
+
async acquire(alias) {
|
|
780
|
+
const pooled = this.conns.get(alias);
|
|
781
|
+
if (pooled !== void 0 && pooled.connecting !== void 0) return pooled.connecting;
|
|
782
|
+
if (pooled !== void 0) {
|
|
783
|
+
pooled.lastUse = Date.now();
|
|
784
|
+
return pooled.conn;
|
|
785
|
+
}
|
|
786
|
+
const entry = {
|
|
787
|
+
conn: void 0,
|
|
788
|
+
timer: void 0,
|
|
789
|
+
lastUse: Date.now(),
|
|
790
|
+
connecting: void 0,
|
|
791
|
+
held: 0
|
|
792
|
+
};
|
|
793
|
+
this.conns.set(alias, entry);
|
|
794
|
+
entry.connecting = this.dial(alias).then((conn) => {
|
|
795
|
+
entry.conn = conn;
|
|
796
|
+
entry.connecting = void 0;
|
|
797
|
+
conn.on("close", () => {
|
|
798
|
+
if (this.conns.get(alias) === entry) this.drop(alias);
|
|
799
|
+
});
|
|
800
|
+
conn.on("error", () => {
|
|
801
|
+
if (this.conns.get(alias) === entry) this.drop(alias);
|
|
802
|
+
});
|
|
803
|
+
this.release(alias);
|
|
804
|
+
return conn;
|
|
805
|
+
}, (error) => {
|
|
806
|
+
this.conns.delete(alias);
|
|
807
|
+
throw error;
|
|
808
|
+
});
|
|
809
|
+
return entry.connecting;
|
|
810
|
+
}
|
|
811
|
+
dial(alias) {
|
|
812
|
+
const host = this.store.require(alias);
|
|
813
|
+
return new Promise((resolve, reject) => {
|
|
814
|
+
const conn = new import_lib.Client();
|
|
815
|
+
let settled = false;
|
|
816
|
+
const fail = (error) => {
|
|
817
|
+
if (settled) return;
|
|
818
|
+
settled = true;
|
|
819
|
+
try {
|
|
820
|
+
conn.end();
|
|
821
|
+
} catch {}
|
|
822
|
+
reject(explainAuthError(host, error));
|
|
823
|
+
};
|
|
824
|
+
conn.on("ready", () => {
|
|
825
|
+
if (settled) return;
|
|
826
|
+
settled = true;
|
|
827
|
+
resolve(conn);
|
|
828
|
+
});
|
|
829
|
+
conn.on("error", (error) => fail(error));
|
|
830
|
+
conn.on("keyboard-interactive", (_name, _instructions, _lang, prompts, finish) => {
|
|
831
|
+
const password = host.auth.kind === "password" ? host.auth.password : void 0;
|
|
832
|
+
if (password !== void 0 && prompts.length === 1 && prompts[0]?.echo === false) {
|
|
833
|
+
try {
|
|
834
|
+
finish([password]);
|
|
835
|
+
} catch {}
|
|
836
|
+
return;
|
|
837
|
+
}
|
|
838
|
+
fail(/* @__PURE__ */ new Error("server requested keyboard-interactive auth (unsupported shape; use key auth or a password challenge)"));
|
|
839
|
+
try {
|
|
840
|
+
finish([]);
|
|
841
|
+
} catch {}
|
|
842
|
+
});
|
|
843
|
+
const hostVerifier = (key) => {
|
|
844
|
+
const fingerprint = createHash("sha256").update(key).digest("base64");
|
|
845
|
+
const known = this.hostKeys.get(alias);
|
|
846
|
+
if (known === void 0) this.hostKeys.set(alias, fingerprint);
|
|
847
|
+
else if (known !== fingerprint) console.warn(`[dsh-remote-vs] host key changed for ${alias}: was ${known}, now ${fingerprint}`);
|
|
848
|
+
return true;
|
|
849
|
+
};
|
|
850
|
+
try {
|
|
851
|
+
if (host.auth.kind === "key") {
|
|
852
|
+
const keyPath = host.auth.keyPath;
|
|
853
|
+
conn.connect({
|
|
854
|
+
host: host.host,
|
|
855
|
+
port: host.port,
|
|
856
|
+
username: host.user,
|
|
857
|
+
privateKey: readFileSync(keyPath),
|
|
858
|
+
passphrase: host.auth.passphrase,
|
|
859
|
+
readyTimeout: READY_TIMEOUT_MS,
|
|
860
|
+
keepaliveInterval: 3e4,
|
|
861
|
+
keepaliveCountMax: 3,
|
|
862
|
+
hostVerifier
|
|
863
|
+
});
|
|
864
|
+
} else conn.connect({
|
|
865
|
+
host: host.host,
|
|
866
|
+
port: host.port,
|
|
867
|
+
username: host.user,
|
|
868
|
+
password: host.auth.password,
|
|
869
|
+
tryKeyboard: true,
|
|
870
|
+
readyTimeout: READY_TIMEOUT_MS,
|
|
871
|
+
keepaliveInterval: 3e4,
|
|
872
|
+
keepaliveCountMax: 3,
|
|
873
|
+
hostVerifier
|
|
874
|
+
});
|
|
875
|
+
} catch (error) {
|
|
876
|
+
fail(error instanceof Error ? error : new Error(String(error)));
|
|
877
|
+
}
|
|
878
|
+
});
|
|
879
|
+
}
|
|
880
|
+
};
|
|
881
|
+
/**
|
|
882
|
+
* Translate ssh2's generic auth failure into something actionable. Never
|
|
883
|
+
* includes secrets — only the entry shape and the likely server-side causes.
|
|
884
|
+
*/
|
|
885
|
+
function explainAuthError(host, error) {
|
|
886
|
+
if (host.auth.kind !== "password" || !/authentication methods failed/i.test(error.message)) return error;
|
|
887
|
+
const hints = ["verify the password (it may have changed/expired)", "many servers only offer keyboard-interactive (now auto-answered — retry the test)"];
|
|
888
|
+
if (host.user === "root") hints.push("OpenSSH 7+ defaults prohibit password login for root (PermitRootLogin); use a non-root user or switch to key auth");
|
|
889
|
+
return /* @__PURE__ */ new Error(`${error.message} — password rejected by the server; ${hints.join("; ")}`);
|
|
890
|
+
}
|
|
891
|
+
/** Append with a 2 MiB cap and a truncation marker. */
|
|
892
|
+
function appendCapped(current, chunk) {
|
|
893
|
+
if (current.length >= OUTPUT_CAP) return current;
|
|
894
|
+
const next = current + chunk;
|
|
895
|
+
if (next.length <= OUTPUT_CAP) return next;
|
|
896
|
+
return next.slice(0, OUTPUT_CAP) + TRUNCATED_MARKER;
|
|
897
|
+
}
|
|
898
|
+
/**
|
|
899
|
+
* Put one shell line in its own session/process group.
|
|
900
|
+
*
|
|
901
|
+
* `setsid --wait` is what makes the timeout able to end the command: the
|
|
902
|
+
* wrapper echoes its own pid, which IS the new session's process-group id, and
|
|
903
|
+
* this pid is what `kill -- -<pgid>` needs. Everything the command spawns stays
|
|
904
|
+
* in that group, so a pipeline's whole tree dies with it — `channel.close()`
|
|
905
|
+
* only releases our side, and `channel.signal()` addresses the outer shell.
|
|
906
|
+
*
|
|
907
|
+
* `$0` is the login shell sshd itself used for this line, so a bashism in a
|
|
908
|
+
* caller's line keeps working on a remote whose /bin/sh is dash (a leading dash
|
|
909
|
+
* — login-shell argv[0] — is not an executable, hence the fallback to `sh`).
|
|
910
|
+
* The line travels as a positional argument rather than being re-quoted into
|
|
911
|
+
* the wrapper, so quoting and `$` expansion stay exactly as they were.
|
|
912
|
+
*/
|
|
913
|
+
function sessionLine(command) {
|
|
914
|
+
return `rv_shell="\${0:-sh}"; case "\$rv_shell" in -*) rv_shell=sh;; esac; exec ${`setsid --wait "$rv_shell" -c 'echo ${PGID_MARKER}$$; exec "$0" -c "$1"' "$rv_shell" ${shq$1(command)}`}`;
|
|
915
|
+
}
|
|
916
|
+
/** Single-quote one word for POSIX sh (same quoting p2/ssh-proc.ts uses). */
|
|
917
|
+
function shq$1(word) {
|
|
918
|
+
return `'${word.replaceAll("'", `'\\''`)}'`;
|
|
919
|
+
}
|
|
920
|
+
/**
|
|
921
|
+
* Consume the session wrapper's leading `RV_PID=<pgid>` line. The wrapper
|
|
922
|
+
* writes it before it execs the target, so it is the first line of the stream
|
|
923
|
+
* by construction; anything else is left untouched, which keeps a command that
|
|
924
|
+
* happens to print a similar first line intact.
|
|
925
|
+
*/
|
|
926
|
+
function takePgid(out) {
|
|
927
|
+
const newline = out.indexOf("\n");
|
|
928
|
+
if (newline < 0) return {
|
|
929
|
+
pgid: void 0,
|
|
930
|
+
out
|
|
931
|
+
};
|
|
932
|
+
const line = out.slice(0, newline).replace(/\r$/, "");
|
|
933
|
+
if (!line.startsWith(PGID_MARKER)) return {
|
|
934
|
+
pgid: void 0,
|
|
935
|
+
out
|
|
936
|
+
};
|
|
937
|
+
const digits = line.slice(7);
|
|
938
|
+
if (!/^\d+$/.test(digits)) return {
|
|
939
|
+
pgid: void 0,
|
|
940
|
+
out
|
|
941
|
+
};
|
|
942
|
+
return {
|
|
943
|
+
pgid: Number(digits),
|
|
944
|
+
out: out.slice(newline + 1)
|
|
945
|
+
};
|
|
946
|
+
}
|
|
947
|
+
/**
|
|
948
|
+
* A timed-out line whose process group was never learned cannot be killed, so
|
|
949
|
+
* say it out loud instead of pretending the command stopped.
|
|
950
|
+
*/
|
|
951
|
+
function warnDetached(alias) {
|
|
952
|
+
console.warn(`[dsh-remote-vs] exec timed out on ${alias}: the channel is closed but the remote command may still be running (no killable process group)`);
|
|
953
|
+
}
|
|
954
|
+
/**
|
|
955
|
+
* Reject when `promise` has not settled within `ms`, so a stalled remote
|
|
956
|
+
* callback cannot keep a session (and its pooled connection) alive forever.
|
|
957
|
+
*/
|
|
958
|
+
function withDeadline(promise, ms, message) {
|
|
959
|
+
return new Promise((resolve, reject) => {
|
|
960
|
+
const timer = setTimeout(() => reject(new Error(message)), ms);
|
|
961
|
+
timer.unref?.();
|
|
962
|
+
promise.then((value) => {
|
|
963
|
+
clearTimeout(timer);
|
|
964
|
+
resolve(value);
|
|
965
|
+
}, (error) => {
|
|
966
|
+
clearTimeout(timer);
|
|
967
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
968
|
+
});
|
|
969
|
+
});
|
|
970
|
+
}
|
|
971
|
+
//#endregion
|
|
972
|
+
//#region src/http.ts
|
|
973
|
+
const JSON_HEADERS = {
|
|
974
|
+
"content-type": "application/json; charset=utf-8",
|
|
975
|
+
"referrer-policy": "no-referrer"
|
|
976
|
+
};
|
|
977
|
+
/**
|
|
978
|
+
* Lenient bounded body reader: parse a request body as JSON, or null on an
|
|
979
|
+
* empty body, invalid JSON, or a body past maxBytes (default 64 KiB).
|
|
980
|
+
*/
|
|
981
|
+
async function readJsonBody(req, opts = {}) {
|
|
982
|
+
const maxBytes = opts.maxBytes ?? 64 * 1024;
|
|
983
|
+
const chunks = [];
|
|
984
|
+
let size = 0;
|
|
985
|
+
for await (const chunk of req) {
|
|
986
|
+
const buffer = chunk;
|
|
987
|
+
size += buffer.length;
|
|
988
|
+
if (size > maxBytes) {
|
|
989
|
+
req.destroy();
|
|
990
|
+
return null;
|
|
991
|
+
}
|
|
992
|
+
chunks.push(buffer);
|
|
993
|
+
}
|
|
994
|
+
const text = Buffer.concat(chunks).toString("utf8");
|
|
995
|
+
if (text === "") return null;
|
|
996
|
+
try {
|
|
997
|
+
const parsed = JSON.parse(text);
|
|
998
|
+
if (opts.objectOnly && !isJsonObject(parsed)) return null;
|
|
999
|
+
return parsed;
|
|
1000
|
+
} catch {
|
|
1001
|
+
return null;
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
function isJsonObject(value) {
|
|
1005
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1006
|
+
}
|
|
1007
|
+
/** Write one JSON response. */
|
|
1008
|
+
function writeJson(res, status, body, headers = {}) {
|
|
1009
|
+
res.writeHead(status, {
|
|
1010
|
+
...JSON_HEADERS,
|
|
1011
|
+
...headers
|
|
1012
|
+
});
|
|
1013
|
+
res.end(JSON.stringify(body));
|
|
1014
|
+
}
|
|
1015
|
+
//#endregion
|
|
1016
|
+
//#region src/loopback.ts
|
|
1017
|
+
function isIPv4Loopback(v4) {
|
|
1018
|
+
const parts = v4.split(".");
|
|
1019
|
+
return parts.length === 4 && parts[0] === "127" && parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255);
|
|
1020
|
+
}
|
|
1021
|
+
function isLoopbackAddress(address) {
|
|
1022
|
+
if (address === void 0) return false;
|
|
1023
|
+
const normalized = address.toLowerCase();
|
|
1024
|
+
if (normalized === "::1") return true;
|
|
1025
|
+
if (normalized.startsWith("::ffff:")) return isIPv4Loopback(normalized.slice(7));
|
|
1026
|
+
return isIPv4Loopback(normalized);
|
|
1027
|
+
}
|
|
1028
|
+
function isLoopbackHostname(hostname) {
|
|
1029
|
+
if (hostname === "localhost" || hostname === "[::1]") return true;
|
|
1030
|
+
return isIPv4Loopback(hostname);
|
|
1031
|
+
}
|
|
1032
|
+
function isLoopbackRequest(request) {
|
|
1033
|
+
if (!isLoopbackAddress(request.socket.remoteAddress)) return false;
|
|
1034
|
+
const host = request.headers.host;
|
|
1035
|
+
if (typeof host !== "string") return false;
|
|
1036
|
+
let hostUrl;
|
|
1037
|
+
try {
|
|
1038
|
+
hostUrl = new URL("http://" + host);
|
|
1039
|
+
} catch {
|
|
1040
|
+
return false;
|
|
1041
|
+
}
|
|
1042
|
+
if (!isLoopbackHostname(hostUrl.hostname)) return false;
|
|
1043
|
+
if (request.headers["sec-fetch-site"] === "cross-site") return false;
|
|
1044
|
+
const origin = request.headers.origin;
|
|
1045
|
+
if (origin === void 0) return true;
|
|
1046
|
+
try {
|
|
1047
|
+
return new URL(origin).host === hostUrl.host;
|
|
1048
|
+
} catch {
|
|
1049
|
+
return false;
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
//#endregion
|
|
1053
|
+
//#region src/protocol.ts
|
|
1054
|
+
/**
|
|
1055
|
+
* Wire contract between the host half (routes.ts) and the browser half
|
|
1056
|
+
* (client/api.ts). Pure types + path constants only — imported by both
|
|
1057
|
+
* halves, bundled into each, no runtime identity to share.
|
|
1058
|
+
*/
|
|
1059
|
+
/** API route table (all loopback-only). */
|
|
1060
|
+
const REMOTE_API = {
|
|
1061
|
+
hosts: "/api/dsh-remote/hosts",
|
|
1062
|
+
test: "/api/dsh-remote/test",
|
|
1063
|
+
keygen: "/api/dsh-remote/keygen",
|
|
1064
|
+
exec: "/api/dsh-remote/exec",
|
|
1065
|
+
files: "/api/dsh-remote/files",
|
|
1066
|
+
/** Streaming binary upload: raw request body, host and path in the query. */
|
|
1067
|
+
upload: "/api/dsh-remote/upload",
|
|
1068
|
+
workspaces: "/api/dsh-remote/workspaces",
|
|
1069
|
+
deploy: "/api/dsh-remote/deploy",
|
|
1070
|
+
importSshConfig: "/api/dsh-remote/import-ssh-config",
|
|
1071
|
+
importDshSsh: "/api/dsh-remote/import-dsh-ssh",
|
|
1072
|
+
terminal: "/api/dsh-remote/terminal",
|
|
1073
|
+
terminalClose: "/api/dsh-remote/terminal-close",
|
|
1074
|
+
routing: "/api/dsh-remote/routing",
|
|
1075
|
+
/** Current working directory of one live session (click resolution helper). */
|
|
1076
|
+
sessionCwd: "/api/dsh-remote/session-cwd",
|
|
1077
|
+
/** What one live session changed on its remote host (the review panel). */
|
|
1078
|
+
changes: "/api/dsh-remote/changes"
|
|
1079
|
+
};
|
|
1080
|
+
//#endregion
|
|
1081
|
+
//#region src/keygen.ts
|
|
1082
|
+
/**
|
|
1083
|
+
* Password -> key bootstrap (Q2 decision): a password host gets a dedicated
|
|
1084
|
+
* ed25519 keypair generated locally (ssh-keygen), the public half is appended
|
|
1085
|
+
* to the remote ~/.ssh/authorized_keys over the password connection, and the
|
|
1086
|
+
* store entry flips to key auth with the managed keyPath.
|
|
1087
|
+
*/
|
|
1088
|
+
const execFileAsync = promisify(execFile);
|
|
1089
|
+
/** Ensure the managed keypair exists; return the private key path. */
|
|
1090
|
+
async function ensureKeypair(alias, dir = keysDir()) {
|
|
1091
|
+
ensurePrivateDir(dir);
|
|
1092
|
+
const privatePath = join(dir, alias);
|
|
1093
|
+
const publicPath = `${privatePath}.pub`;
|
|
1094
|
+
if (!existsSync(privatePath) || !existsSync(publicPath)) {
|
|
1095
|
+
try {
|
|
1096
|
+
await execFileAsync("ssh-keygen", [
|
|
1097
|
+
"-t",
|
|
1098
|
+
"ed25519",
|
|
1099
|
+
"-f",
|
|
1100
|
+
privatePath,
|
|
1101
|
+
"-N",
|
|
1102
|
+
"",
|
|
1103
|
+
"-C",
|
|
1104
|
+
`dsh-remote-vs/${alias}`
|
|
1105
|
+
]);
|
|
1106
|
+
} catch (error) {
|
|
1107
|
+
throw new Error(`ssh-keygen failed (required for key bootstrap): ${error instanceof Error ? error.message : String(error)}`);
|
|
1108
|
+
}
|
|
1109
|
+
try {
|
|
1110
|
+
chmodSync(privatePath, 384);
|
|
1111
|
+
} catch {}
|
|
1112
|
+
}
|
|
1113
|
+
const publicKey = readFileSync(publicPath, "utf8").trim();
|
|
1114
|
+
if (!publicKey.startsWith("ssh-ed25519 ")) throw new Error(`unexpected public key format in ${publicPath}`);
|
|
1115
|
+
return {
|
|
1116
|
+
privatePath,
|
|
1117
|
+
publicKey
|
|
1118
|
+
};
|
|
1119
|
+
}
|
|
1120
|
+
/**
|
|
1121
|
+
* Bootstrap one host: install the managed public key remotely, then flip the
|
|
1122
|
+
* entry to key auth and drop the pooled password connection.
|
|
1123
|
+
*/
|
|
1124
|
+
async function bootstrap(pool, store, alias) {
|
|
1125
|
+
const entry = store.require(alias);
|
|
1126
|
+
if (entry.auth.kind !== "password") return {
|
|
1127
|
+
alias,
|
|
1128
|
+
keyPath: entry.auth.keyPath ?? "",
|
|
1129
|
+
flipped: false,
|
|
1130
|
+
detail: "host already uses key auth"
|
|
1131
|
+
};
|
|
1132
|
+
const { privatePath, publicKey } = await ensureKeypair(alias);
|
|
1133
|
+
const script = [
|
|
1134
|
+
"mkdir -p ~/.ssh && chmod 700 ~/.ssh",
|
|
1135
|
+
`touch ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys`,
|
|
1136
|
+
`grep -qxF '${publicKey}' ~/.ssh/authorized_keys 2>/dev/null || echo '${publicKey}' >> ~/.ssh/authorized_keys`
|
|
1137
|
+
].join(" && ");
|
|
1138
|
+
const result = await pool.exec(alias, script, { timeoutMs: 3e4 });
|
|
1139
|
+
if (!result.success) throw new Error(`key install failed [exit code: ${result.exitCode}]: ${result.stderr || result.stdout || result.error || "unknown"}`);
|
|
1140
|
+
store.update(alias, { auth: {
|
|
1141
|
+
kind: "key",
|
|
1142
|
+
keyPath: privatePath
|
|
1143
|
+
} });
|
|
1144
|
+
pool.drop(alias);
|
|
1145
|
+
const check = await pool.test(alias);
|
|
1146
|
+
if (!check.ok) throw new Error(`key installed but key login failed: ${check.error} (password entry was already flipped; fix the key or re-enter the password)`);
|
|
1147
|
+
return {
|
|
1148
|
+
alias,
|
|
1149
|
+
keyPath: privatePath,
|
|
1150
|
+
flipped: true,
|
|
1151
|
+
detail: "public key installed; entry now uses key auth"
|
|
1152
|
+
};
|
|
1153
|
+
}
|
|
1154
|
+
//#endregion
|
|
1155
|
+
//#region src/importers.ts
|
|
1156
|
+
/**
|
|
1157
|
+
* One-shot importers: ~/.ssh/config and the existing dsh-ssh.json. Both only
|
|
1158
|
+
* READ the source and produce payloads for our own store — no runtime coupling.
|
|
1159
|
+
* P0 supports direct key/password hosts only; jump/proxy/agent entries are
|
|
1160
|
+
* reported as skipped with reasons.
|
|
1161
|
+
*/
|
|
1162
|
+
/** Expand a leading ~ to the user home. */
|
|
1163
|
+
function expandHome(path) {
|
|
1164
|
+
if (path === "~") return homedir();
|
|
1165
|
+
if (path.startsWith("~/") || path.startsWith("~\\")) return join(homedir(), path.slice(2));
|
|
1166
|
+
return path;
|
|
1167
|
+
}
|
|
1168
|
+
/** Parse OpenSSH client config text into host payloads. */
|
|
1169
|
+
function parseSshConfig(text) {
|
|
1170
|
+
const payloads = [];
|
|
1171
|
+
const skipped = [];
|
|
1172
|
+
const blocks = [];
|
|
1173
|
+
let current;
|
|
1174
|
+
for (const rawLine of text.split("\n")) {
|
|
1175
|
+
const line = rawLine.trim();
|
|
1176
|
+
if (line === "" || line.startsWith("#")) continue;
|
|
1177
|
+
const match = /^(\S+)\s+(.*)$/.exec(line);
|
|
1178
|
+
if (match === null) continue;
|
|
1179
|
+
const key = match[1].toLowerCase();
|
|
1180
|
+
let value = match[2].trim().replace(/^"(.*)"$/, "$1");
|
|
1181
|
+
const hash = value.indexOf(" #");
|
|
1182
|
+
if (hash >= 0) value = value.slice(0, hash).trim();
|
|
1183
|
+
if (key === "host") {
|
|
1184
|
+
current = {
|
|
1185
|
+
patterns: value.split(/\s+/),
|
|
1186
|
+
fields: /* @__PURE__ */ new Map()
|
|
1187
|
+
};
|
|
1188
|
+
blocks.push(current);
|
|
1189
|
+
} else if (key === "match") {
|
|
1190
|
+
current = void 0;
|
|
1191
|
+
skipped.push({
|
|
1192
|
+
alias: "(match)",
|
|
1193
|
+
reason: "Match conditional blocks are not imported"
|
|
1194
|
+
});
|
|
1195
|
+
} else if (current !== void 0 && !current.fields.has(key)) current.fields.set(key, value);
|
|
1196
|
+
}
|
|
1197
|
+
for (const block of blocks) {
|
|
1198
|
+
const alias = block.patterns[0];
|
|
1199
|
+
if (alias === void 0 || alias.includes("*") || alias.includes("?") || alias.includes("!")) {
|
|
1200
|
+
skipped.push({
|
|
1201
|
+
alias: block.patterns.join(" ") || "(empty)",
|
|
1202
|
+
reason: "wildcard patterns are not imported"
|
|
1203
|
+
});
|
|
1204
|
+
continue;
|
|
1205
|
+
}
|
|
1206
|
+
if (block.fields.has("proxyjump") || block.fields.has("proxycommand")) {
|
|
1207
|
+
skipped.push({
|
|
1208
|
+
alias,
|
|
1209
|
+
reason: "proxyJump/proxyCommand needs P1 (direct hosts only in P0)"
|
|
1210
|
+
});
|
|
1211
|
+
continue;
|
|
1212
|
+
}
|
|
1213
|
+
const identityFile = block.fields.get("identityfile")?.split(/\s+/)[0];
|
|
1214
|
+
payloads.push({
|
|
1215
|
+
alias,
|
|
1216
|
+
host: block.fields.get("hostname") ?? alias,
|
|
1217
|
+
port: block.fields.get("port") !== void 0 ? Number(block.fields.get("port")) : 22,
|
|
1218
|
+
user: block.fields.get("user") ?? "",
|
|
1219
|
+
auth: identityFile !== void 0 ? {
|
|
1220
|
+
kind: "key",
|
|
1221
|
+
keyPath: expandHome(identityFile)
|
|
1222
|
+
} : {
|
|
1223
|
+
kind: "password",
|
|
1224
|
+
password: ""
|
|
1225
|
+
},
|
|
1226
|
+
tags: ["imported:ssh-config"]
|
|
1227
|
+
});
|
|
1228
|
+
}
|
|
1229
|
+
return {
|
|
1230
|
+
payloads,
|
|
1231
|
+
skipped
|
|
1232
|
+
};
|
|
1233
|
+
}
|
|
1234
|
+
/** Read the default ~/.ssh/config (throws when missing). */
|
|
1235
|
+
function readDefaultSshConfig() {
|
|
1236
|
+
const file = join(homedir(), ".ssh", "config");
|
|
1237
|
+
if (!existsSync(file)) throw new Error(`no such file: ${file}`);
|
|
1238
|
+
return readFileSync(file, "utf8");
|
|
1239
|
+
}
|
|
1240
|
+
/** Migrate entries from the third-party dsh-ssh.json (one-shot copy). */
|
|
1241
|
+
function migrateDshSsh(home = dshHome()) {
|
|
1242
|
+
const file = join(home, "dsh-ssh.json");
|
|
1243
|
+
if (!existsSync(file)) throw new Error(`no such file: ${file} (nothing to import)`);
|
|
1244
|
+
const doc = JSON.parse(readFileSync(file, "utf8"));
|
|
1245
|
+
if (!Array.isArray(doc.hosts)) throw new Error(`${file}: hosts is not an array`);
|
|
1246
|
+
const payloads = [];
|
|
1247
|
+
const skipped = [];
|
|
1248
|
+
for (const raw of doc.hosts) {
|
|
1249
|
+
const alias = typeof raw.alias === "string" ? raw.alias : "(unknown)";
|
|
1250
|
+
const auth = raw.auth ?? {};
|
|
1251
|
+
const kind = auth.kind;
|
|
1252
|
+
const proxyJump = raw.proxyJump ?? [];
|
|
1253
|
+
const proxyCommand = raw.proxyCommand;
|
|
1254
|
+
if (kind !== "key" && kind !== "password") {
|
|
1255
|
+
skipped.push({
|
|
1256
|
+
alias,
|
|
1257
|
+
reason: `auth kind '${String(kind)}' needs P1 (key/password only in P0)`
|
|
1258
|
+
});
|
|
1259
|
+
continue;
|
|
1260
|
+
}
|
|
1261
|
+
if (proxyJump.length > 0 || typeof proxyCommand === "string" && proxyCommand !== "") {
|
|
1262
|
+
skipped.push({
|
|
1263
|
+
alias,
|
|
1264
|
+
reason: "proxyJump/proxyCommand needs P1 (direct hosts only in P0)"
|
|
1265
|
+
});
|
|
1266
|
+
continue;
|
|
1267
|
+
}
|
|
1268
|
+
if (kind === "key" && typeof auth.keyPath !== "string") {
|
|
1269
|
+
skipped.push({
|
|
1270
|
+
alias,
|
|
1271
|
+
reason: "key auth without keyPath"
|
|
1272
|
+
});
|
|
1273
|
+
continue;
|
|
1274
|
+
}
|
|
1275
|
+
if (kind === "password" && typeof auth.password !== "string") {
|
|
1276
|
+
skipped.push({
|
|
1277
|
+
alias,
|
|
1278
|
+
reason: "password auth without stored password (enter it manually)"
|
|
1279
|
+
});
|
|
1280
|
+
continue;
|
|
1281
|
+
}
|
|
1282
|
+
payloads.push({
|
|
1283
|
+
alias,
|
|
1284
|
+
host: typeof raw.host === "string" ? raw.host : "",
|
|
1285
|
+
port: typeof raw.port === "number" ? raw.port : 22,
|
|
1286
|
+
user: typeof raw.user === "string" ? raw.user : "",
|
|
1287
|
+
auth: kind === "key" ? {
|
|
1288
|
+
kind: "key",
|
|
1289
|
+
keyPath: auth.keyPath,
|
|
1290
|
+
...typeof auth.passphrase === "string" ? { passphrase: auth.passphrase } : {}
|
|
1291
|
+
} : {
|
|
1292
|
+
kind: "password",
|
|
1293
|
+
password: auth.password
|
|
1294
|
+
},
|
|
1295
|
+
...typeof raw.description === "string" && raw.description !== "" ? { description: raw.description } : {},
|
|
1296
|
+
tags: ["imported:dsh-ssh"]
|
|
1297
|
+
});
|
|
1298
|
+
}
|
|
1299
|
+
return {
|
|
1300
|
+
payloads,
|
|
1301
|
+
skipped
|
|
1302
|
+
};
|
|
1303
|
+
}
|
|
1304
|
+
//#endregion
|
|
1305
|
+
//#region src/terminal.ts
|
|
1306
|
+
/**
|
|
1307
|
+
* Remote PTY terminal sessions: one ssh2 shell per session, surviving socket
|
|
1308
|
+
* detaches (reattach by id). Frames are JSON text (see protocol.ts).
|
|
1309
|
+
*/
|
|
1310
|
+
/**
|
|
1311
|
+
* A session with no attached socket is kept this long so that a page reload
|
|
1312
|
+
* can reattach by id. Past it the session closes and the remote shell dies.
|
|
1313
|
+
*
|
|
1314
|
+
* Without this sweep nothing ever released the session: its `pool.hold` never
|
|
1315
|
+
* returned to zero, so the connection could not idle out, and the remote sshd
|
|
1316
|
+
* session slot stayed taken for the life of the host process. Ten minutes is
|
|
1317
|
+
* far longer than a reload needs and far shorter than "until you restart DSH".
|
|
1318
|
+
*/
|
|
1319
|
+
const EMPTY_SOCKET_TTL_MS = 10 * 6e4;
|
|
1320
|
+
/**
|
|
1321
|
+
* Terminal output is a live stream with no natural end, so it needs a bound.
|
|
1322
|
+
* When a socket falls this far behind, pause the channel — that pushes the
|
|
1323
|
+
* brake all the way back to the remote command, instead of growing the host
|
|
1324
|
+
* process. Resume once the socket has drained.
|
|
1325
|
+
*
|
|
1326
|
+
* The pause is real backpressure, not a local buffer: ssh2 only sends a window
|
|
1327
|
+
* adjustment while the stream is flowing, so a paused channel lets the peer's
|
|
1328
|
+
* send window drain to zero and the remote command blocks on its own write.
|
|
1329
|
+
* The retained ceiling is therefore this mark plus ssh2's window (2 MiB) plus
|
|
1330
|
+
* whatever is in flight — bounded, but not exactly 4 MiB.
|
|
1331
|
+
*
|
|
1332
|
+
* The counter is per session, not per socket: one lagging socket pauses the
|
|
1333
|
+
* channel for every socket attached to that shell. That is deliberate — the
|
|
1334
|
+
* alternative is letting the slowest consumer grow the host process.
|
|
1335
|
+
*/
|
|
1336
|
+
const OUT_HIGH_WATER = 4 * 1024 * 1024;
|
|
1337
|
+
const OUT_LOW_WATER = 1 * 1024 * 1024;
|
|
1338
|
+
/**
|
|
1339
|
+
* The largest client frame accepted, in bytes.
|
|
1340
|
+
*
|
|
1341
|
+
* The only frame with a payload is `in` — keystrokes, and pastes, which the
|
|
1342
|
+
* client chunks to stay under this. The upgrade server (`routes.ts`) passes
|
|
1343
|
+
* this value as ws's `maxPayload`, so an oversized frame is refused before it
|
|
1344
|
+
* is buffered at all; the check in `hookSocket` is the second layer, and it is
|
|
1345
|
+
* what keeps such a frame from becoming a string plus an object graph in the
|
|
1346
|
+
* process that also runs the agent.
|
|
1347
|
+
*/
|
|
1348
|
+
const TERMINAL_MAX_FRAME_BYTES = 1024 * 1024;
|
|
1349
|
+
var TerminalSessionRegistry = class {
|
|
1350
|
+
pool;
|
|
1351
|
+
sessions = /* @__PURE__ */ new Map();
|
|
1352
|
+
constructor(pool) {
|
|
1353
|
+
this.pool = pool;
|
|
1354
|
+
}
|
|
1355
|
+
/** Open a shell and attach the first socket. */
|
|
1356
|
+
async open(ws, alias, cols, rows) {
|
|
1357
|
+
const id = randomUUID().slice(0, 8);
|
|
1358
|
+
let channel;
|
|
1359
|
+
try {
|
|
1360
|
+
channel = await this.pool.shell(alias, {
|
|
1361
|
+
cols,
|
|
1362
|
+
rows
|
|
1363
|
+
});
|
|
1364
|
+
} catch (error) {
|
|
1365
|
+
this.send(ws, {
|
|
1366
|
+
t: "error",
|
|
1367
|
+
message: error instanceof Error ? error.message : String(error)
|
|
1368
|
+
});
|
|
1369
|
+
try {
|
|
1370
|
+
ws.close(1011);
|
|
1371
|
+
} catch {}
|
|
1372
|
+
throw error;
|
|
1373
|
+
}
|
|
1374
|
+
const session = {
|
|
1375
|
+
id,
|
|
1376
|
+
alias,
|
|
1377
|
+
channel,
|
|
1378
|
+
alive: true,
|
|
1379
|
+
exitCode: null,
|
|
1380
|
+
sockets: /* @__PURE__ */ new Set(),
|
|
1381
|
+
pending: 0,
|
|
1382
|
+
paused: false,
|
|
1383
|
+
reap: void 0
|
|
1384
|
+
};
|
|
1385
|
+
this.sessions.set(id, session);
|
|
1386
|
+
channel.on("data", (chunk) => {
|
|
1387
|
+
this.broadcast(session, {
|
|
1388
|
+
t: "out",
|
|
1389
|
+
d: chunk.toString("utf8")
|
|
1390
|
+
});
|
|
1391
|
+
});
|
|
1392
|
+
channel.stderr.on("data", (chunk) => {
|
|
1393
|
+
this.broadcast(session, {
|
|
1394
|
+
t: "out",
|
|
1395
|
+
d: chunk.toString("utf8")
|
|
1396
|
+
});
|
|
1397
|
+
});
|
|
1398
|
+
channel.on("close", (code) => {
|
|
1399
|
+
session.alive = false;
|
|
1400
|
+
session.exitCode = code;
|
|
1401
|
+
this.broadcast(session, {
|
|
1402
|
+
t: "exit",
|
|
1403
|
+
code
|
|
1404
|
+
});
|
|
1405
|
+
for (const socket of session.sockets) try {
|
|
1406
|
+
socket.close(1e3);
|
|
1407
|
+
} catch {}
|
|
1408
|
+
});
|
|
1409
|
+
this.hookSocket(session, ws);
|
|
1410
|
+
this.send(ws, {
|
|
1411
|
+
t: "ready",
|
|
1412
|
+
id
|
|
1413
|
+
});
|
|
1414
|
+
return id;
|
|
1415
|
+
}
|
|
1416
|
+
/**
|
|
1417
|
+
* Attach another socket to a live session; false when unknown/dead.
|
|
1418
|
+
*
|
|
1419
|
+
* The size in the URL is deliberately NOT applied here. It is the same
|
|
1420
|
+
* fixed 80x24 the view uses to open a shell, and re-applying it on every
|
|
1421
|
+
* reattach — which is what a page reload does — SHRANK a running shell:
|
|
1422
|
+
* SIGWINCH, and a full-screen program re-laying itself out at 80 columns,
|
|
1423
|
+
* until that tab became visible and refit. The session already has a size.
|
|
1424
|
+
* The socket sends a real `resize` frame as soon as it can measure itself
|
|
1425
|
+
* (`XTerm.pushSize`), which is the only place that knows how wide it is.
|
|
1426
|
+
*
|
|
1427
|
+
* The parameters stay because the upgrade handler owns the URL and passes
|
|
1428
|
+
* them on both paths; they matter to `open` (the PTY's initial size) only.
|
|
1429
|
+
*/
|
|
1430
|
+
attach(ws, id, _cols, _rows) {
|
|
1431
|
+
const session = this.sessions.get(id);
|
|
1432
|
+
if (session === void 0 || !session.alive) return false;
|
|
1433
|
+
this.hookSocket(session, ws);
|
|
1434
|
+
this.send(ws, {
|
|
1435
|
+
t: "ready",
|
|
1436
|
+
id
|
|
1437
|
+
});
|
|
1438
|
+
return true;
|
|
1439
|
+
}
|
|
1440
|
+
/** Close one session (kills the remote shell). */
|
|
1441
|
+
close(id) {
|
|
1442
|
+
const session = this.sessions.get(id);
|
|
1443
|
+
if (session === void 0) return false;
|
|
1444
|
+
this.sessions.delete(id);
|
|
1445
|
+
this.cancelReap(session);
|
|
1446
|
+
try {
|
|
1447
|
+
session.channel.close();
|
|
1448
|
+
} catch {}
|
|
1449
|
+
for (const socket of session.sockets) try {
|
|
1450
|
+
socket.close(1e3);
|
|
1451
|
+
} catch {}
|
|
1452
|
+
session.sockets.clear();
|
|
1453
|
+
return true;
|
|
1454
|
+
}
|
|
1455
|
+
/** Close every session (plugin unload). */
|
|
1456
|
+
dispose() {
|
|
1457
|
+
for (const id of [...this.sessions.keys()]) this.close(id);
|
|
1458
|
+
}
|
|
1459
|
+
list() {
|
|
1460
|
+
return [...this.sessions.values()].map((s) => ({
|
|
1461
|
+
id: s.id,
|
|
1462
|
+
alias: s.alias,
|
|
1463
|
+
alive: s.alive
|
|
1464
|
+
}));
|
|
1465
|
+
}
|
|
1466
|
+
hookSocket(session, ws) {
|
|
1467
|
+
this.cancelReap(session);
|
|
1468
|
+
session.sockets.add(ws);
|
|
1469
|
+
ws.on("message", (raw) => {
|
|
1470
|
+
if ((Array.isArray(raw) ? raw.reduce((total, part) => total + part.byteLength, 0) : raw.byteLength) > 1048576) {
|
|
1471
|
+
try {
|
|
1472
|
+
ws.close(1009);
|
|
1473
|
+
} catch {}
|
|
1474
|
+
return;
|
|
1475
|
+
}
|
|
1476
|
+
let frame;
|
|
1477
|
+
try {
|
|
1478
|
+
frame = JSON.parse(String(raw));
|
|
1479
|
+
} catch {
|
|
1480
|
+
return;
|
|
1481
|
+
}
|
|
1482
|
+
if (!session.alive) return;
|
|
1483
|
+
if (frame.t === "in" && typeof frame.d === "string") try {
|
|
1484
|
+
session.channel.write(frame.d);
|
|
1485
|
+
} catch {}
|
|
1486
|
+
else if (frame.t === "resize" && Number.isFinite(frame.cols) && Number.isFinite(frame.rows)) try {
|
|
1487
|
+
session.channel.setWindow(frame.rows, frame.cols, 0, 0);
|
|
1488
|
+
} catch {}
|
|
1489
|
+
});
|
|
1490
|
+
const detach = () => {
|
|
1491
|
+
session.sockets.delete(ws);
|
|
1492
|
+
if (session.sockets.size > 0) return;
|
|
1493
|
+
session.pending = 0;
|
|
1494
|
+
this.setPaused(session, false);
|
|
1495
|
+
this.armReap(session);
|
|
1496
|
+
};
|
|
1497
|
+
ws.on("close", detach);
|
|
1498
|
+
ws.on("error", detach);
|
|
1499
|
+
}
|
|
1500
|
+
/** Keep the shell for a while (reload can reattach), then close it for good. */
|
|
1501
|
+
armReap(session) {
|
|
1502
|
+
this.cancelReap(session);
|
|
1503
|
+
const timer = setTimeout(() => {
|
|
1504
|
+
session.reap = void 0;
|
|
1505
|
+
if (this.sessions.get(session.id) !== session) return;
|
|
1506
|
+
if (session.sockets.size === 0) this.close(session.id);
|
|
1507
|
+
}, EMPTY_SOCKET_TTL_MS);
|
|
1508
|
+
timer.unref?.();
|
|
1509
|
+
session.reap = timer;
|
|
1510
|
+
}
|
|
1511
|
+
cancelReap(session) {
|
|
1512
|
+
if (session.reap === void 0) return;
|
|
1513
|
+
clearTimeout(session.reap);
|
|
1514
|
+
session.reap = void 0;
|
|
1515
|
+
}
|
|
1516
|
+
setPaused(session, paused) {
|
|
1517
|
+
if (session.paused === paused) return;
|
|
1518
|
+
session.paused = paused;
|
|
1519
|
+
try {
|
|
1520
|
+
if (paused) session.channel.pause();
|
|
1521
|
+
else session.channel.resume();
|
|
1522
|
+
} catch {}
|
|
1523
|
+
}
|
|
1524
|
+
send(ws, frame) {
|
|
1525
|
+
try {
|
|
1526
|
+
ws.send(JSON.stringify(frame));
|
|
1527
|
+
} catch {}
|
|
1528
|
+
}
|
|
1529
|
+
broadcast(session, frame) {
|
|
1530
|
+
const text = JSON.stringify(frame);
|
|
1531
|
+
const size = Buffer.byteLength(text, "utf8");
|
|
1532
|
+
for (const socket of session.sockets) {
|
|
1533
|
+
let settled = false;
|
|
1534
|
+
const done = () => {
|
|
1535
|
+
if (settled) return;
|
|
1536
|
+
settled = true;
|
|
1537
|
+
session.pending = Math.max(0, session.pending - size);
|
|
1538
|
+
if (session.pending <= OUT_LOW_WATER) this.setPaused(session, false);
|
|
1539
|
+
};
|
|
1540
|
+
session.pending += size;
|
|
1541
|
+
try {
|
|
1542
|
+
socket.send(text, done);
|
|
1543
|
+
} catch {
|
|
1544
|
+
done();
|
|
1545
|
+
}
|
|
1546
|
+
}
|
|
1547
|
+
if (session.pending > OUT_HIGH_WATER) this.setPaused(session, true);
|
|
1548
|
+
}
|
|
1549
|
+
};
|
|
1550
|
+
//#endregion
|
|
1551
|
+
//#region src/timeouts.ts
|
|
1552
|
+
/** Smallest accepted timeout: below this, local work would start failing. */
|
|
1553
|
+
const MIN_TIMEOUT_MS = 1e3;
|
|
1554
|
+
/** Largest accepted timeout — the exec route clamps to the same ceiling. */
|
|
1555
|
+
const MAX_TIMEOUT_MS = 6e5;
|
|
1556
|
+
/**
|
|
1557
|
+
* Default for one git command (status/log/branches/diff over exec).
|
|
1558
|
+
*
|
|
1559
|
+
* 120 s was the old internal value and 45 s the old client deadline; both were
|
|
1560
|
+
* routinely exceeded by a cold `git status` on a large monorepo. Five minutes
|
|
1561
|
+
* still fails eventually (a wedged host must not spin forever) but outlasts any
|
|
1562
|
+
* plausible cold index walk.
|
|
1563
|
+
*/
|
|
1564
|
+
const DEFAULT_GIT_TIMEOUT_MS = 3e5;
|
|
1565
|
+
/** Default for one SFTP file read (editor, diff sides, document preview). */
|
|
1566
|
+
const DEFAULT_READ_TIMEOUT_MS = 12e4;
|
|
1567
|
+
/** The pair every half starts from before any Host answer arrives. */
|
|
1568
|
+
const DEFAULT_TIMEOUTS = {
|
|
1569
|
+
gitMs: DEFAULT_GIT_TIMEOUT_MS,
|
|
1570
|
+
readMs: DEFAULT_READ_TIMEOUT_MS
|
|
1571
|
+
};
|
|
1572
|
+
/**
|
|
1573
|
+
* Clamp one untrusted timeout into the accepted range.
|
|
1574
|
+
*
|
|
1575
|
+
* @param value - raw value (plugin config, JSON body, anything).
|
|
1576
|
+
* @param fallback - used when the value is not a usable number.
|
|
1577
|
+
* @returns a whole number of milliseconds within [MIN, MAX].
|
|
1578
|
+
*/
|
|
1579
|
+
function clampTimeout(value, fallback) {
|
|
1580
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
|
|
1581
|
+
const whole = Math.floor(value);
|
|
1582
|
+
if (whole < 1e3) return MIN_TIMEOUT_MS;
|
|
1583
|
+
if (whole > 6e5) return MAX_TIMEOUT_MS;
|
|
1584
|
+
return whole;
|
|
1585
|
+
}
|
|
1586
|
+
/**
|
|
1587
|
+
* Clamp an optional request timeout: absent/invalid stays absent, which is what
|
|
1588
|
+
* the exec route forwards as "use the pool default".
|
|
1589
|
+
*
|
|
1590
|
+
* @param value - raw `timeoutMs` from a request body.
|
|
1591
|
+
* @returns the clamped timeout, or undefined when the caller named none.
|
|
1592
|
+
*/
|
|
1593
|
+
function clampExecTimeout(value) {
|
|
1594
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return void 0;
|
|
1595
|
+
return clampTimeout(value, DEFAULT_GIT_TIMEOUT_MS);
|
|
1596
|
+
}
|
|
1597
|
+
/** Resolve the effective pair from raw config values. */
|
|
1598
|
+
function resolveTimeouts(config) {
|
|
1599
|
+
return {
|
|
1600
|
+
gitMs: clampTimeout(config?.gitTimeoutMs, DEFAULT_GIT_TIMEOUT_MS),
|
|
1601
|
+
readMs: clampTimeout(config?.readTimeoutMs, DEFAULT_READ_TIMEOUT_MS)
|
|
1602
|
+
};
|
|
1603
|
+
}
|
|
1604
|
+
//#endregion
|
|
1605
|
+
//#region src/routes.ts
|
|
1606
|
+
/** One noServer WebSocket server for terminal upgrades (RFC 6455). */
|
|
1607
|
+
const terminalWss = new WebSocketServer({
|
|
1608
|
+
noServer: true,
|
|
1609
|
+
maxPayload: TERMINAL_MAX_FRAME_BYTES
|
|
1610
|
+
});
|
|
1611
|
+
function queryParam(url, name) {
|
|
1612
|
+
const value = url.searchParams.get(name);
|
|
1613
|
+
return value === null ? void 0 : value;
|
|
1614
|
+
}
|
|
1615
|
+
/** This plugin's version from the shipped package.json (Settings display). */
|
|
1616
|
+
let cachedVersion;
|
|
1617
|
+
function pluginVersion() {
|
|
1618
|
+
if (cachedVersion !== void 0) return cachedVersion;
|
|
1619
|
+
try {
|
|
1620
|
+
let dir = dirname(fileURLToPath(import.meta.url));
|
|
1621
|
+
for (let depth = 0; depth < 4; depth += 1) {
|
|
1622
|
+
const pkg = join(dir, "package.json");
|
|
1623
|
+
try {
|
|
1624
|
+
if (existsSync(pkg)) {
|
|
1625
|
+
const raw = JSON.parse(readFileSync(pkg, "utf8"));
|
|
1626
|
+
if (raw.name === "dsh-remote-vs" && typeof raw.version === "string" && raw.version !== "") {
|
|
1627
|
+
cachedVersion = raw.version;
|
|
1628
|
+
return cachedVersion;
|
|
1629
|
+
}
|
|
1630
|
+
}
|
|
1631
|
+
} catch {}
|
|
1632
|
+
dir = dirname(dir);
|
|
1633
|
+
}
|
|
1634
|
+
} catch {}
|
|
1635
|
+
cachedVersion = "unknown";
|
|
1636
|
+
return cachedVersion;
|
|
1637
|
+
}
|
|
1638
|
+
/** Package name our loader row is mounted from (ns discovery key). */
|
|
1639
|
+
const REMOTE_PACKAGE_NAME = "dsh-remote-vs";
|
|
1640
|
+
/**
|
|
1641
|
+
* The working directory of one LIVE session, or undefined.
|
|
1642
|
+
*
|
|
1643
|
+
* Soft reads only: a composition without `agents`/`workingDirectory` answers
|
|
1644
|
+
* undefined instead of throwing, and the callers treat that as "unknown".
|
|
1645
|
+
*/
|
|
1646
|
+
function liveSessionCwd(ctx, sessionId) {
|
|
1647
|
+
try {
|
|
1648
|
+
const agents = ctx.get("agents");
|
|
1649
|
+
const workingDirectory = ctx.get("workingDirectory");
|
|
1650
|
+
const agent = agents?.get?.(sessionId);
|
|
1651
|
+
if (agent?.session !== void 0 && typeof workingDirectory?.get === "function") {
|
|
1652
|
+
const value = workingDirectory.get(agent.session);
|
|
1653
|
+
if (typeof value === "string" && value !== "") return value;
|
|
1654
|
+
}
|
|
1655
|
+
} catch {}
|
|
1656
|
+
}
|
|
1657
|
+
/**
|
|
1658
|
+
* This plugin's own loader entry id (the settings-form namespace).
|
|
1659
|
+
*
|
|
1660
|
+
* Settings are addressed by profile entry id, which is a mount choice, not
|
|
1661
|
+
* a package property — an aggregate bundle may mount this package under any
|
|
1662
|
+
* id. So the row is identified by package name plus fiber identity, with an
|
|
1663
|
+
* enabled same-name row as fallback (the dsh-better-sidebar pattern). All
|
|
1664
|
+
* reads are soft: unknown compositions answer undefined instead of throwing.
|
|
1665
|
+
*/
|
|
1666
|
+
function ownSettingsNs(ctx) {
|
|
1667
|
+
let fallback;
|
|
1668
|
+
try {
|
|
1669
|
+
const loader = ctx.get("loader");
|
|
1670
|
+
if (typeof loader?.entries !== "function") return void 0;
|
|
1671
|
+
const self = ctx.fiber;
|
|
1672
|
+
for (const entry of loader.entries()) {
|
|
1673
|
+
const id = entry.options.id;
|
|
1674
|
+
if (entry.options.name !== REMOTE_PACKAGE_NAME || typeof id !== "string" || id === "") continue;
|
|
1675
|
+
if (self !== void 0 && entry.fiber === self) return id;
|
|
1676
|
+
if (entry.disabled !== true && fallback === void 0) fallback = id;
|
|
1677
|
+
}
|
|
1678
|
+
} catch {
|
|
1679
|
+
return;
|
|
1680
|
+
}
|
|
1681
|
+
return fallback;
|
|
1682
|
+
}
|
|
1683
|
+
function makeRoutes(deps) {
|
|
1684
|
+
const { store, pool, worlds, ctx } = deps;
|
|
1685
|
+
/** Timeouts for this request (a volatile config edit must apply live). */
|
|
1686
|
+
const timeouts = () => deps.timeouts?.() ?? DEFAULT_TIMEOUTS;
|
|
1687
|
+
const files = new RemoteFiles(pool);
|
|
1688
|
+
const terminalSessions = new TerminalSessionRegistry(pool);
|
|
1689
|
+
/** Apply parsed import payloads; per-entry errors become skips. */
|
|
1690
|
+
const applyImport = (parsed) => {
|
|
1691
|
+
const imported = [];
|
|
1692
|
+
const skipped = [...parsed.skipped];
|
|
1693
|
+
for (const payload of parsed.payloads) {
|
|
1694
|
+
if (store.get(payload.alias) !== void 0) {
|
|
1695
|
+
skipped.push({
|
|
1696
|
+
alias: payload.alias,
|
|
1697
|
+
reason: "alias already exists"
|
|
1698
|
+
});
|
|
1699
|
+
continue;
|
|
1700
|
+
}
|
|
1701
|
+
try {
|
|
1702
|
+
store.create(payload);
|
|
1703
|
+
imported.push(payload.alias);
|
|
1704
|
+
} catch (error) {
|
|
1705
|
+
skipped.push({
|
|
1706
|
+
alias: payload.alias,
|
|
1707
|
+
reason: error instanceof Error ? error.message : String(error)
|
|
1708
|
+
});
|
|
1709
|
+
}
|
|
1710
|
+
}
|
|
1711
|
+
return {
|
|
1712
|
+
imported,
|
|
1713
|
+
skipped
|
|
1714
|
+
};
|
|
1715
|
+
};
|
|
1716
|
+
return {
|
|
1717
|
+
routes: [
|
|
1718
|
+
{
|
|
1719
|
+
kind: "exact",
|
|
1720
|
+
path: REMOTE_API.hosts,
|
|
1721
|
+
handler: async (req, res) => {
|
|
1722
|
+
if (!isLoopbackRequest(req)) {
|
|
1723
|
+
writeJson(res, 403, { error: "forbidden: loopback-only" });
|
|
1724
|
+
return;
|
|
1725
|
+
}
|
|
1726
|
+
const method = req.method ?? "GET";
|
|
1727
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
1728
|
+
if (method === "GET") {
|
|
1729
|
+
writeJson(res, 200, { hosts: store.list().map((h) => store.summarize(h)) });
|
|
1730
|
+
return;
|
|
1731
|
+
}
|
|
1732
|
+
if (method === "POST") {
|
|
1733
|
+
const body = await readJsonBody(req, { objectOnly: true });
|
|
1734
|
+
if (body === null) {
|
|
1735
|
+
writeJson(res, 400, { error: "invalid JSON body" });
|
|
1736
|
+
return;
|
|
1737
|
+
}
|
|
1738
|
+
try {
|
|
1739
|
+
const entry = store.create(body);
|
|
1740
|
+
writeJson(res, 201, { host: store.summarize(entry) });
|
|
1741
|
+
} catch (error) {
|
|
1742
|
+
writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) });
|
|
1743
|
+
}
|
|
1744
|
+
return;
|
|
1745
|
+
}
|
|
1746
|
+
if (method !== "PATCH" && method !== "DELETE") {
|
|
1747
|
+
writeJson(res, 405, { error: `method not allowed: ${method}` });
|
|
1748
|
+
return;
|
|
1749
|
+
}
|
|
1750
|
+
const alias = queryParam(url, "alias");
|
|
1751
|
+
if (alias === void 0 || alias === "") {
|
|
1752
|
+
writeJson(res, 400, { error: "alias query parameter is required" });
|
|
1753
|
+
return;
|
|
1754
|
+
}
|
|
1755
|
+
if (method === "DELETE") {
|
|
1756
|
+
pool.drop(alias);
|
|
1757
|
+
worlds.dropHost(alias).catch(() => void 0);
|
|
1758
|
+
if (!store.remove(alias)) {
|
|
1759
|
+
writeJson(res, 404, { error: `unknown host alias: ${alias}` });
|
|
1760
|
+
return;
|
|
1761
|
+
}
|
|
1762
|
+
worlds.syncWorkspaces().catch(() => void 0);
|
|
1763
|
+
writeJson(res, 200, { ok: true });
|
|
1764
|
+
return;
|
|
1765
|
+
}
|
|
1766
|
+
const body = await readJsonBody(req, { objectOnly: true });
|
|
1767
|
+
if (body === null) {
|
|
1768
|
+
writeJson(res, 400, { error: "invalid JSON body" });
|
|
1769
|
+
return;
|
|
1770
|
+
}
|
|
1771
|
+
try {
|
|
1772
|
+
pool.drop(alias);
|
|
1773
|
+
worlds.dropHost(alias).catch(() => void 0);
|
|
1774
|
+
const entry = store.update(alias, body);
|
|
1775
|
+
writeJson(res, 200, { host: store.summarize(entry) });
|
|
1776
|
+
} catch (error) {
|
|
1777
|
+
writeJson(res, (error instanceof Error ? error.message : "").startsWith("unknown host alias") ? 404 : 400, { error: error instanceof Error ? error.message : String(error) });
|
|
1778
|
+
}
|
|
1779
|
+
}
|
|
1780
|
+
},
|
|
1781
|
+
{
|
|
1782
|
+
kind: "exact",
|
|
1783
|
+
path: REMOTE_API.test,
|
|
1784
|
+
handler: async (req, res) => {
|
|
1785
|
+
if (!isLoopbackRequest(req)) {
|
|
1786
|
+
writeJson(res, 403, { error: "forbidden: loopback-only" });
|
|
1787
|
+
return;
|
|
1788
|
+
}
|
|
1789
|
+
if (req.method !== "POST") {
|
|
1790
|
+
writeJson(res, 405, { error: `method not allowed: ${req.method}` });
|
|
1791
|
+
return;
|
|
1792
|
+
}
|
|
1793
|
+
const body = await readJsonBody(req, { objectOnly: true });
|
|
1794
|
+
if (body === null || typeof body.alias !== "string") {
|
|
1795
|
+
writeJson(res, 400, { error: "alias is required" });
|
|
1796
|
+
return;
|
|
1797
|
+
}
|
|
1798
|
+
writeJson(res, 200, await pool.test(body.alias));
|
|
1799
|
+
}
|
|
1800
|
+
},
|
|
1801
|
+
{
|
|
1802
|
+
kind: "exact",
|
|
1803
|
+
path: REMOTE_API.keygen,
|
|
1804
|
+
handler: async (req, res) => {
|
|
1805
|
+
if (!isLoopbackRequest(req)) {
|
|
1806
|
+
writeJson(res, 403, { error: "forbidden: loopback-only" });
|
|
1807
|
+
return;
|
|
1808
|
+
}
|
|
1809
|
+
if (req.method !== "POST") {
|
|
1810
|
+
writeJson(res, 405, { error: `method not allowed: ${req.method}` });
|
|
1811
|
+
return;
|
|
1812
|
+
}
|
|
1813
|
+
const body = await readJsonBody(req, { objectOnly: true });
|
|
1814
|
+
if (body === null || typeof body.alias !== "string") {
|
|
1815
|
+
writeJson(res, 400, { error: "alias is required" });
|
|
1816
|
+
return;
|
|
1817
|
+
}
|
|
1818
|
+
try {
|
|
1819
|
+
writeJson(res, 200, await bootstrap(pool, store, body.alias));
|
|
1820
|
+
} catch (error) {
|
|
1821
|
+
writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
|
|
1822
|
+
}
|
|
1823
|
+
}
|
|
1824
|
+
},
|
|
1825
|
+
{
|
|
1826
|
+
kind: "exact",
|
|
1827
|
+
path: REMOTE_API.exec,
|
|
1828
|
+
handler: async (req, res) => {
|
|
1829
|
+
if (!isLoopbackRequest(req)) {
|
|
1830
|
+
writeJson(res, 403, { error: "forbidden: loopback-only" });
|
|
1831
|
+
return;
|
|
1832
|
+
}
|
|
1833
|
+
if (req.method !== "POST") {
|
|
1834
|
+
writeJson(res, 405, { error: `method not allowed: ${req.method}` });
|
|
1835
|
+
return;
|
|
1836
|
+
}
|
|
1837
|
+
const body = await readJsonBody(req, { objectOnly: true });
|
|
1838
|
+
if (body === null || typeof body.alias !== "string" || typeof body.command !== "string" || body.command === "") {
|
|
1839
|
+
writeJson(res, 400, { error: "alias and a non-empty command are required" });
|
|
1840
|
+
return;
|
|
1841
|
+
}
|
|
1842
|
+
const timeoutMs = clampExecTimeout(body.timeoutMs);
|
|
1843
|
+
writeJson(res, 200, await pool.exec(body.alias, body.command, timeoutMs === void 0 ? {} : { timeoutMs }));
|
|
1844
|
+
}
|
|
1845
|
+
},
|
|
1846
|
+
{
|
|
1847
|
+
kind: "exact",
|
|
1848
|
+
path: REMOTE_API.files,
|
|
1849
|
+
handler: async (req, res) => {
|
|
1850
|
+
if (!isLoopbackRequest(req)) {
|
|
1851
|
+
writeJson(res, 403, { error: "forbidden: loopback-only" });
|
|
1852
|
+
return;
|
|
1853
|
+
}
|
|
1854
|
+
if (req.method !== "POST") {
|
|
1855
|
+
writeJson(res, 405, { error: `method not allowed: ${req.method}` });
|
|
1856
|
+
return;
|
|
1857
|
+
}
|
|
1858
|
+
const body = await readJsonBody(req, {
|
|
1859
|
+
objectOnly: true,
|
|
1860
|
+
maxBytes: 10 * 1024 * 1024
|
|
1861
|
+
});
|
|
1862
|
+
if (body === null || typeof body.op !== "string" || typeof body.alias !== "string") {
|
|
1863
|
+
writeJson(res, 400, { error: "op and alias are required" });
|
|
1864
|
+
return;
|
|
1865
|
+
}
|
|
1866
|
+
const alias = body.alias;
|
|
1867
|
+
try {
|
|
1868
|
+
switch (body.op) {
|
|
1869
|
+
case "list":
|
|
1870
|
+
if (typeof body.path !== "string") throw new Error("path is required");
|
|
1871
|
+
writeJson(res, 200, { entries: await files.list(alias, body.path) });
|
|
1872
|
+
return;
|
|
1873
|
+
case "stat":
|
|
1874
|
+
if (typeof body.path !== "string") throw new Error("path is required");
|
|
1875
|
+
writeJson(res, 200, { entry: await files.stat(alias, body.path) });
|
|
1876
|
+
return;
|
|
1877
|
+
case "read":
|
|
1878
|
+
if (typeof body.path !== "string") throw new Error("path is required");
|
|
1879
|
+
writeJson(res, 200, await files.read(alias, body.path));
|
|
1880
|
+
return;
|
|
1881
|
+
case "write": {
|
|
1882
|
+
if (typeof body.path !== "string" || typeof body.content !== "string") throw new Error("path and content are required");
|
|
1883
|
+
const encoding = body.encoding === "base64" ? "base64" : "utf8";
|
|
1884
|
+
writeJson(res, 200, await files.write(alias, body.path, body.content, encoding));
|
|
1885
|
+
return;
|
|
1886
|
+
}
|
|
1887
|
+
case "mkdir":
|
|
1888
|
+
if (typeof body.path !== "string") throw new Error("path is required");
|
|
1889
|
+
await files.mkdir(alias, body.path);
|
|
1890
|
+
writeJson(res, 200, { ok: true });
|
|
1891
|
+
return;
|
|
1892
|
+
case "rm":
|
|
1893
|
+
if (typeof body.path !== "string") throw new Error("path is required");
|
|
1894
|
+
await files.remove(alias, body.path, body.recursive === true);
|
|
1895
|
+
writeJson(res, 200, { ok: true });
|
|
1896
|
+
return;
|
|
1897
|
+
case "rename":
|
|
1898
|
+
if (typeof body.from !== "string" || typeof body.to !== "string") throw new Error("from and to are required");
|
|
1899
|
+
await files.rename(alias, body.from, body.to);
|
|
1900
|
+
writeJson(res, 200, { ok: true });
|
|
1901
|
+
return;
|
|
1902
|
+
default: writeJson(res, 400, { error: `unknown op: ${body.op}` });
|
|
1903
|
+
}
|
|
1904
|
+
} catch (error) {
|
|
1905
|
+
writeJson(res, 502, { error: error instanceof Error ? error.message : String(error) });
|
|
1906
|
+
}
|
|
1907
|
+
}
|
|
1908
|
+
},
|
|
1909
|
+
{
|
|
1910
|
+
kind: "exact",
|
|
1911
|
+
path: REMOTE_API.upload,
|
|
1912
|
+
handler: async (req, res) => {
|
|
1913
|
+
if (!isLoopbackRequest(req)) {
|
|
1914
|
+
writeJson(res, 403, { error: "forbidden: loopback-only" });
|
|
1915
|
+
return;
|
|
1916
|
+
}
|
|
1917
|
+
if (req.method !== "POST") {
|
|
1918
|
+
writeJson(res, 405, { error: `method not allowed: ${req.method}` });
|
|
1919
|
+
return;
|
|
1920
|
+
}
|
|
1921
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
1922
|
+
const alias = url.searchParams.get("alias") ?? "";
|
|
1923
|
+
const path = url.searchParams.get("path") ?? "";
|
|
1924
|
+
const overwrite = url.searchParams.get("overwrite") === "1";
|
|
1925
|
+
if (alias === "" || path === "") {
|
|
1926
|
+
writeJson(res, 400, { error: "alias and path query parameters are required" });
|
|
1927
|
+
return;
|
|
1928
|
+
}
|
|
1929
|
+
if (!path.startsWith("/")) {
|
|
1930
|
+
writeJson(res, 400, { error: `path must be absolute: ${path}` });
|
|
1931
|
+
return;
|
|
1932
|
+
}
|
|
1933
|
+
try {
|
|
1934
|
+
if (!overwrite) {
|
|
1935
|
+
if (await files.stat(alias, path).then(() => true, () => false)) {
|
|
1936
|
+
writeJson(res, 409, {
|
|
1937
|
+
error: `${path} already exists`,
|
|
1938
|
+
exists: true
|
|
1939
|
+
});
|
|
1940
|
+
return;
|
|
1941
|
+
}
|
|
1942
|
+
}
|
|
1943
|
+
writeJson(res, 200, {
|
|
1944
|
+
ok: true,
|
|
1945
|
+
size: (await files.upload(alias, path, req, void 0, overwrite)).size
|
|
1946
|
+
});
|
|
1947
|
+
} catch (error) {
|
|
1948
|
+
writeJson(res, 502, { error: error instanceof Error ? error.message : String(error) });
|
|
1949
|
+
}
|
|
1950
|
+
}
|
|
1951
|
+
},
|
|
1952
|
+
{
|
|
1953
|
+
kind: "exact",
|
|
1954
|
+
path: REMOTE_API.workspaces,
|
|
1955
|
+
handler: async (req, res) => {
|
|
1956
|
+
if (!isLoopbackRequest(req)) {
|
|
1957
|
+
writeJson(res, 403, { error: "forbidden: loopback-only" });
|
|
1958
|
+
return;
|
|
1959
|
+
}
|
|
1960
|
+
const method = req.method ?? "GET";
|
|
1961
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
1962
|
+
if (method === "GET") {
|
|
1963
|
+
const bindings = store.listWorkspaces();
|
|
1964
|
+
const aliasPaths = {};
|
|
1965
|
+
for (const b of bindings) aliasPaths[b.id] = worlds.aliasPathFor(b.id);
|
|
1966
|
+
worlds.syncWorkspaces().catch(() => void 0);
|
|
1967
|
+
writeJson(res, 200, {
|
|
1968
|
+
workspaces: bindings,
|
|
1969
|
+
mode: worlds.mode,
|
|
1970
|
+
aliasPaths,
|
|
1971
|
+
tombstones: store.listTombstones(),
|
|
1972
|
+
version: pluginVersion(),
|
|
1973
|
+
timeouts: timeouts()
|
|
1974
|
+
});
|
|
1975
|
+
return;
|
|
1976
|
+
}
|
|
1977
|
+
if (method === "POST") {
|
|
1978
|
+
const body = await readJsonBody(req, { objectOnly: true });
|
|
1979
|
+
if (body === null) {
|
|
1980
|
+
writeJson(res, 400, { error: "invalid JSON body" });
|
|
1981
|
+
return;
|
|
1982
|
+
}
|
|
1983
|
+
try {
|
|
1984
|
+
const binding = store.createWorkspace(body);
|
|
1985
|
+
const aliasPath = worlds.aliasPathFor(binding.id);
|
|
1986
|
+
try {
|
|
1987
|
+
mkdirSync(aliasPath, { recursive: true });
|
|
1988
|
+
} catch (error) {
|
|
1989
|
+
throw new Error(`cannot materialize alias dir ${aliasPath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
1990
|
+
}
|
|
1991
|
+
await worlds.syncWorkspaces().catch(() => void 0);
|
|
1992
|
+
writeJson(res, 201, {
|
|
1993
|
+
workspace: binding,
|
|
1994
|
+
aliasPath
|
|
1995
|
+
});
|
|
1996
|
+
} catch (error) {
|
|
1997
|
+
writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) });
|
|
1998
|
+
}
|
|
1999
|
+
return;
|
|
2000
|
+
}
|
|
2001
|
+
if (method !== "DELETE") {
|
|
2002
|
+
writeJson(res, 405, { error: `method not allowed: ${method}` });
|
|
2003
|
+
return;
|
|
2004
|
+
}
|
|
2005
|
+
const id = queryParam(url, "id");
|
|
2006
|
+
if (id === void 0 || id === "") {
|
|
2007
|
+
writeJson(res, 400, { error: "id query parameter is required" });
|
|
2008
|
+
return;
|
|
2009
|
+
}
|
|
2010
|
+
if (!store.removeWorkspace(id)) {
|
|
2011
|
+
writeJson(res, 404, { error: `unknown workspace id: ${id}` });
|
|
2012
|
+
return;
|
|
2013
|
+
}
|
|
2014
|
+
worlds.syncWorkspaces().catch(() => void 0);
|
|
2015
|
+
writeJson(res, 200, { ok: true });
|
|
2016
|
+
}
|
|
2017
|
+
},
|
|
2018
|
+
{
|
|
2019
|
+
kind: "exact",
|
|
2020
|
+
path: REMOTE_API.sessionCwd,
|
|
2021
|
+
handler: async (req, res) => {
|
|
2022
|
+
if (!isLoopbackRequest(req)) {
|
|
2023
|
+
writeJson(res, 403, { error: "forbidden: loopback-only" });
|
|
2024
|
+
return;
|
|
2025
|
+
}
|
|
2026
|
+
if ((req.method ?? "GET") !== "POST") {
|
|
2027
|
+
writeJson(res, 405, { error: `method not allowed: ${req.method ?? "GET"}` });
|
|
2028
|
+
return;
|
|
2029
|
+
}
|
|
2030
|
+
const body = await readJsonBody(req, { objectOnly: true });
|
|
2031
|
+
const sessionId = typeof body?.sessionId === "string" ? body.sessionId : "";
|
|
2032
|
+
if (sessionId === "") {
|
|
2033
|
+
writeJson(res, 400, { error: "sessionId must be a non-empty string" });
|
|
2034
|
+
return;
|
|
2035
|
+
}
|
|
2036
|
+
writeJson(res, 200, { cwd: liveSessionCwd(ctx, sessionId) });
|
|
2037
|
+
}
|
|
2038
|
+
},
|
|
2039
|
+
{
|
|
2040
|
+
kind: "exact",
|
|
2041
|
+
path: REMOTE_API.changes,
|
|
2042
|
+
handler: async (req, res) => {
|
|
2043
|
+
if (!isLoopbackRequest(req)) {
|
|
2044
|
+
writeJson(res, 403, { error: "forbidden: loopback-only" });
|
|
2045
|
+
return;
|
|
2046
|
+
}
|
|
2047
|
+
if ((req.method ?? "GET") !== "POST") {
|
|
2048
|
+
writeJson(res, 405, { error: `method not allowed: ${req.method ?? "GET"}` });
|
|
2049
|
+
return;
|
|
2050
|
+
}
|
|
2051
|
+
const body = await readJsonBody(req, { objectOnly: true });
|
|
2052
|
+
const sessionId = typeof body?.sessionId === "string" ? body.sessionId : "";
|
|
2053
|
+
const op = typeof body?.op === "string" ? body.op : "";
|
|
2054
|
+
if (sessionId === "" || op !== "list" && op !== "diff") {
|
|
2055
|
+
writeJson(res, 400, { error: "op (list|diff) and a non-empty sessionId are required" });
|
|
2056
|
+
return;
|
|
2057
|
+
}
|
|
2058
|
+
const ledger = deps.changes;
|
|
2059
|
+
if (ledger === void 0) {
|
|
2060
|
+
writeJson(res, 200, {
|
|
2061
|
+
summary: null,
|
|
2062
|
+
diff: null
|
|
2063
|
+
});
|
|
2064
|
+
return;
|
|
2065
|
+
}
|
|
2066
|
+
if (typeof body?.repoSubdir === "string" || body?.repoSubdir === null) ledger.useRepoSubdir(sessionId, typeof body.repoSubdir === "string" ? body.repoSubdir : void 0);
|
|
2067
|
+
if (op === "list") {
|
|
2068
|
+
if (ledger.summary(sessionId) === null) {
|
|
2069
|
+
const cwd = liveSessionCwd(ctx, sessionId);
|
|
2070
|
+
if (cwd !== void 0) ledger.beginTurn({
|
|
2071
|
+
sessionId,
|
|
2072
|
+
cwd
|
|
2073
|
+
});
|
|
2074
|
+
}
|
|
2075
|
+
if (body?.force === true) ledger.rescan(sessionId);
|
|
2076
|
+
else ledger.touch(sessionId);
|
|
2077
|
+
writeJson(res, 200, { summary: ledger.summary(sessionId) });
|
|
2078
|
+
return;
|
|
2079
|
+
}
|
|
2080
|
+
const path = typeof body?.path === "string" ? body.path : "";
|
|
2081
|
+
if (path === "") {
|
|
2082
|
+
writeJson(res, 400, { error: "path is required for op=diff" });
|
|
2083
|
+
return;
|
|
2084
|
+
}
|
|
2085
|
+
try {
|
|
2086
|
+
writeJson(res, 200, { diff: await ledger.diff(sessionId, path) });
|
|
2087
|
+
} catch (error) {
|
|
2088
|
+
writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
|
|
2089
|
+
}
|
|
2090
|
+
}
|
|
2091
|
+
},
|
|
2092
|
+
{
|
|
2093
|
+
kind: "exact",
|
|
2094
|
+
path: REMOTE_API.routing,
|
|
2095
|
+
handler: async (req, res) => {
|
|
2096
|
+
if (!isLoopbackRequest(req)) {
|
|
2097
|
+
writeJson(res, 403, { error: "forbidden: loopback-only" });
|
|
2098
|
+
return;
|
|
2099
|
+
}
|
|
2100
|
+
if ((req.method ?? "GET") !== "POST") {
|
|
2101
|
+
writeJson(res, 405, { error: `method not allowed: ${req.method ?? "GET"}` });
|
|
2102
|
+
return;
|
|
2103
|
+
}
|
|
2104
|
+
const mode = (await readJsonBody(req, { objectOnly: true }))?.mode;
|
|
2105
|
+
if (mode !== "off" && mode !== "shadow" && mode !== "enforce") {
|
|
2106
|
+
writeJson(res, 400, { error: `mode must be one of off/shadow/enforce (got ${JSON.stringify(mode) ?? "missing"})` });
|
|
2107
|
+
return;
|
|
2108
|
+
}
|
|
2109
|
+
try {
|
|
2110
|
+
const settings = ctx.get("settings");
|
|
2111
|
+
if (typeof settings?.update !== "function") {
|
|
2112
|
+
writeJson(res, 503, { error: "settings service unavailable (cannot persist routing mode)" });
|
|
2113
|
+
return;
|
|
2114
|
+
}
|
|
2115
|
+
const ns = ownSettingsNs(ctx) ?? "remote-vs";
|
|
2116
|
+
await settings.update(ns, { routingMode: mode });
|
|
2117
|
+
writeJson(res, 200, { mode: worlds.mode });
|
|
2118
|
+
} catch (error) {
|
|
2119
|
+
writeJson(res, 502, { error: error instanceof Error ? error.message : String(error) });
|
|
2120
|
+
}
|
|
2121
|
+
}
|
|
2122
|
+
},
|
|
2123
|
+
{
|
|
2124
|
+
kind: "exact",
|
|
2125
|
+
path: REMOTE_API.deploy,
|
|
2126
|
+
handler: async (req, res) => {
|
|
2127
|
+
if (!isLoopbackRequest(req)) {
|
|
2128
|
+
writeJson(res, 403, { error: "forbidden: loopback-only" });
|
|
2129
|
+
return;
|
|
2130
|
+
}
|
|
2131
|
+
const method = req.method ?? "GET";
|
|
2132
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
2133
|
+
if (method === "GET") {
|
|
2134
|
+
const alias = queryParam(url, "alias");
|
|
2135
|
+
const view = queryParam(url, "view") ?? "status";
|
|
2136
|
+
if (alias === void 0 || alias === "") {
|
|
2137
|
+
writeJson(res, 400, { error: "alias query parameter is required" });
|
|
2138
|
+
return;
|
|
2139
|
+
}
|
|
2140
|
+
if (view === "probe") {
|
|
2141
|
+
try {
|
|
2142
|
+
writeJson(res, 200, await probe(pool, alias));
|
|
2143
|
+
} catch (error) {
|
|
2144
|
+
writeJson(res, 502, { error: error instanceof Error ? error.message : String(error) });
|
|
2145
|
+
}
|
|
2146
|
+
return;
|
|
2147
|
+
}
|
|
2148
|
+
if (view === "status") {
|
|
2149
|
+
try {
|
|
2150
|
+
writeJson(res, 200, {
|
|
2151
|
+
deploy: store.require(alias).deploy ?? { status: "not-deployed" },
|
|
2152
|
+
helpers: helperAvailability()
|
|
2153
|
+
});
|
|
2154
|
+
} catch (error) {
|
|
2155
|
+
writeJson(res, 404, { error: error instanceof Error ? error.message : String(error) });
|
|
2156
|
+
}
|
|
2157
|
+
return;
|
|
2158
|
+
}
|
|
2159
|
+
writeJson(res, 400, { error: `unknown view: ${view}` });
|
|
2160
|
+
return;
|
|
2161
|
+
}
|
|
2162
|
+
if (method !== "POST") {
|
|
2163
|
+
writeJson(res, 405, { error: `method not allowed: ${method}` });
|
|
2164
|
+
return;
|
|
2165
|
+
}
|
|
2166
|
+
const body = await readJsonBody(req, { objectOnly: true });
|
|
2167
|
+
if (body === null || typeof body.alias !== "string") {
|
|
2168
|
+
writeJson(res, 400, { error: "alias is required" });
|
|
2169
|
+
return;
|
|
2170
|
+
}
|
|
2171
|
+
try {
|
|
2172
|
+
writeJson(res, 200, await pushPipeline(pool, store, body.alias, typeof body.version === "string" ? { version: body.version } : {}));
|
|
2173
|
+
} catch (error) {
|
|
2174
|
+
writeJson(res, 502, { error: error instanceof Error ? error.message : String(error) });
|
|
2175
|
+
}
|
|
2176
|
+
}
|
|
2177
|
+
},
|
|
2178
|
+
{
|
|
2179
|
+
kind: "exact",
|
|
2180
|
+
path: REMOTE_API.importSshConfig,
|
|
2181
|
+
handler: async (req, res) => {
|
|
2182
|
+
if (!isLoopbackRequest(req)) {
|
|
2183
|
+
writeJson(res, 403, { error: "forbidden: loopback-only" });
|
|
2184
|
+
return;
|
|
2185
|
+
}
|
|
2186
|
+
if (req.method !== "POST") {
|
|
2187
|
+
writeJson(res, 405, { error: `method not allowed: ${req.method}` });
|
|
2188
|
+
return;
|
|
2189
|
+
}
|
|
2190
|
+
const body = await readJsonBody(req, { maxBytes: 1024 * 1024 });
|
|
2191
|
+
try {
|
|
2192
|
+
const text = body !== null && typeof body.text === "string" ? body.text : readDefaultSshConfig();
|
|
2193
|
+
writeJson(res, 200, applyImport(parseSshConfig(text)));
|
|
2194
|
+
} catch (error) {
|
|
2195
|
+
writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) });
|
|
2196
|
+
}
|
|
2197
|
+
}
|
|
2198
|
+
},
|
|
2199
|
+
{
|
|
2200
|
+
kind: "exact",
|
|
2201
|
+
path: REMOTE_API.importDshSsh,
|
|
2202
|
+
handler: async (req, res) => {
|
|
2203
|
+
if (!isLoopbackRequest(req)) {
|
|
2204
|
+
writeJson(res, 403, { error: "forbidden: loopback-only" });
|
|
2205
|
+
return;
|
|
2206
|
+
}
|
|
2207
|
+
if (req.method !== "POST") {
|
|
2208
|
+
writeJson(res, 405, { error: `method not allowed: ${req.method}` });
|
|
2209
|
+
return;
|
|
2210
|
+
}
|
|
2211
|
+
try {
|
|
2212
|
+
writeJson(res, 200, applyImport(migrateDshSsh()));
|
|
2213
|
+
} catch (error) {
|
|
2214
|
+
writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) });
|
|
2215
|
+
}
|
|
2216
|
+
}
|
|
2217
|
+
},
|
|
2218
|
+
{
|
|
2219
|
+
kind: "exact",
|
|
2220
|
+
path: REMOTE_API.terminalClose,
|
|
2221
|
+
handler: async (req, res) => {
|
|
2222
|
+
if (!isLoopbackRequest(req)) {
|
|
2223
|
+
writeJson(res, 403, { error: "forbidden: loopback-only" });
|
|
2224
|
+
return;
|
|
2225
|
+
}
|
|
2226
|
+
if (req.method !== "POST") {
|
|
2227
|
+
writeJson(res, 405, { error: `method not allowed: ${req.method}` });
|
|
2228
|
+
return;
|
|
2229
|
+
}
|
|
2230
|
+
const body = await readJsonBody(req, { objectOnly: true });
|
|
2231
|
+
if (body === null || typeof body.id !== "string" || body.id === "") {
|
|
2232
|
+
writeJson(res, 400, { error: "id is required" });
|
|
2233
|
+
return;
|
|
2234
|
+
}
|
|
2235
|
+
writeJson(res, 200, { ok: terminalSessions.close(body.id) });
|
|
2236
|
+
}
|
|
2237
|
+
}
|
|
2238
|
+
],
|
|
2239
|
+
upgrade: {
|
|
2240
|
+
path: REMOTE_API.terminal,
|
|
2241
|
+
handler: (req, socket, head) => {
|
|
2242
|
+
if (!isLoopbackRequest(req)) {
|
|
2243
|
+
socket.write("HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n");
|
|
2244
|
+
socket.destroy();
|
|
2245
|
+
return;
|
|
2246
|
+
}
|
|
2247
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
2248
|
+
const alias = queryParam(url, "alias");
|
|
2249
|
+
const sessionId = queryParam(url, "session");
|
|
2250
|
+
if (alias === void 0 && sessionId === void 0) {
|
|
2251
|
+
socket.write("HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n");
|
|
2252
|
+
socket.destroy();
|
|
2253
|
+
return;
|
|
2254
|
+
}
|
|
2255
|
+
const cols = Number.parseInt(queryParam(url, "cols") ?? "80", 10);
|
|
2256
|
+
const rows = Number.parseInt(queryParam(url, "rows") ?? "24", 10);
|
|
2257
|
+
const size = {
|
|
2258
|
+
cols: Number.isFinite(cols) && cols > 0 ? Math.min(cols, 500) : 80,
|
|
2259
|
+
rows: Number.isFinite(rows) && rows > 0 ? Math.min(rows, 200) : 24
|
|
2260
|
+
};
|
|
2261
|
+
terminalWss.handleUpgrade(req, socket, head, (ws) => {
|
|
2262
|
+
if (sessionId !== void 0) {
|
|
2263
|
+
if (terminalSessions.attach(ws, sessionId, size.cols, size.rows)) return;
|
|
2264
|
+
try {
|
|
2265
|
+
ws.send(JSON.stringify({
|
|
2266
|
+
t: "error",
|
|
2267
|
+
message: "terminal session expired"
|
|
2268
|
+
}));
|
|
2269
|
+
} catch {}
|
|
2270
|
+
try {
|
|
2271
|
+
ws.close(1e3);
|
|
2272
|
+
} catch {}
|
|
2273
|
+
return;
|
|
2274
|
+
}
|
|
2275
|
+
terminalSessions.open(ws, alias, size.cols, size.rows).then(() => void 0, () => void 0);
|
|
2276
|
+
});
|
|
2277
|
+
}
|
|
2278
|
+
},
|
|
2279
|
+
terminalSessions
|
|
2280
|
+
};
|
|
2281
|
+
}
|
|
2282
|
+
//#endregion
|
|
2283
|
+
//#region src/changes/status.ts
|
|
2284
|
+
/** Exit statuses of the read commands (git's own statuses pass through as-is). */
|
|
2285
|
+
const READ_EXIT = {
|
|
2286
|
+
/** The reported root does not exist on the host (stale alias). */
|
|
2287
|
+
noRoot: 90,
|
|
2288
|
+
/**
|
|
2289
|
+
* No `base64` on the host. Probed up front because without it every read
|
|
2290
|
+
* would come back empty and look like "this file has no content", which is a
|
|
2291
|
+
* far worse failure than a clear refusal.
|
|
2292
|
+
*/
|
|
2293
|
+
noBase64: 92
|
|
2294
|
+
};
|
|
2295
|
+
/** Marker that starts one file block in a read reply. */
|
|
2296
|
+
const FILE_MARKER = "@@RVS-FILE ";
|
|
2297
|
+
/** POSIX single-quote one shell word. Throws on NUL, which no shell can carry. */
|
|
2298
|
+
function shq(word) {
|
|
2299
|
+
if (word.includes("\0")) throw new Error("NUL byte in shell word");
|
|
2300
|
+
return `'${word.replace(/'/g, `'\\''`)}'`;
|
|
2301
|
+
}
|
|
2302
|
+
/** `base64` of a UTF-8 string, for the marker's path field. */
|
|
2303
|
+
function pathToken(path) {
|
|
2304
|
+
return Buffer.from(path, "utf8").toString("base64");
|
|
2305
|
+
}
|
|
2306
|
+
/** `cd` into the root, or exit with {@link READ_EXIT.noRoot}. */
|
|
2307
|
+
function cdLine(root) {
|
|
2308
|
+
return `cd ${shq(root)} || exit ${READ_EXIT.noRoot}`;
|
|
2309
|
+
}
|
|
2310
|
+
/** A cap is a byte count: non-negative, integral, and never NaN. */
|
|
2311
|
+
function capOf(value) {
|
|
2312
|
+
if (!Number.isFinite(value)) return 0;
|
|
2313
|
+
return Math.max(0, Math.floor(value));
|
|
2314
|
+
}
|
|
2315
|
+
/**
|
|
2316
|
+
* Tracked paths that changed, HEAD/index vs worktree, staged changes included.
|
|
2317
|
+
*
|
|
2318
|
+
* `-uno` is what keeps this cheap: untracked detection is the expensive half
|
|
2319
|
+
* (11s above) and it is answered by {@link untrackedCommand} instead. `-- .`
|
|
2320
|
+
* scopes the walk to the session root, so a session sitting in one package of
|
|
2321
|
+
* a monorepo does not pay for the rest.
|
|
2322
|
+
*
|
|
2323
|
+
* @param root - remote absolute POSIX directory (the session cwd).
|
|
2324
|
+
* @returns a `sh` script printing NUL-separated porcelain v1 records.
|
|
2325
|
+
*/
|
|
2326
|
+
function trackedStatusCommand(root) {
|
|
2327
|
+
return [cdLine(root), "git --no-pager status --porcelain -z -uno -- ."].join("\n");
|
|
2328
|
+
}
|
|
2329
|
+
/**
|
|
2330
|
+
* New files git is not tracking yet (the ones a session usually creates).
|
|
2331
|
+
*
|
|
2332
|
+
* @param root - remote absolute POSIX directory (the session cwd).
|
|
2333
|
+
* @returns a `sh` script printing NUL-separated paths.
|
|
2334
|
+
*/
|
|
2335
|
+
function untrackedCommand(root) {
|
|
2336
|
+
return [cdLine(root), "git ls-files --others --exclude-standard -z -- ."].join("\n");
|
|
2337
|
+
}
|
|
2338
|
+
/**
|
|
2339
|
+
* Parse `git status --porcelain -z -uno` output.
|
|
2340
|
+
*
|
|
2341
|
+
* Porcelain v1 with `-z` writes `XY <path>\0`, and for a rename or copy the
|
|
2342
|
+
* source path follows as its own record: `R <new>\0<old>\0` (verified against
|
|
2343
|
+
* git 2.x: the NEW path comes first). The source record is consumed, not parsed
|
|
2344
|
+
* as its own entry, and the reported path is the NEW one — the only path the
|
|
2345
|
+
* editor can open.
|
|
2346
|
+
*
|
|
2347
|
+
* The two status columns mean "index vs HEAD" (X) and "worktree vs index" (Y);
|
|
2348
|
+
* one letter is reported, chosen for how the file reads in a session review:
|
|
2349
|
+
* conflict > deleted > renamed > copied > added > typechange > modified.
|
|
2350
|
+
*
|
|
2351
|
+
* @param stdout - raw reply of {@link trackedStatusCommand}.
|
|
2352
|
+
*/
|
|
2353
|
+
function parseStatusZ(stdout) {
|
|
2354
|
+
const entries = [];
|
|
2355
|
+
const records = stdout.split("\0");
|
|
2356
|
+
for (let index = 0; index < records.length; index += 1) {
|
|
2357
|
+
const record = records[index];
|
|
2358
|
+
if (record === void 0 || record.length < 4) continue;
|
|
2359
|
+
const x = record.charAt(0);
|
|
2360
|
+
const y = record.charAt(1);
|
|
2361
|
+
if (record.charAt(2) !== " ") continue;
|
|
2362
|
+
const path = record.slice(3);
|
|
2363
|
+
if (path === "") continue;
|
|
2364
|
+
if (x === "R" || y === "R" || x === "C" || y === "C") index += 1;
|
|
2365
|
+
entries.push({
|
|
2366
|
+
path,
|
|
2367
|
+
status: statusOf(x, y)
|
|
2368
|
+
});
|
|
2369
|
+
}
|
|
2370
|
+
return entries;
|
|
2371
|
+
}
|
|
2372
|
+
/** Collapse porcelain's two columns into the one letter a review panel shows. */
|
|
2373
|
+
function statusOf(x, y) {
|
|
2374
|
+
const both = `${x}${y}`;
|
|
2375
|
+
if (x === "?" || y === "?") return "?";
|
|
2376
|
+
if (x === "U" || y === "U" || both === "AA" || both === "DD") return "U";
|
|
2377
|
+
if (x === "D" || y === "D") return "D";
|
|
2378
|
+
if (x === "R" || y === "R") return "R";
|
|
2379
|
+
if (x === "C" || y === "C") return "C";
|
|
2380
|
+
if (x === "A" || y === "A") return "A";
|
|
2381
|
+
if (x === "T" || y === "T") return "T";
|
|
2382
|
+
return "M";
|
|
2383
|
+
}
|
|
2384
|
+
/**
|
|
2385
|
+
* Parse `git ls-files --others --exclude-standard -z` output into untracked
|
|
2386
|
+
* entries. Git already filtered ignored files out, and its paths are never
|
|
2387
|
+
* quoted in `-z` mode.
|
|
2388
|
+
*/
|
|
2389
|
+
function parseUntrackedZ(stdout) {
|
|
2390
|
+
const entries = [];
|
|
2391
|
+
for (const record of stdout.split("\0")) {
|
|
2392
|
+
if (record === "") continue;
|
|
2393
|
+
entries.push({
|
|
2394
|
+
path: record,
|
|
2395
|
+
status: "?"
|
|
2396
|
+
});
|
|
2397
|
+
}
|
|
2398
|
+
return entries;
|
|
2399
|
+
}
|
|
2400
|
+
/**
|
|
2401
|
+
* Read several worktree files in one remote run.
|
|
2402
|
+
*
|
|
2403
|
+
* One `sh` script walks the paths in order and emits a {@link ReadResult} block
|
|
2404
|
+
* per path. Sizes come from `wc -c` before anything is transferred, so a 4GB
|
|
2405
|
+
* build artifact costs one `stat` instead of 5GB of base64; the running total
|
|
2406
|
+
* stops the reply from growing past `totalCapBytes`, after which the remaining
|
|
2407
|
+
* paths are reported `big` without being read.
|
|
2408
|
+
*
|
|
2409
|
+
* A symlink reports its target as content, because that is what git stores for
|
|
2410
|
+
* mode 120000 — catting the link's target file would diff the wrong bytes.
|
|
2411
|
+
*
|
|
2412
|
+
* @param root - remote absolute POSIX directory (the session cwd).
|
|
2413
|
+
* @param paths - paths relative to `root`.
|
|
2414
|
+
* @param capBytes - per-file cap; larger files are reported `big`.
|
|
2415
|
+
* @param totalCapBytes - cap on the sum of transferred content bytes.
|
|
2416
|
+
*/
|
|
2417
|
+
function readWorktreeCommand(root, paths, capBytes, totalCapBytes) {
|
|
2418
|
+
return [
|
|
2419
|
+
"set -u",
|
|
2420
|
+
`command -v base64 >/dev/null 2>&1 || exit ${READ_EXIT.noBase64}`,
|
|
2421
|
+
cdLine(root),
|
|
2422
|
+
`rvs_cap=${capOf(capBytes)}`,
|
|
2423
|
+
`rvs_ttl=${capOf(totalCapBytes)}`,
|
|
2424
|
+
"rvs_total=0",
|
|
2425
|
+
"# rvs_one <base64(path)> <path>: emit one @@RVS-FILE block.",
|
|
2426
|
+
"rvs_one() {",
|
|
2427
|
+
" rvs_b=$1",
|
|
2428
|
+
" rvs_p=$2",
|
|
2429
|
+
" rvs_src=missing",
|
|
2430
|
+
" rvs_n=0",
|
|
2431
|
+
" if [ -L \"$rvs_p\" ]; then",
|
|
2432
|
+
" if rvs_v=$(readlink -- \"$rvs_p\" 2>/dev/null); then",
|
|
2433
|
+
" rvs_src=symlink",
|
|
2434
|
+
" # Bytes, not characters: the caps below are byte budgets.",
|
|
2435
|
+
" rvs_n=$(printf \"%s\" \"$rvs_v\" | wc -c | tr -d \" \")",
|
|
2436
|
+
" fi",
|
|
2437
|
+
" elif [ -f \"$rvs_p\" ] && [ -r \"$rvs_p\" ]; then",
|
|
2438
|
+
" rvs_n=$(wc -c < \"$rvs_p\" 2>/dev/null | tr -d \" \")",
|
|
2439
|
+
" case $rvs_n in",
|
|
2440
|
+
" \"\"|*[!0-9]*) rvs_n=0 ;;",
|
|
2441
|
+
" *) rvs_src=file ;;",
|
|
2442
|
+
" esac",
|
|
2443
|
+
" fi",
|
|
2444
|
+
" if [ \"$rvs_src\" = missing ]; then",
|
|
2445
|
+
" printf '@@RVS-FILE %s %s 0 0@@\\n' \"$rvs_b\" absent",
|
|
2446
|
+
" return 0",
|
|
2447
|
+
" fi",
|
|
2448
|
+
" # mtime is what lets a caller rule out \"unchanged\" for a file whose bytes",
|
|
2449
|
+
" # were withheld by the caps below.",
|
|
2450
|
+
" rvs_m=$(stat -c %Y -- \"$rvs_p\" 2>/dev/null || echo 0)",
|
|
2451
|
+
" case $rvs_m in \"\"|*[!0-9]*) rvs_m=0 ;; esac",
|
|
2452
|
+
" if [ \"$rvs_n\" -gt \"$rvs_cap\" ] || [ \"$((rvs_total + rvs_n))\" -gt \"$rvs_ttl\" ]; then",
|
|
2453
|
+
" printf '@@RVS-FILE %s %s %s %s@@\\n' \"$rvs_b\" big \"$rvs_m\" \"$rvs_n\"",
|
|
2454
|
+
" return 0",
|
|
2455
|
+
" fi",
|
|
2456
|
+
" if [ \"$rvs_src\" = symlink ]; then",
|
|
2457
|
+
" rvs_e=$(printf \"%s\" \"$rvs_v\" | base64 | tr -d \"\\n\")",
|
|
2458
|
+
" else",
|
|
2459
|
+
" rvs_e=$(base64 < \"$rvs_p\" 2>/dev/null | tr -d \"\\n\")",
|
|
2460
|
+
" # A non-empty file that produced no bytes vanished under us.",
|
|
2461
|
+
" if [ -z \"$rvs_e\" ] && [ \"$rvs_n\" -gt 0 ]; then",
|
|
2462
|
+
" printf '@@RVS-FILE %s %s 0 0@@\\n' \"$rvs_b\" absent",
|
|
2463
|
+
" return 0",
|
|
2464
|
+
" fi",
|
|
2465
|
+
" fi",
|
|
2466
|
+
" printf '@@RVS-FILE %s %s %s %s@@\\n%s\\n' \"$rvs_b\" ok \"$rvs_m\" \"$rvs_n\" \"$rvs_e\"",
|
|
2467
|
+
" rvs_total=$((rvs_total + rvs_n))",
|
|
2468
|
+
" return 0",
|
|
2469
|
+
"}",
|
|
2470
|
+
...paths.map((path) => `rvs_one ${shq(pathToken(path))} ${shq(path)}`)
|
|
2471
|
+
].join("\n");
|
|
2472
|
+
}
|
|
2473
|
+
/**
|
|
2474
|
+
* Read several `HEAD:<path>` blobs in one remote run — the "before" side of a
|
|
2475
|
+
* file that was already committed and clean when the session started.
|
|
2476
|
+
*
|
|
2477
|
+
* `git cat-file -s` answers existence *and* size before any bytes move, so the
|
|
2478
|
+
* caps are exact, and a path that is not in `HEAD` (a new file, a repository
|
|
2479
|
+
* with no commit yet, a submodule gitlink) is reported `absent` rather than
|
|
2480
|
+
* failing the whole read. `cat-file blob` is used instead of `show` on purpose:
|
|
2481
|
+
* no textconv, no filters, no pager — the bytes git will diff.
|
|
2482
|
+
*
|
|
2483
|
+
* @param root - remote absolute POSIX directory (the session cwd).
|
|
2484
|
+
* @param paths - paths relative to `root`.
|
|
2485
|
+
* @param capBytes - per-file cap; larger blobs are reported `big`.
|
|
2486
|
+
* @param totalCapBytes - cap on the sum of transferred content bytes.
|
|
2487
|
+
*/
|
|
2488
|
+
function readHeadCommand(root, paths, capBytes, totalCapBytes) {
|
|
2489
|
+
return [
|
|
2490
|
+
"set -u",
|
|
2491
|
+
`command -v base64 >/dev/null 2>&1 || exit ${READ_EXIT.noBase64}`,
|
|
2492
|
+
cdLine(root),
|
|
2493
|
+
`rvs_cap=${capOf(capBytes)}`,
|
|
2494
|
+
`rvs_ttl=${capOf(totalCapBytes)}`,
|
|
2495
|
+
"rvs_total=0",
|
|
2496
|
+
"# rvs_one <base64(path)> <path>: emit one @@RVS-FILE block.",
|
|
2497
|
+
"rvs_one() {",
|
|
2498
|
+
" rvs_b=$1",
|
|
2499
|
+
" rvs_p=$2",
|
|
2500
|
+
" rvs_n=",
|
|
2501
|
+
" if ! rvs_n=$(git cat-file -s \"HEAD:$rvs_p\" 2>/dev/null); then",
|
|
2502
|
+
" printf '@@RVS-FILE %s absent 0 0@@\\n' \"$rvs_b\"",
|
|
2503
|
+
" return 0",
|
|
2504
|
+
" fi",
|
|
2505
|
+
" rvs_n=$(printf \"%s\" \"$rvs_n\" | tr -d \" \")",
|
|
2506
|
+
" case $rvs_n in",
|
|
2507
|
+
" \"\"|*[!0-9]*)",
|
|
2508
|
+
" printf '@@RVS-FILE %s absent 0 0@@\\n' \"$rvs_b\"",
|
|
2509
|
+
" return 0",
|
|
2510
|
+
" ;;",
|
|
2511
|
+
" esac",
|
|
2512
|
+
" if [ \"$rvs_n\" -gt \"$rvs_cap\" ] || [ \"$((rvs_total + rvs_n))\" -gt \"$rvs_ttl\" ]; then",
|
|
2513
|
+
" printf '@@RVS-FILE %s big 0 %s@@\\n' \"$rvs_b\" \"$rvs_n\"",
|
|
2514
|
+
" return 0",
|
|
2515
|
+
" fi",
|
|
2516
|
+
" rvs_e=$(git cat-file blob \"HEAD:$rvs_p\" 2>/dev/null | base64 | tr -d \"\\n\")",
|
|
2517
|
+
" # Non-empty blob but no bytes decoded: not a blob (a tree or a gitlink).",
|
|
2518
|
+
" if [ -z \"$rvs_e\" ] && [ \"$rvs_n\" -gt 0 ]; then",
|
|
2519
|
+
" printf '@@RVS-FILE %s absent 0 0@@\\n' \"$rvs_b\"",
|
|
2520
|
+
" return 0",
|
|
2521
|
+
" fi",
|
|
2522
|
+
" printf '@@RVS-FILE %s ok 0 %s@@\\n%s\\n' \"$rvs_b\" \"$rvs_n\" \"$rvs_e\"",
|
|
2523
|
+
" rvs_total=$((rvs_total + rvs_n))",
|
|
2524
|
+
" return 0",
|
|
2525
|
+
"}",
|
|
2526
|
+
...paths.map((path) => `rvs_one ${shq(pathToken(path))} ${shq(path)}`)
|
|
2527
|
+
].join("\n");
|
|
2528
|
+
}
|
|
2529
|
+
/**
|
|
2530
|
+
* Name every immediate subdirectory that is a git repository.
|
|
2531
|
+
*
|
|
2532
|
+
* A session working directory is often a CONTAINER for repositories (the
|
|
2533
|
+
* reference host has `/home/xiray/dsh_workbase` holding `emv-toolbox-web`), and
|
|
2534
|
+
* `git status` at the container answers 128. This is a one-level `ls`-style
|
|
2535
|
+
* probe — bounded work, no `find` — so it can never become the expensive part
|
|
2536
|
+
* of a scan.
|
|
2537
|
+
*
|
|
2538
|
+
* It prints ALL matches on purpose: a workspace with several repositories has no
|
|
2539
|
+
* single right answer, and the caller must refuse to guess rather than pick the
|
|
2540
|
+
* first one. The SCM panel's explicit repository directory is the real answer.
|
|
2541
|
+
*
|
|
2542
|
+
* @param root - remote absolute POSIX directory (the session cwd).
|
|
2543
|
+
*/
|
|
2544
|
+
function reposBelowCommand(root) {
|
|
2545
|
+
return [
|
|
2546
|
+
"set -u",
|
|
2547
|
+
`cd ${shq(root)} || exit ${READ_EXIT.noRoot}`,
|
|
2548
|
+
"for rvs_d in */; do",
|
|
2549
|
+
" [ -d \"$rvs_d/.git\" ] || continue",
|
|
2550
|
+
" printf '%s\\n' \"${rvs_d%/}\"",
|
|
2551
|
+
"done"
|
|
2552
|
+
].join("\n");
|
|
2553
|
+
}
|
|
2554
|
+
/**
|
|
2555
|
+
* Parse a {@link readWorktreeCommand} / {@link readHeadCommand} reply.
|
|
2556
|
+
*
|
|
2557
|
+
* Keys are the decoded paths, exactly as requested. A marker without its
|
|
2558
|
+
* content line can only mean the reply was cut short (a timeout killing the
|
|
2559
|
+
* pipe); such a path is reported truncated rather than as an empty file, so an
|
|
2560
|
+
* aborted read never masquerades as "the file is empty".
|
|
2561
|
+
*
|
|
2562
|
+
* @param stdout - raw reply of either read command.
|
|
2563
|
+
*/
|
|
2564
|
+
function parseReads(stdout) {
|
|
2565
|
+
const results = /* @__PURE__ */ new Map();
|
|
2566
|
+
const lines = stdout.split("\n");
|
|
2567
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
2568
|
+
const line = lines[index];
|
|
2569
|
+
if (line === void 0 || !line.startsWith(FILE_MARKER)) continue;
|
|
2570
|
+
const fields = line.slice(11);
|
|
2571
|
+
if (!fields.endsWith("@@")) continue;
|
|
2572
|
+
const parts = fields.slice(0, -2).split(" ");
|
|
2573
|
+
if (parts.length < 2) continue;
|
|
2574
|
+
const token = parts[0] ?? "";
|
|
2575
|
+
const flags = parts[1] ?? "";
|
|
2576
|
+
const path = Buffer.from(token, "base64").toString("utf8");
|
|
2577
|
+
if (path === "") continue;
|
|
2578
|
+
const mtime = parts.length > 3 ? Number.parseInt(parts[2] ?? "", 10) : NaN;
|
|
2579
|
+
const size = parts.length > 3 ? Number.parseInt(parts[3] ?? "", 10) : NaN;
|
|
2580
|
+
const stats = {
|
|
2581
|
+
...Number.isSafeInteger(mtime) && mtime > 0 ? { mtime } : {},
|
|
2582
|
+
...Number.isSafeInteger(size) && size >= 0 ? { size } : {}
|
|
2583
|
+
};
|
|
2584
|
+
if (flags !== "ok") {
|
|
2585
|
+
results.set(path, flags === "big" ? {
|
|
2586
|
+
text: null,
|
|
2587
|
+
truncated: true,
|
|
2588
|
+
binary: false,
|
|
2589
|
+
absent: false,
|
|
2590
|
+
...stats
|
|
2591
|
+
} : {
|
|
2592
|
+
text: null,
|
|
2593
|
+
truncated: false,
|
|
2594
|
+
binary: false,
|
|
2595
|
+
absent: true,
|
|
2596
|
+
...stats
|
|
2597
|
+
});
|
|
2598
|
+
continue;
|
|
2599
|
+
}
|
|
2600
|
+
const encoded = lines[index + 1];
|
|
2601
|
+
if (encoded === void 0 || index + 1 === lines.length - 1) {
|
|
2602
|
+
results.set(path, {
|
|
2603
|
+
text: null,
|
|
2604
|
+
truncated: true,
|
|
2605
|
+
binary: false,
|
|
2606
|
+
absent: false,
|
|
2607
|
+
...stats
|
|
2608
|
+
});
|
|
2609
|
+
break;
|
|
2610
|
+
}
|
|
2611
|
+
index += 1;
|
|
2612
|
+
const bytes = Buffer.from(encoded, "base64");
|
|
2613
|
+
if (bytes.includes(0)) {
|
|
2614
|
+
results.set(path, {
|
|
2615
|
+
text: null,
|
|
2616
|
+
truncated: false,
|
|
2617
|
+
binary: true,
|
|
2618
|
+
absent: false,
|
|
2619
|
+
...stats
|
|
2620
|
+
});
|
|
2621
|
+
continue;
|
|
2622
|
+
}
|
|
2623
|
+
results.set(path, {
|
|
2624
|
+
text: bytes.toString("utf8"),
|
|
2625
|
+
truncated: false,
|
|
2626
|
+
binary: false,
|
|
2627
|
+
absent: false,
|
|
2628
|
+
...stats
|
|
2629
|
+
});
|
|
2630
|
+
}
|
|
2631
|
+
return results;
|
|
2632
|
+
}
|
|
2633
|
+
//#endregion
|
|
2634
|
+
//#region src/changes/text-diff.ts
|
|
2635
|
+
/** How far Myers may wander before the comparison degrades to "all replaced". */
|
|
2636
|
+
const MAX_EDIT_DISTANCE = 2e3;
|
|
2637
|
+
/**
|
|
2638
|
+
* Split text into lines the way git does: `\n` is the separator, the last one
|
|
2639
|
+
* does not create an empty line, and `\r` stays part of the line.
|
|
2640
|
+
*/
|
|
2641
|
+
function splitLines(text) {
|
|
2642
|
+
if (text === "") return [];
|
|
2643
|
+
const lines = text.split("\n");
|
|
2644
|
+
if (lines[lines.length - 1] === "") lines.pop();
|
|
2645
|
+
return lines;
|
|
2646
|
+
}
|
|
2647
|
+
/** Diff two line arrays into an edit script; `undefined` when the cap was hit. */
|
|
2648
|
+
function editScript(before, after) {
|
|
2649
|
+
const prefix = [];
|
|
2650
|
+
let start = 0;
|
|
2651
|
+
const shortest = Math.min(before.length, after.length);
|
|
2652
|
+
while (start < shortest && before[start] === after[start]) {
|
|
2653
|
+
prefix.push({
|
|
2654
|
+
kind: "=",
|
|
2655
|
+
text: before[start] ?? ""
|
|
2656
|
+
});
|
|
2657
|
+
start += 1;
|
|
2658
|
+
}
|
|
2659
|
+
const suffix = [];
|
|
2660
|
+
let beforeEnd = before.length - 1;
|
|
2661
|
+
let afterEnd = after.length - 1;
|
|
2662
|
+
while (beforeEnd >= start && afterEnd >= start && before[beforeEnd] === after[afterEnd]) {
|
|
2663
|
+
suffix.push({
|
|
2664
|
+
kind: "=",
|
|
2665
|
+
text: before[beforeEnd] ?? ""
|
|
2666
|
+
});
|
|
2667
|
+
beforeEnd -= 1;
|
|
2668
|
+
afterEnd -= 1;
|
|
2669
|
+
}
|
|
2670
|
+
suffix.reverse();
|
|
2671
|
+
const middle = myers(before.slice(start, beforeEnd + 1), after.slice(start, afterEnd + 1));
|
|
2672
|
+
if (middle === void 0) return void 0;
|
|
2673
|
+
return [
|
|
2674
|
+
...prefix,
|
|
2675
|
+
...middle,
|
|
2676
|
+
...suffix
|
|
2677
|
+
];
|
|
2678
|
+
}
|
|
2679
|
+
/**
|
|
2680
|
+
* Myers' greedy algorithm with a per-level trace, so the path can be walked back.
|
|
2681
|
+
*
|
|
2682
|
+
* `trace[d]` holds the furthest x per diagonal after `d - 1` steps, which is
|
|
2683
|
+
* exactly what the backtrack at distance `d` reads (`prevK` is always a
|
|
2684
|
+
* diagonal reachable in `d - 1` steps). Only the diagonals a level touches are
|
|
2685
|
+
* copied, which keeps the trace at O(d²) instead of O(d · (n + m)).
|
|
2686
|
+
*/
|
|
2687
|
+
function myers(before, after) {
|
|
2688
|
+
const n = before.length;
|
|
2689
|
+
const m = after.length;
|
|
2690
|
+
if (n === 0) return after.map((text) => ({
|
|
2691
|
+
kind: "+",
|
|
2692
|
+
text
|
|
2693
|
+
}));
|
|
2694
|
+
if (m === 0) return before.map((text) => ({
|
|
2695
|
+
kind: "-",
|
|
2696
|
+
text
|
|
2697
|
+
}));
|
|
2698
|
+
const limit = Math.min(n + m, MAX_EDIT_DISTANCE);
|
|
2699
|
+
const offset = limit + 2;
|
|
2700
|
+
const furthest = new Int32Array(2 * offset + 5).fill(-1);
|
|
2701
|
+
furthest[1 + offset] = 0;
|
|
2702
|
+
const trace = [];
|
|
2703
|
+
let distance = -1;
|
|
2704
|
+
for (let d = 0; d <= limit && distance < 0; d += 1) {
|
|
2705
|
+
const base = -(d + 1);
|
|
2706
|
+
const snapshot = new Int32Array(2 * d + 3);
|
|
2707
|
+
for (let k = base; k <= d + 1; k += 1) snapshot[k - base] = furthest[k + offset] ?? -1;
|
|
2708
|
+
trace.push(snapshot);
|
|
2709
|
+
for (let k = -d; k <= d; k += 2) {
|
|
2710
|
+
let x;
|
|
2711
|
+
if (k === -d || k !== d && (furthest[k - 1 + offset] ?? -1) < (furthest[k + 1 + offset] ?? -1)) x = furthest[k + 1 + offset] ?? -1;
|
|
2712
|
+
else x = (furthest[k - 1 + offset] ?? -1) + 1;
|
|
2713
|
+
let y = x - k;
|
|
2714
|
+
while (x < n && y < m && before[x] === after[y]) {
|
|
2715
|
+
x += 1;
|
|
2716
|
+
y += 1;
|
|
2717
|
+
}
|
|
2718
|
+
furthest[k + offset] = x;
|
|
2719
|
+
if (x >= n && y >= m) {
|
|
2720
|
+
distance = d;
|
|
2721
|
+
break;
|
|
2722
|
+
}
|
|
2723
|
+
}
|
|
2724
|
+
}
|
|
2725
|
+
if (distance < 0) return void 0;
|
|
2726
|
+
return backtrack(trace, before, after, distance);
|
|
2727
|
+
}
|
|
2728
|
+
/** Walk a completed Myers trace back into an edit script, in file order. */
|
|
2729
|
+
function backtrack(trace, before, after, distance) {
|
|
2730
|
+
const ops = [];
|
|
2731
|
+
let x = before.length;
|
|
2732
|
+
let y = after.length;
|
|
2733
|
+
for (let d = distance; d > 0; d -= 1) {
|
|
2734
|
+
const snapshot = trace[d];
|
|
2735
|
+
if (snapshot === void 0) break;
|
|
2736
|
+
const base = -(d + 1);
|
|
2737
|
+
const at = (k) => snapshot[k - base] ?? -1;
|
|
2738
|
+
const k = x - y;
|
|
2739
|
+
const prevK = k === -d || k !== d && at(k - 1) < at(k + 1) ? k + 1 : k - 1;
|
|
2740
|
+
const prevX = at(prevK);
|
|
2741
|
+
const prevY = prevX - prevK;
|
|
2742
|
+
while (x > prevX && y > prevY) {
|
|
2743
|
+
ops.push({
|
|
2744
|
+
kind: "=",
|
|
2745
|
+
text: before[x - 1] ?? ""
|
|
2746
|
+
});
|
|
2747
|
+
x -= 1;
|
|
2748
|
+
y -= 1;
|
|
2749
|
+
}
|
|
2750
|
+
if (x === prevX) {
|
|
2751
|
+
ops.push({
|
|
2752
|
+
kind: "+",
|
|
2753
|
+
text: after[y - 1] ?? ""
|
|
2754
|
+
});
|
|
2755
|
+
y -= 1;
|
|
2756
|
+
} else {
|
|
2757
|
+
ops.push({
|
|
2758
|
+
kind: "-",
|
|
2759
|
+
text: before[x - 1] ?? ""
|
|
2760
|
+
});
|
|
2761
|
+
x -= 1;
|
|
2762
|
+
}
|
|
2763
|
+
}
|
|
2764
|
+
while (x > 0 && y > 0) {
|
|
2765
|
+
ops.push({
|
|
2766
|
+
kind: "=",
|
|
2767
|
+
text: before[x - 1] ?? ""
|
|
2768
|
+
});
|
|
2769
|
+
x -= 1;
|
|
2770
|
+
y -= 1;
|
|
2771
|
+
}
|
|
2772
|
+
while (x > 0) {
|
|
2773
|
+
ops.push({
|
|
2774
|
+
kind: "-",
|
|
2775
|
+
text: before[x - 1] ?? ""
|
|
2776
|
+
});
|
|
2777
|
+
x -= 1;
|
|
2778
|
+
}
|
|
2779
|
+
while (y > 0) {
|
|
2780
|
+
ops.push({
|
|
2781
|
+
kind: "+",
|
|
2782
|
+
text: after[y - 1] ?? ""
|
|
2783
|
+
});
|
|
2784
|
+
y -= 1;
|
|
2785
|
+
}
|
|
2786
|
+
ops.reverse();
|
|
2787
|
+
return ops;
|
|
2788
|
+
}
|
|
2789
|
+
/** Compare two texts into git-shaped hunks (default 3 lines of context). */
|
|
2790
|
+
function diffText(before, after, context = 3) {
|
|
2791
|
+
const span = Number.isFinite(context) ? Math.max(0, Math.floor(context)) : 3;
|
|
2792
|
+
if (before === after) return {
|
|
2793
|
+
hunks: [],
|
|
2794
|
+
added: 0,
|
|
2795
|
+
deleted: 0
|
|
2796
|
+
};
|
|
2797
|
+
const oldLines = splitLines(before);
|
|
2798
|
+
const newLines = splitLines(after);
|
|
2799
|
+
const beforeNoEol = before !== "" && !before.endsWith("\n");
|
|
2800
|
+
const afterNoEol = after !== "" && !after.endsWith("\n");
|
|
2801
|
+
const ops = editScript(oldLines, newLines) ?? [...oldLines.map((text) => ({
|
|
2802
|
+
kind: "-",
|
|
2803
|
+
text
|
|
2804
|
+
})), ...newLines.map((text) => ({
|
|
2805
|
+
kind: "+",
|
|
2806
|
+
text
|
|
2807
|
+
}))];
|
|
2808
|
+
markTrailingNewline(ops, beforeNoEol, afterNoEol);
|
|
2809
|
+
return buildHunks(ops, span, beforeNoEol, afterNoEol);
|
|
2810
|
+
}
|
|
2811
|
+
/**
|
|
2812
|
+
* Make "this line's final newline changed" visible to the diff.
|
|
2813
|
+
*
|
|
2814
|
+
* `splitLines` drops the terminator, so a line without one is indistinguishable
|
|
2815
|
+
* from the same line with one — yet to git they are different lines, and it
|
|
2816
|
+
* prints such a pair once removed (with the marker) and once added:
|
|
2817
|
+
*
|
|
2818
|
+
* ```
|
|
2819
|
+
* before "a" after "a\nb\n" => -a (no eol), +a, +b
|
|
2820
|
+
* before "a\nb\n" after "a\nb" => a, -b, +b (no eol)
|
|
2821
|
+
* ```
|
|
2822
|
+
*
|
|
2823
|
+
* The old file's final line can only be byte-equal to a new-side line when that
|
|
2824
|
+
* line is the new file's final line too *and* both sides agree about the EOL, so
|
|
2825
|
+
* any other match against it must be broken into a removal plus an addition.
|
|
2826
|
+
*/
|
|
2827
|
+
function markTrailingNewline(ops, beforeNoEol, afterNoEol) {
|
|
2828
|
+
if (!beforeNoEol && !afterNoEol) return;
|
|
2829
|
+
const oldLast = lastIndexOfKind(ops, (kind) => kind !== "+");
|
|
2830
|
+
const newLast = lastIndexOfKind(ops, (kind) => kind !== "-");
|
|
2831
|
+
const oldOp = ops[oldLast];
|
|
2832
|
+
if (oldOp !== void 0 && oldOp.kind === "=" && beforeNoEol && !(oldLast === newLast && afterNoEol)) {
|
|
2833
|
+
ops.splice(oldLast, 1, {
|
|
2834
|
+
kind: "-",
|
|
2835
|
+
text: oldOp.text
|
|
2836
|
+
}, {
|
|
2837
|
+
kind: "+",
|
|
2838
|
+
text: oldOp.text
|
|
2839
|
+
});
|
|
2840
|
+
return;
|
|
2841
|
+
}
|
|
2842
|
+
const newOp = ops[newLast];
|
|
2843
|
+
if (newOp !== void 0 && newOp.kind === "=" && afterNoEol && !(newLast === oldLast && beforeNoEol)) ops.splice(newLast, 1, {
|
|
2844
|
+
kind: "-",
|
|
2845
|
+
text: newOp.text
|
|
2846
|
+
}, {
|
|
2847
|
+
kind: "+",
|
|
2848
|
+
text: newOp.text
|
|
2849
|
+
});
|
|
2850
|
+
}
|
|
2851
|
+
/** Group an edit script into hunks of `context` lines around each change. */
|
|
2852
|
+
function buildHunks(ops, context, beforeNoEol, afterNoEol) {
|
|
2853
|
+
/** Old-side line number just before op `i`. */
|
|
2854
|
+
const oldBefore = new Array(ops.length + 1).fill(0);
|
|
2855
|
+
/** New-side line number just before op `i`. */
|
|
2856
|
+
const newBefore = new Array(ops.length + 1).fill(0);
|
|
2857
|
+
let oldCount = 0;
|
|
2858
|
+
let newCount = 0;
|
|
2859
|
+
for (let index = 0; index < ops.length; index += 1) {
|
|
2860
|
+
oldBefore[index] = oldCount;
|
|
2861
|
+
newBefore[index] = newCount;
|
|
2862
|
+
const kind = ops[index]?.kind;
|
|
2863
|
+
if (kind !== "+") oldCount += 1;
|
|
2864
|
+
if (kind !== "-") newCount += 1;
|
|
2865
|
+
}
|
|
2866
|
+
oldBefore[ops.length] = oldCount;
|
|
2867
|
+
newBefore[ops.length] = newCount;
|
|
2868
|
+
const changes = [];
|
|
2869
|
+
for (let index = 0; index < ops.length; index += 1) if (ops[index]?.kind !== "=") changes.push(index);
|
|
2870
|
+
if (changes.length === 0) return {
|
|
2871
|
+
hunks: [],
|
|
2872
|
+
added: 0,
|
|
2873
|
+
deleted: 0
|
|
2874
|
+
};
|
|
2875
|
+
const groups = [];
|
|
2876
|
+
let groupStart = changes[0] ?? 0;
|
|
2877
|
+
let groupEnd = groupStart;
|
|
2878
|
+
for (let index = 1; index < changes.length; index += 1) {
|
|
2879
|
+
const next = changes[index] ?? groupEnd;
|
|
2880
|
+
if (next - groupEnd - 1 <= 2 * context) {
|
|
2881
|
+
groupEnd = next;
|
|
2882
|
+
continue;
|
|
2883
|
+
}
|
|
2884
|
+
groups.push([groupStart, groupEnd]);
|
|
2885
|
+
groupStart = next;
|
|
2886
|
+
groupEnd = next;
|
|
2887
|
+
}
|
|
2888
|
+
groups.push([groupStart, groupEnd]);
|
|
2889
|
+
const lastOldOp = lastIndexOfKind(ops, (kind) => kind !== "+");
|
|
2890
|
+
const lastNewOp = lastIndexOfKind(ops, (kind) => kind !== "-");
|
|
2891
|
+
const hunks = [];
|
|
2892
|
+
let added = 0;
|
|
2893
|
+
let deleted = 0;
|
|
2894
|
+
for (const [from, to] of groups) {
|
|
2895
|
+
const start = Math.max(0, from - context);
|
|
2896
|
+
const end = Math.min(ops.length - 1, to + context);
|
|
2897
|
+
const oldStartIndex = oldBefore[start] ?? 0;
|
|
2898
|
+
const newStartIndex = newBefore[start] ?? 0;
|
|
2899
|
+
const oldLines = (oldBefore[end + 1] ?? 0) - oldStartIndex;
|
|
2900
|
+
const newLines = (newBefore[end + 1] ?? 0) - newStartIndex;
|
|
2901
|
+
const lines = [];
|
|
2902
|
+
for (let index = start; index <= end; index += 1) {
|
|
2903
|
+
const op = ops[index];
|
|
2904
|
+
if (op === void 0) continue;
|
|
2905
|
+
lines.push((op.kind === "=" ? " " : op.kind) + op.text);
|
|
2906
|
+
if (op.kind === "+") added += 1;
|
|
2907
|
+
if (op.kind === "-") deleted += 1;
|
|
2908
|
+
}
|
|
2909
|
+
const hunk = {
|
|
2910
|
+
oldStart: oldLines === 0 ? oldStartIndex : oldStartIndex + 1,
|
|
2911
|
+
oldLines,
|
|
2912
|
+
newStart: newLines === 0 ? newStartIndex : newStartIndex + 1,
|
|
2913
|
+
newLines,
|
|
2914
|
+
lines
|
|
2915
|
+
};
|
|
2916
|
+
if (beforeNoEol && lastOldOp >= start && lastOldOp <= end) hunk.oldNoEol = true;
|
|
2917
|
+
if (afterNoEol && lastNewOp >= start && lastNewOp <= end) hunk.newNoEol = true;
|
|
2918
|
+
hunks.push(hunk);
|
|
2919
|
+
}
|
|
2920
|
+
return {
|
|
2921
|
+
hunks,
|
|
2922
|
+
added,
|
|
2923
|
+
deleted
|
|
2924
|
+
};
|
|
2925
|
+
}
|
|
2926
|
+
/** Index of the last op satisfying `predicate`, or -1. */
|
|
2927
|
+
function lastIndexOfKind(ops, predicate) {
|
|
2928
|
+
for (let index = ops.length - 1; index >= 0; index -= 1) {
|
|
2929
|
+
const kind = ops[index]?.kind;
|
|
2930
|
+
if (kind !== void 0 && predicate(kind)) return index;
|
|
2931
|
+
}
|
|
2932
|
+
return -1;
|
|
2933
|
+
}
|
|
2934
|
+
/**
|
|
2935
|
+
* Serialize one hunk as a patch `git apply` accepts (and `git apply -R` undoes).
|
|
2936
|
+
*
|
|
2937
|
+
* The `@@ -a,b +c,d @@` counts are always written out — git accepts the elided
|
|
2938
|
+
* `,1` and `,-0` spellings, but a reviewer reading a log is better served by the
|
|
2939
|
+
* explicit form, and one spelling means one code path to test.
|
|
2940
|
+
*
|
|
2941
|
+
* @param displayPath - repo-relative path, as the panel shows it.
|
|
2942
|
+
* @param hunk - one hunk of {@link diffText}.
|
|
2943
|
+
*/
|
|
2944
|
+
function hunkPatch(displayPath, hunk) {
|
|
2945
|
+
const out = [
|
|
2946
|
+
`--- a/${displayPath}`,
|
|
2947
|
+
`+++ b/${displayPath}`,
|
|
2948
|
+
`@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`
|
|
2949
|
+
];
|
|
2950
|
+
const markers = /* @__PURE__ */ new Set();
|
|
2951
|
+
if (hunk.oldNoEol === true) {
|
|
2952
|
+
const at = lastLineOfSide(hunk.lines, "-");
|
|
2953
|
+
if (at >= 0) markers.add(at);
|
|
2954
|
+
}
|
|
2955
|
+
if (hunk.newNoEol === true) {
|
|
2956
|
+
const at = lastLineOfSide(hunk.lines, "+");
|
|
2957
|
+
if (at >= 0) markers.add(at);
|
|
2958
|
+
}
|
|
2959
|
+
for (let index = 0; index < hunk.lines.length; index += 1) {
|
|
2960
|
+
out.push(hunk.lines[index] ?? "");
|
|
2961
|
+
if (markers.has(index)) out.push("\");
|
|
2962
|
+
}
|
|
2963
|
+
return `${out.join("\n")}\n`;
|
|
2964
|
+
}
|
|
2965
|
+
/**
|
|
2966
|
+
* Index of the last line in `lines` that belongs to the old side (`tag` is `'-'`)
|
|
2967
|
+
* or the new side (`tag` is `'+'`); context lines belong to both.
|
|
2968
|
+
*/
|
|
2969
|
+
function lastLineOfSide(lines, tag) {
|
|
2970
|
+
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
|
2971
|
+
const head = lines[index]?.charAt(0);
|
|
2972
|
+
if (head === " " || head === tag) return index;
|
|
2973
|
+
}
|
|
2974
|
+
return -1;
|
|
2975
|
+
}
|
|
2976
|
+
//#endregion
|
|
2977
|
+
//#region src/changes/recorder.ts
|
|
2978
|
+
/**
|
|
2979
|
+
* Map a scan entry onto the panel's vocabulary.
|
|
2980
|
+
*
|
|
2981
|
+
* `?` means "git does not track this path yet", which for a review panel is
|
|
2982
|
+
* exactly a new file — the same thing `A` says for a staged addition.
|
|
2983
|
+
*/
|
|
2984
|
+
function panelStatus(status) {
|
|
2985
|
+
return status === "?" ? "A" : status;
|
|
2986
|
+
}
|
|
2987
|
+
/** Grace added to an exec budget before the watchdog gives up on it. */
|
|
2988
|
+
const WATCHDOG_GRACE_MS = 5e3;
|
|
2989
|
+
/** Sentinel for "the host call never came back". */
|
|
2990
|
+
const WATCHDOG_FIRED = "watchdog-fired";
|
|
2991
|
+
/** POSIX join — `node:path` would apply Windows semantics on the host. */
|
|
2992
|
+
function posixJoin(base, rel) {
|
|
2993
|
+
if (rel.startsWith("/")) return rel;
|
|
2994
|
+
const left = base.endsWith("/") ? base.slice(0, -1) : base;
|
|
2995
|
+
const right = rel.startsWith("./") ? rel.slice(2) : rel;
|
|
2996
|
+
return right === "" ? left : `${left}/${right}`;
|
|
2997
|
+
}
|
|
2998
|
+
/**
|
|
2999
|
+
* Whether two reads prove the file did not move between them.
|
|
3000
|
+
*
|
|
3001
|
+
* The last resort for a path whose bytes never crossed the wire (over the size
|
|
3002
|
+
* cap, or binary): mtime and size are all that is left, and requiring BOTH keeps
|
|
3003
|
+
* the claim honest. mtime has one-second resolution, so an edit inside the same
|
|
3004
|
+
* second as the read is invisible here — which is why this is only ever used
|
|
3005
|
+
* where a content comparison is impossible.
|
|
3006
|
+
*/
|
|
3007
|
+
function sameStat(before, after) {
|
|
3008
|
+
if (before === void 0 || after === void 0) return false;
|
|
3009
|
+
if (before.mtime === void 0 || after.mtime === void 0) return false;
|
|
3010
|
+
if (before.size === void 0 || after.size === void 0) return false;
|
|
3011
|
+
return before.mtime === after.mtime && before.size === after.size;
|
|
3012
|
+
}
|
|
3013
|
+
var SessionChanges = class {
|
|
3014
|
+
ledgers = /* @__PURE__ */ new Map();
|
|
3015
|
+
deps;
|
|
3016
|
+
disposed = false;
|
|
3017
|
+
constructor(deps) {
|
|
3018
|
+
this.deps = deps;
|
|
3019
|
+
}
|
|
3020
|
+
/** Open (or reuse) the ledger for one session at the start of a turn. */
|
|
3021
|
+
beginTurn(target) {
|
|
3022
|
+
if (this.disposed) return;
|
|
3023
|
+
const ledger = this.ensure(target);
|
|
3024
|
+
if (ledger === void 0) return;
|
|
3025
|
+
if (!ledger.baselineTaken) this.schedule(ledger);
|
|
3026
|
+
}
|
|
3027
|
+
/** Refresh the ledger at the end of a turn. */
|
|
3028
|
+
endTurn(target, turn) {
|
|
3029
|
+
if (this.disposed) return;
|
|
3030
|
+
const ledger = this.ensure(target);
|
|
3031
|
+
if (ledger === void 0) return;
|
|
3032
|
+
ledger.turn = Math.max(ledger.turn, turn);
|
|
3033
|
+
this.schedule(ledger);
|
|
3034
|
+
}
|
|
3035
|
+
/** The current summary for one session, or null when it has no ledger. */
|
|
3036
|
+
summary(sessionId) {
|
|
3037
|
+
const ledger = this.ledgers.get(sessionId);
|
|
3038
|
+
if (ledger === void 0) return null;
|
|
3039
|
+
return {
|
|
3040
|
+
alias: ledger.alias,
|
|
3041
|
+
root: ledger.root,
|
|
3042
|
+
turn: ledger.turn,
|
|
3043
|
+
files: ledger.files,
|
|
3044
|
+
added: ledger.added,
|
|
3045
|
+
deleted: ledger.deleted,
|
|
3046
|
+
at: ledger.at,
|
|
3047
|
+
pending: ledger.untrackedJob !== void 0 || ledger.pending && ledger.files.length > 0,
|
|
3048
|
+
...ledger.truncated === true ? { truncated: true } : {},
|
|
3049
|
+
...ledger.unavailable === void 0 ? {} : { unavailable: ledger.unavailable }
|
|
3050
|
+
};
|
|
3051
|
+
}
|
|
3052
|
+
/**
|
|
3053
|
+
* Compare one listed file across the whole session.
|
|
3054
|
+
*
|
|
3055
|
+
* The `before` side comes from the session baseline, which never moves. The
|
|
3056
|
+
* `after` side is read from the host on every call rather than replayed from
|
|
3057
|
+
* the last scan: a revert, a save from the editor or a shell command all
|
|
3058
|
+
* change the file between scans, and replaying a cached copy against a fresh
|
|
3059
|
+
* buffer is exactly how the painted lines end up at the wrong places.
|
|
3060
|
+
*
|
|
3061
|
+
* @param sessionId - live session owning the ledger.
|
|
3062
|
+
* @param path - the file's remote absolute path (as listed in the summary).
|
|
3063
|
+
* @returns hunks for decorations, or null when the session/file is unknown.
|
|
3064
|
+
*/
|
|
3065
|
+
async diff(sessionId, path) {
|
|
3066
|
+
const ledger = this.ledgers.get(sessionId);
|
|
3067
|
+
if (ledger === void 0) return null;
|
|
3068
|
+
const record = ledger.records.get(path);
|
|
3069
|
+
if (record === void 0) return null;
|
|
3070
|
+
const status = record.status;
|
|
3071
|
+
let afterText = record.after;
|
|
3072
|
+
let binary = record.binary;
|
|
3073
|
+
let truncated = record.truncated;
|
|
3074
|
+
const fresh = await this.readPaths(ledger, [record.display], "worktree");
|
|
3075
|
+
if (ledger.disposed) return null;
|
|
3076
|
+
const side = fresh.get(record.display);
|
|
3077
|
+
if (side !== void 0) {
|
|
3078
|
+
binary = side.binary;
|
|
3079
|
+
truncated = side.truncated;
|
|
3080
|
+
afterText = side.absent ? null : side.text;
|
|
3081
|
+
if (afterText === null && !side.absent && !side.binary) afterText = record.after;
|
|
3082
|
+
}
|
|
3083
|
+
if (binary) return {
|
|
3084
|
+
kind: "binary",
|
|
3085
|
+
path,
|
|
3086
|
+
display: record.display,
|
|
3087
|
+
hunks: [],
|
|
3088
|
+
...status === void 0 ? {} : { status }
|
|
3089
|
+
};
|
|
3090
|
+
const hunks = diffText(record.before ?? "", afterText ?? "").hunks.map((hunk) => ({
|
|
3091
|
+
oldStart: hunk.oldStart,
|
|
3092
|
+
oldCount: hunk.oldLines,
|
|
3093
|
+
newStart: hunk.newStart,
|
|
3094
|
+
newCount: hunk.newLines,
|
|
3095
|
+
heading: "",
|
|
3096
|
+
lines: hunk.lines,
|
|
3097
|
+
patch: hunkPatch(record.display, hunk)
|
|
3098
|
+
}));
|
|
3099
|
+
if (hunks.length === 0) return {
|
|
3100
|
+
kind: "missing",
|
|
3101
|
+
path,
|
|
3102
|
+
display: record.display,
|
|
3103
|
+
hunks: [],
|
|
3104
|
+
...status === void 0 ? {} : { status }
|
|
3105
|
+
};
|
|
3106
|
+
return {
|
|
3107
|
+
kind: "text",
|
|
3108
|
+
path,
|
|
3109
|
+
display: record.display,
|
|
3110
|
+
hunks,
|
|
3111
|
+
...status === void 0 ? {} : { status },
|
|
3112
|
+
...truncated ? { truncated: true } : {}
|
|
3113
|
+
};
|
|
3114
|
+
}
|
|
3115
|
+
/** Resolve once every queued refresh has settled, including coalesced work. */
|
|
3116
|
+
async settled() {
|
|
3117
|
+
for (let guard = 0; guard < 100; guard += 1) {
|
|
3118
|
+
await Promise.all([...this.ledgers.values()].map((ledger) => ledger.queue));
|
|
3119
|
+
const jobs = [...this.ledgers.values()].map((ledger) => ledger.untrackedJob).filter((job) => job !== void 0);
|
|
3120
|
+
if (jobs.length > 0) await Promise.all(jobs);
|
|
3121
|
+
if (![...this.ledgers.values()].some((ledger) => ledger.pending || ledger.untrackedJob !== void 0)) return;
|
|
3122
|
+
}
|
|
3123
|
+
}
|
|
3124
|
+
/** Drop one session's ledger. */
|
|
3125
|
+
forget(sessionId) {
|
|
3126
|
+
const ledger = this.ledgers.get(sessionId);
|
|
3127
|
+
if (ledger === void 0) return;
|
|
3128
|
+
this.ledgers.delete(sessionId);
|
|
3129
|
+
ledger.disposed = true;
|
|
3130
|
+
}
|
|
3131
|
+
/** Release every ledger (plugin unmount). */
|
|
3132
|
+
dispose() {
|
|
3133
|
+
this.disposed = true;
|
|
3134
|
+
for (const sessionId of [...this.ledgers.keys()]) this.forget(sessionId);
|
|
3135
|
+
}
|
|
3136
|
+
/**
|
|
3137
|
+
* Force a fresh pass, ignoring the untracked cadence.
|
|
3138
|
+
*
|
|
3139
|
+
* The panel's refresh button calls this: the cadence exists so a slow scan
|
|
3140
|
+
* does not run on every turn, but "I clicked refresh" means the user wants
|
|
3141
|
+
* the truth now — including files that appeared seconds ago.
|
|
3142
|
+
*/
|
|
3143
|
+
rescan(sessionId) {
|
|
3144
|
+
const ledger = this.ledgers.get(sessionId);
|
|
3145
|
+
if (ledger === void 0 || ledger.disposed) return;
|
|
3146
|
+
ledger.untrackedAt = 0;
|
|
3147
|
+
this.schedule(ledger);
|
|
3148
|
+
}
|
|
3149
|
+
/**
|
|
3150
|
+
* Self-heal a ledger whose data has gone stale.
|
|
3151
|
+
*
|
|
3152
|
+
* The panel polls `list`, and a poll is the one moment we know a human is
|
|
3153
|
+
* looking at the answer. If the data is older than `maxAgeMs`, re-scan — so a
|
|
3154
|
+
* turn boundary this Host never delivered costs a few seconds of staleness
|
|
3155
|
+
* instead of a manual refresh.
|
|
3156
|
+
*
|
|
3157
|
+
* @returns whether a refresh was queued.
|
|
3158
|
+
*/
|
|
3159
|
+
touch(sessionId, maxAgeMs = 3e4) {
|
|
3160
|
+
const ledger = this.ledgers.get(sessionId);
|
|
3161
|
+
if (ledger === void 0 || ledger.disposed || ledger.pending) return false;
|
|
3162
|
+
if (ledger.at > 0 && Date.now() - ledger.at < maxAgeMs) return false;
|
|
3163
|
+
this.schedule(ledger);
|
|
3164
|
+
return true;
|
|
3165
|
+
}
|
|
3166
|
+
/**
|
|
3167
|
+
* Point one ledger at a subdirectory of the session root.
|
|
3168
|
+
*
|
|
3169
|
+
* This is the Source Control panel's "repository directory": an explicit
|
|
3170
|
+
* choice by the user, and therefore the authority whenever it is set. The
|
|
3171
|
+
* automatic one-level probe is only a fallback for a container workspace that
|
|
3172
|
+
* holds exactly one repository.
|
|
3173
|
+
*
|
|
3174
|
+
* @param subdir - workspace-relative directory, or undefined for the session
|
|
3175
|
+
* root itself. Absolute paths and `..` are refused: they would move the
|
|
3176
|
+
* ledger outside the workspace it was created for.
|
|
3177
|
+
*/
|
|
3178
|
+
useRepoSubdir(sessionId, subdir) {
|
|
3179
|
+
const ledger = this.ledgers.get(sessionId);
|
|
3180
|
+
if (ledger === void 0 || ledger.disposed) return;
|
|
3181
|
+
const clean = normalizeRepoSubdir(subdir);
|
|
3182
|
+
if (clean === ledger.subdir) return;
|
|
3183
|
+
ledger.subdir = clean;
|
|
3184
|
+
ledger.root = clean === "" ? ledger.baseRoot : `${ledger.baseRoot}/${clean}`;
|
|
3185
|
+
ledger.baseline = void 0;
|
|
3186
|
+
ledger.baselineTaken = false;
|
|
3187
|
+
ledger.adoptedSubdir = false;
|
|
3188
|
+
ledger.files = [];
|
|
3189
|
+
ledger.records = /* @__PURE__ */ new Map();
|
|
3190
|
+
ledger.added = 0;
|
|
3191
|
+
ledger.deleted = 0;
|
|
3192
|
+
ledger.at = 0;
|
|
3193
|
+
this.schedule(ledger);
|
|
3194
|
+
}
|
|
3195
|
+
/** Resolve the routing target once per session, caching the ledger. */
|
|
3196
|
+
ensure(target) {
|
|
3197
|
+
if (target.cwd.trim() === "") return void 0;
|
|
3198
|
+
const existing = this.ledgers.get(target.sessionId);
|
|
3199
|
+
if (existing !== void 0 && !existing.disposed && existing.root !== "") return existing;
|
|
3200
|
+
let route;
|
|
3201
|
+
try {
|
|
3202
|
+
route = this.deps.worlds.routeDir(target.cwd);
|
|
3203
|
+
} catch {
|
|
3204
|
+
return;
|
|
3205
|
+
}
|
|
3206
|
+
if (route.kind !== "remote") {
|
|
3207
|
+
this.ledgers.delete(target.sessionId);
|
|
3208
|
+
return;
|
|
3209
|
+
}
|
|
3210
|
+
const root = route.remotePath.length > 1 && route.remotePath.endsWith("/") ? route.remotePath.replace(/\/+$/, "") : route.remotePath;
|
|
3211
|
+
const ledger = {
|
|
3212
|
+
sessionId: target.sessionId,
|
|
3213
|
+
alias: route.alias,
|
|
3214
|
+
root,
|
|
3215
|
+
baseline: void 0,
|
|
3216
|
+
baselineTaken: false,
|
|
3217
|
+
untrackedGivenUp: false,
|
|
3218
|
+
startedAt: Date.now(),
|
|
3219
|
+
absorbed: false,
|
|
3220
|
+
adoptedSubdir: false,
|
|
3221
|
+
baseRoot: root,
|
|
3222
|
+
subdir: "",
|
|
3223
|
+
tracked: [],
|
|
3224
|
+
untracked: void 0,
|
|
3225
|
+
untrackedAt: 0,
|
|
3226
|
+
untrackedMs: void 0,
|
|
3227
|
+
untrackedJob: void 0,
|
|
3228
|
+
turn: 0,
|
|
3229
|
+
files: [],
|
|
3230
|
+
records: /* @__PURE__ */ new Map(),
|
|
3231
|
+
added: 0,
|
|
3232
|
+
deleted: 0,
|
|
3233
|
+
truncated: void 0,
|
|
3234
|
+
at: 0,
|
|
3235
|
+
unavailable: void 0,
|
|
3236
|
+
pending: false,
|
|
3237
|
+
queue: Promise.resolve(),
|
|
3238
|
+
dirty: false,
|
|
3239
|
+
disposed: false
|
|
3240
|
+
};
|
|
3241
|
+
this.ledgers.set(target.sessionId, ledger);
|
|
3242
|
+
return ledger;
|
|
3243
|
+
}
|
|
3244
|
+
/** Queue a refresh, coalescing bursts (a turn can end while one is running). */
|
|
3245
|
+
schedule(ledger) {
|
|
3246
|
+
if (ledger.disposed) return;
|
|
3247
|
+
if (ledger.pending) {
|
|
3248
|
+
ledger.dirty = true;
|
|
3249
|
+
return;
|
|
3250
|
+
}
|
|
3251
|
+
ledger.pending = true;
|
|
3252
|
+
ledger.queue = ledger.queue.then(() => this.refresh(ledger)).catch((error) => this.warn(`refresh failed: ${messageOf(error)}`)).finally(() => {
|
|
3253
|
+
ledger.pending = false;
|
|
3254
|
+
if (ledger.dirty && !ledger.disposed) {
|
|
3255
|
+
ledger.dirty = false;
|
|
3256
|
+
this.schedule(ledger);
|
|
3257
|
+
}
|
|
3258
|
+
});
|
|
3259
|
+
}
|
|
3260
|
+
/**
|
|
3261
|
+
* One refresh: the cheap scan first, then the slow one in the background.
|
|
3262
|
+
*
|
|
3263
|
+
* The split is not cosmetic. On the reference monorepo `git status -uno`
|
|
3264
|
+
* answers in ~3s while `git ls-files --others` needs over a minute, so a
|
|
3265
|
+
* single sequential refresh would leave the panel empty for that whole
|
|
3266
|
+
* minute even though the tracked changes were known after three seconds.
|
|
3267
|
+
* Phase one publishes, phase two adds the new files when (and if) it lands.
|
|
3268
|
+
*/
|
|
3269
|
+
async refresh(ledger) {
|
|
3270
|
+
if (ledger.disposed || ledger.root === "") return;
|
|
3271
|
+
const tracked = await this.scanTracked(ledger);
|
|
3272
|
+
if (ledger.disposed || tracked === void 0) return;
|
|
3273
|
+
ledger.tracked = tracked;
|
|
3274
|
+
await this.publish(ledger);
|
|
3275
|
+
this.kickUntracked(ledger);
|
|
3276
|
+
}
|
|
3277
|
+
/** Recompute the panel's list from the scans currently known. */
|
|
3278
|
+
async publish(ledger) {
|
|
3279
|
+
if (ledger.disposed) return;
|
|
3280
|
+
const merged = /* @__PURE__ */ new Map();
|
|
3281
|
+
for (const entry of ledger.tracked) merged.set(entry.path, entry);
|
|
3282
|
+
for (const entry of ledger.untracked ?? []) if (!merged.has(entry.path)) merged.set(entry.path, entry);
|
|
3283
|
+
const entries = [...merged.values()].sort((left, right) => left.path.localeCompare(right.path));
|
|
3284
|
+
const cap = this.deps.maxFiles ?? 500;
|
|
3285
|
+
const listed = entries.slice(0, cap);
|
|
3286
|
+
const truncated = entries.length > cap;
|
|
3287
|
+
if (!ledger.baselineTaken) {
|
|
3288
|
+
ledger.baseline = await this.readPaths(ledger, listed.map((entry) => entry.path), "worktree");
|
|
3289
|
+
if (ledger.disposed) return;
|
|
3290
|
+
ledger.baselineTaken = true;
|
|
3291
|
+
ledger.files = [];
|
|
3292
|
+
ledger.records = /* @__PURE__ */ new Map();
|
|
3293
|
+
ledger.added = 0;
|
|
3294
|
+
ledger.deleted = 0;
|
|
3295
|
+
ledger.truncated = truncated ? true : void 0;
|
|
3296
|
+
ledger.at = Date.now();
|
|
3297
|
+
return;
|
|
3298
|
+
}
|
|
3299
|
+
const baseline = ledger.baseline ?? /* @__PURE__ */ new Map();
|
|
3300
|
+
const fromHead = listed.filter((entry) => !baseline.has(entry.path)).map((entry) => entry.path);
|
|
3301
|
+
const head = fromHead.length === 0 ? /* @__PURE__ */ new Map() : await this.readPaths(ledger, fromHead, "head");
|
|
3302
|
+
const after = await this.readPaths(ledger, listed.map((entry) => entry.path), "worktree");
|
|
3303
|
+
if (ledger.disposed) return;
|
|
3304
|
+
const records = /* @__PURE__ */ new Map();
|
|
3305
|
+
const files = [];
|
|
3306
|
+
let added = 0;
|
|
3307
|
+
let deleted = 0;
|
|
3308
|
+
for (const entry of listed) {
|
|
3309
|
+
const beforeSide = baseline.get(entry.path) ?? head.get(entry.path);
|
|
3310
|
+
const afterSide = after.get(entry.path);
|
|
3311
|
+
const before = beforeSide?.text ?? null;
|
|
3312
|
+
const afterText = afterSide?.text ?? null;
|
|
3313
|
+
const binary = beforeSide?.binary === true || afterSide?.binary === true;
|
|
3314
|
+
const fileTruncated = beforeSide?.truncated === true || afterSide?.truncated === true;
|
|
3315
|
+
if (beforeSide?.absent === true && afterSide?.absent === true) continue;
|
|
3316
|
+
if (!binary && !fileTruncated && before === afterText) continue;
|
|
3317
|
+
if ((binary || fileTruncated) && sameStat(beforeSide, afterSide)) continue;
|
|
3318
|
+
const oversized = fileTruncated && !binary;
|
|
3319
|
+
const diff = binary || oversized ? {
|
|
3320
|
+
added: 0,
|
|
3321
|
+
deleted: 0
|
|
3322
|
+
} : diffText(before ?? "", afterText ?? "");
|
|
3323
|
+
const status = panelStatus(entry.status);
|
|
3324
|
+
const absolute = posixJoin(ledger.root, entry.path);
|
|
3325
|
+
records.set(absolute, {
|
|
3326
|
+
display: entry.path,
|
|
3327
|
+
path: absolute,
|
|
3328
|
+
status,
|
|
3329
|
+
before,
|
|
3330
|
+
after: afterText,
|
|
3331
|
+
binary,
|
|
3332
|
+
truncated: fileTruncated
|
|
3333
|
+
});
|
|
3334
|
+
added += diff.added;
|
|
3335
|
+
deleted += diff.deleted;
|
|
3336
|
+
files.push({
|
|
3337
|
+
path: absolute,
|
|
3338
|
+
display: entry.path,
|
|
3339
|
+
added: diff.added,
|
|
3340
|
+
deleted: diff.deleted,
|
|
3341
|
+
...binary ? { binary: true } : {},
|
|
3342
|
+
...oversized ? { oversized: true } : {},
|
|
3343
|
+
...status === void 0 ? {} : { status }
|
|
3344
|
+
});
|
|
3345
|
+
}
|
|
3346
|
+
files.sort((left, right) => left.display.localeCompare(right.display));
|
|
3347
|
+
ledger.records = records;
|
|
3348
|
+
ledger.files = files;
|
|
3349
|
+
ledger.added = added;
|
|
3350
|
+
ledger.deleted = deleted;
|
|
3351
|
+
ledger.truncated = truncated ? true : void 0;
|
|
3352
|
+
ledger.unavailable = listed.length > 0 && after.size === 0 ? "file contents could not be read from the host" : void 0;
|
|
3353
|
+
ledger.at = Date.now();
|
|
3354
|
+
}
|
|
3355
|
+
/**
|
|
3356
|
+
* The cheap scan: tracked files whose working-tree bytes differ from the
|
|
3357
|
+
* index (1.4s measured on the reference monorepo).
|
|
3358
|
+
*
|
|
3359
|
+
* A session working directory is often a CONTAINER for repositories rather
|
|
3360
|
+
* than one itself (the reference host has `/home/xiray/dsh_workbase` holding
|
|
3361
|
+
* `emv-toolbox-web`), and `git status` there answers exit 128 — "not a git
|
|
3362
|
+
* repository (or any parent up to mount point /)". When that happens the
|
|
3363
|
+
* ledger looks one level down once and adopts the repository it finds, which
|
|
3364
|
+
* is the same idea the SCM panel's `repoSubdir` expresses by hand.
|
|
3365
|
+
*
|
|
3366
|
+
* @returns entries relative to the ledger's root, or undefined when no
|
|
3367
|
+
* repository could be found (the ledger then reports why).
|
|
3368
|
+
*/
|
|
3369
|
+
async scanTracked(ledger) {
|
|
3370
|
+
const tracked = await this.run(ledger.alias, trackedStatusCommand(ledger.root));
|
|
3371
|
+
if (tracked.ok) {
|
|
3372
|
+
ledger.unavailable = void 0;
|
|
3373
|
+
return parseStatusZ(tracked.stdout);
|
|
3374
|
+
}
|
|
3375
|
+
if (tracked.code === 90 && ledger.subdir !== "") {
|
|
3376
|
+
this.warn(`repository directory "${ledger.subdir}" is missing on the host; falling back to the session root`);
|
|
3377
|
+
ledger.subdir = "";
|
|
3378
|
+
ledger.root = ledger.baseRoot;
|
|
3379
|
+
ledger.baseline = void 0;
|
|
3380
|
+
ledger.baselineTaken = false;
|
|
3381
|
+
const retry = await this.run(ledger.alias, trackedStatusCommand(ledger.root));
|
|
3382
|
+
if (retry.ok) {
|
|
3383
|
+
ledger.unavailable = void 0;
|
|
3384
|
+
return parseStatusZ(retry.stdout);
|
|
3385
|
+
}
|
|
3386
|
+
ledger.unavailable = retry.reason;
|
|
3387
|
+
return;
|
|
3388
|
+
}
|
|
3389
|
+
if (!ledger.adoptedSubdir) {
|
|
3390
|
+
if (await this.adoptRepositoryBelow(ledger) !== void 0) {
|
|
3391
|
+
const retry = await this.run(ledger.alias, trackedStatusCommand(ledger.root));
|
|
3392
|
+
if (retry.ok) {
|
|
3393
|
+
ledger.unavailable = void 0;
|
|
3394
|
+
return parseStatusZ(retry.stdout);
|
|
3395
|
+
}
|
|
3396
|
+
ledger.unavailable = retry.reason;
|
|
3397
|
+
return;
|
|
3398
|
+
}
|
|
3399
|
+
}
|
|
3400
|
+
ledger.unavailable = tracked.reason;
|
|
3401
|
+
}
|
|
3402
|
+
/**
|
|
3403
|
+
* Look for a repository in the session root's immediate subdirectories.
|
|
3404
|
+
*
|
|
3405
|
+
* **Only an unambiguous answer is adopted.** A workspace that holds several
|
|
3406
|
+
* repositories has no single right one, and silently picking the first would
|
|
3407
|
+
* report changes from a project the session never touched — worse than saying
|
|
3408
|
+
* "I don't know". The explicit repository directory the Source Control panel
|
|
3409
|
+
* stores is the real answer, and this fallback exists only to spare the user
|
|
3410
|
+
* from setting it when there is exactly one candidate.
|
|
3411
|
+
*
|
|
3412
|
+
* @returns the adopted root, or undefined when nothing (or too much) was found.
|
|
3413
|
+
*/
|
|
3414
|
+
async adoptRepositoryBelow(ledger) {
|
|
3415
|
+
ledger.adoptedSubdir = true;
|
|
3416
|
+
const probe = await this.run(ledger.alias, reposBelowCommand(ledger.root));
|
|
3417
|
+
if (!probe.ok) return void 0;
|
|
3418
|
+
const found = probe.stdout.split("\n").map((line) => line.trim()).filter((line) => line !== "" && !line.includes("/") && line !== ".");
|
|
3419
|
+
if (found.length === 0) return void 0;
|
|
3420
|
+
if (found.length > 1) {
|
|
3421
|
+
ledger.unavailable = `no git repository at the workspace root, and ${found.length} candidates below it — set the repository directory in Source Control`;
|
|
3422
|
+
return;
|
|
3423
|
+
}
|
|
3424
|
+
ledger.root = `${ledger.root}/${found[0]}`;
|
|
3425
|
+
this.warn(`no repository at the session root; using ${ledger.root}`);
|
|
3426
|
+
return ledger.root;
|
|
3427
|
+
}
|
|
3428
|
+
/**
|
|
3429
|
+
* The slow scan: new files, in the background.
|
|
3430
|
+
*
|
|
3431
|
+
* The same command costs ~11s on a warm tree and over a minute on a cold one,
|
|
3432
|
+
* and on the reference AOSP monorepo it has been measured past 60s with no
|
|
3433
|
+
* answer at all. It therefore never runs inline with the tracked scan, never
|
|
3434
|
+
* runs twice at once, and only runs again after {@link untrackedCadence}. A
|
|
3435
|
+
* repository that cannot answer inside {@link untrackedBudget} is written off
|
|
3436
|
+
* for the session (`untrackedGivenUp`) rather than retried every turn.
|
|
3437
|
+
*/
|
|
3438
|
+
async kickUntracked(ledger) {
|
|
3439
|
+
if (ledger.disposed || ledger.untrackedGivenUp || ledger.untrackedJob !== void 0) return;
|
|
3440
|
+
if (ledger.untracked !== void 0 && Date.now() - ledger.untrackedAt < this.untrackedCadence(ledger)) return;
|
|
3441
|
+
ledger.untrackedAt = Date.now();
|
|
3442
|
+
ledger.untrackedJob = (async () => {
|
|
3443
|
+
const started = Date.now();
|
|
3444
|
+
const scan = await this.run(ledger.alias, untrackedCommand(ledger.root), this.untrackedBudget());
|
|
3445
|
+
ledger.untrackedMs = Date.now() - started;
|
|
3446
|
+
if (ledger.disposed) return;
|
|
3447
|
+
if (!scan.ok) {
|
|
3448
|
+
ledger.untrackedGivenUp = true;
|
|
3449
|
+
this.warn(`untracked scan unusable (${scan.reason}); new files will not be listed`);
|
|
3450
|
+
return;
|
|
3451
|
+
}
|
|
3452
|
+
ledger.untracked = parseUntrackedZ(scan.stdout);
|
|
3453
|
+
await this.absorbPreExisting(ledger);
|
|
3454
|
+
await this.publish(ledger);
|
|
3455
|
+
})().catch((error) => this.warn(`untracked scan failed: ${messageOf(error)}`)).finally(() => {
|
|
3456
|
+
ledger.untrackedJob = void 0;
|
|
3457
|
+
});
|
|
3458
|
+
}
|
|
3459
|
+
/**
|
|
3460
|
+
* How long before the untracked scan may run again.
|
|
3461
|
+
*
|
|
3462
|
+
* Self-tuning on purpose: the same command costs ~0.2s in a small repository
|
|
3463
|
+
* and over a minute in a monorepo, so a fixed cadence is either wasteful or
|
|
3464
|
+
* useless. Backing off to four times the last duration keeps small projects
|
|
3465
|
+
* near-real-time while a big one settles into a scan every couple of minutes
|
|
3466
|
+
* instead of one per turn.
|
|
3467
|
+
*/
|
|
3468
|
+
untrackedCadence(ledger) {
|
|
3469
|
+
const configured = this.deps.untrackedRefreshMs;
|
|
3470
|
+
if (configured !== void 0) return configured;
|
|
3471
|
+
const last = ledger.untrackedMs;
|
|
3472
|
+
if (last === void 0) return 0;
|
|
3473
|
+
return Math.max(5e3, Math.min(last * 2, 6e5));
|
|
3474
|
+
}
|
|
3475
|
+
/**
|
|
3476
|
+
* Fold files that already existed when the session began into the baseline.
|
|
3477
|
+
*
|
|
3478
|
+
* The untracked walk is slow enough (a minute on a monorepo) that the baseline
|
|
3479
|
+
* refresh publishes before it lands, so its first result arrives without a
|
|
3480
|
+
* "before" side. Without this, every file the checkout already had — the
|
|
3481
|
+
* user's own scratch files, a build's leftovers — would be announced as this
|
|
3482
|
+
* session's work, complete with a full-file addition count.
|
|
3483
|
+
*
|
|
3484
|
+
* mtime decides: strictly older than the ledger's own start means the session
|
|
3485
|
+
* cannot have created it, and its present content is therefore its baseline.
|
|
3486
|
+
*/
|
|
3487
|
+
async absorbPreExisting(ledger) {
|
|
3488
|
+
if (ledger.untracked === void 0 || ledger.absorbed) return;
|
|
3489
|
+
ledger.absorbed = true;
|
|
3490
|
+
const baseline = ledger.baseline ?? /* @__PURE__ */ new Map();
|
|
3491
|
+
ledger.baseline = baseline;
|
|
3492
|
+
const unknown = ledger.untracked.map((entry) => entry.path).filter((path) => !baseline.has(path));
|
|
3493
|
+
if (unknown.length === 0) return;
|
|
3494
|
+
const reads = await this.readPaths(ledger, unknown, "worktree");
|
|
3495
|
+
if (ledger.disposed) return;
|
|
3496
|
+
for (const [path, read] of reads) {
|
|
3497
|
+
if (read.mtime === void 0) continue;
|
|
3498
|
+
if (read.mtime * 1e3 < ledger.startedAt) baseline.set(path, read);
|
|
3499
|
+
}
|
|
3500
|
+
}
|
|
3501
|
+
/**
|
|
3502
|
+
* Race one host call against its own budget.
|
|
3503
|
+
*
|
|
3504
|
+
* A budget handed to `exec` is a request, not a guarantee: the transport may
|
|
3505
|
+
* keep the promise open long after the command should have been killed (the
|
|
3506
|
+
* remote process can even be gone while the channel stays). Awaiting such a
|
|
3507
|
+
* promise blocks the ledger's whole queue, so the panel shows "computing…"
|
|
3508
|
+
* for as long as the user is willing to stare at it. This bounds that wait.
|
|
3509
|
+
*/
|
|
3510
|
+
async withWatchdog(call, ms) {
|
|
3511
|
+
let timer;
|
|
3512
|
+
try {
|
|
3513
|
+
return await Promise.race([call, new Promise((resolve) => {
|
|
3514
|
+
timer = setTimeout(() => resolve(WATCHDOG_FIRED), ms);
|
|
3515
|
+
})]);
|
|
3516
|
+
} finally {
|
|
3517
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
3518
|
+
call.catch(() => void 0);
|
|
3519
|
+
}
|
|
3520
|
+
}
|
|
3521
|
+
/** Read a batch of files, capped per file and in total. */
|
|
3522
|
+
async readPaths(ledger, paths, side) {
|
|
3523
|
+
if (paths.length === 0) return /* @__PURE__ */ new Map();
|
|
3524
|
+
const perFile = this.deps.maxFileBytes ?? 256 * 1024;
|
|
3525
|
+
const total = this.deps.maxTotalBytes ?? 8 * 1024 * 1024;
|
|
3526
|
+
const command = side === "worktree" ? readWorktreeCommand(ledger.root, paths, perFile, total) : readHeadCommand(ledger.root, paths, perFile, total);
|
|
3527
|
+
const result = await this.run(ledger.alias, command);
|
|
3528
|
+
if (!result.ok) {
|
|
3529
|
+
this.warn(`read failed (${side}): ${result.reason}`);
|
|
3530
|
+
return /* @__PURE__ */ new Map();
|
|
3531
|
+
}
|
|
3532
|
+
return parseReads(result.stdout);
|
|
3533
|
+
}
|
|
3534
|
+
/** Run one command on the host, normalizing failure into a reason string. */
|
|
3535
|
+
async run(alias, command, budget = this.execBudget()) {
|
|
3536
|
+
try {
|
|
3537
|
+
const call = this.deps.pool.exec(alias, command, { timeoutMs: budget });
|
|
3538
|
+
const result = await this.withWatchdog(call, budget + Math.min(WATCHDOG_GRACE_MS, budget));
|
|
3539
|
+
if (result === WATCHDOG_FIRED) return {
|
|
3540
|
+
ok: false,
|
|
3541
|
+
reason: "git did not answer in time"
|
|
3542
|
+
};
|
|
3543
|
+
const stdout = typeof result.stdout === "string" ? result.stdout : "";
|
|
3544
|
+
if (result.success) return {
|
|
3545
|
+
ok: true,
|
|
3546
|
+
stdout
|
|
3547
|
+
};
|
|
3548
|
+
const stderr = typeof result.stderr === "string" ? result.stderr : "";
|
|
3549
|
+
return {
|
|
3550
|
+
ok: false,
|
|
3551
|
+
reason: this.reasonOf(result.exitCode, stderr),
|
|
3552
|
+
code: result.exitCode
|
|
3553
|
+
};
|
|
3554
|
+
} catch (error) {
|
|
3555
|
+
return {
|
|
3556
|
+
ok: false,
|
|
3557
|
+
reason: `host unreachable: ${messageOf(error)}`
|
|
3558
|
+
};
|
|
3559
|
+
}
|
|
3560
|
+
}
|
|
3561
|
+
/** Turn a failed scan into something a user can act on. */
|
|
3562
|
+
reasonOf(exitCode, stderr) {
|
|
3563
|
+
if (exitCode === 93 || exitCode === 128) return "not a git repository (change tracking needs git)";
|
|
3564
|
+
if (exitCode === 90) return "session directory is missing on the host";
|
|
3565
|
+
if (exitCode === 124 || exitCode === null || exitCode === void 0) return `git did not answer in time${stderr === "" ? "" : ` (${firstLine(stderr)})`}`;
|
|
3566
|
+
return `git failed (exit ${exitCode})${stderr === "" ? "" : ` (${firstLine(stderr)})`}`;
|
|
3567
|
+
}
|
|
3568
|
+
/** One exec's budget: the git setting, clamped so a hung host cannot pile up work. */
|
|
3569
|
+
execBudget() {
|
|
3570
|
+
const override = this.deps.execBudgetMs;
|
|
3571
|
+
if (override !== void 0 && Number.isFinite(override) && override > 0) return override;
|
|
3572
|
+
return this.budget(this.deps.timeouts().gitMs, 5e3, 6e4);
|
|
3573
|
+
}
|
|
3574
|
+
/** The untracked scan gets its own, more generous budget. */
|
|
3575
|
+
/**
|
|
3576
|
+
* The untracked scan gets its own, much more generous budget.
|
|
3577
|
+
*
|
|
3578
|
+
* Three minutes is a deliberate choice for a monorepo: the walk is proportional
|
|
3579
|
+
* to the whole checkout, not to the session, and the reference AOSP tree has
|
|
3580
|
+
* been measured past a minute cold. Waiting is acceptable here because the
|
|
3581
|
+
* scan runs in the background and the tracked changes are already on screen;
|
|
3582
|
+
* giving up early is not, because the files a session creates with a shell
|
|
3583
|
+
* command are visible ONLY through this scan.
|
|
3584
|
+
*/
|
|
3585
|
+
untrackedBudget() {
|
|
3586
|
+
return this.deps.untrackedBudgetMs ?? this.budget(this.deps.timeouts().gitMs, 1e4, 18e4);
|
|
3587
|
+
}
|
|
3588
|
+
budget(configured, floor, ceiling) {
|
|
3589
|
+
return Math.max(floor, Math.min(Number.isFinite(configured) && configured > 0 ? configured : 3e4, ceiling));
|
|
3590
|
+
}
|
|
3591
|
+
warn(message) {
|
|
3592
|
+
try {
|
|
3593
|
+
this.deps.log?.("warn", message);
|
|
3594
|
+
} catch {}
|
|
3595
|
+
}
|
|
3596
|
+
};
|
|
3597
|
+
function firstLine(text) {
|
|
3598
|
+
return (text.split("\n").find((candidate) => candidate.trim() !== "") ?? "").trim().slice(0, 160);
|
|
3599
|
+
}
|
|
3600
|
+
/** Workspace-relative repository directory, or '' when the value is unusable. */
|
|
3601
|
+
function normalizeRepoSubdir(value) {
|
|
3602
|
+
if (value === void 0) return "";
|
|
3603
|
+
const trimmed = value.trim().replace(/^\.\/+/, "").replace(/\/+$/, "");
|
|
3604
|
+
if (trimmed === "" || trimmed === ".") return "";
|
|
3605
|
+
if (trimmed.startsWith("/") || trimmed.includes("..")) return "";
|
|
3606
|
+
return trimmed;
|
|
3607
|
+
}
|
|
3608
|
+
function messageOf(error) {
|
|
3609
|
+
return error instanceof Error ? error.message : String(error);
|
|
3610
|
+
}
|
|
3611
|
+
//#endregion
|
|
3612
|
+
//#region src/tools.ts
|
|
3613
|
+
/**
|
|
3614
|
+
* Agent tools: the model-facing counterpart of the remote GUI. Every tool
|
|
3615
|
+
* talks to the same pool/files the web UI uses. Hosts must be configured in
|
|
3616
|
+
* the GUI first (agents cannot create hosts).
|
|
3617
|
+
*/
|
|
3618
|
+
function text(value) {
|
|
3619
|
+
return [{
|
|
3620
|
+
type: "text",
|
|
3621
|
+
text: value
|
|
3622
|
+
}];
|
|
3623
|
+
}
|
|
3624
|
+
function toAgentHostRow(host) {
|
|
3625
|
+
return {
|
|
3626
|
+
alias: host.alias,
|
|
3627
|
+
host: host.host,
|
|
3628
|
+
port: host.port,
|
|
3629
|
+
user: host.user,
|
|
3630
|
+
auth: host.auth,
|
|
3631
|
+
keyReady: host.keyReady,
|
|
3632
|
+
managedKey: host.managedKey,
|
|
3633
|
+
...host.description !== void 0 ? { description: host.description } : {},
|
|
3634
|
+
tags: [...host.tags],
|
|
3635
|
+
createdAt: host.createdAt,
|
|
3636
|
+
updatedAt: host.updatedAt
|
|
3637
|
+
};
|
|
3638
|
+
}
|
|
3639
|
+
/**
|
|
3640
|
+
* Render the host table.
|
|
3641
|
+
*
|
|
3642
|
+
* An empty list has TWO very different causes and they used to share one
|
|
3643
|
+
* message: nothing is configured, or a query filtered everything out. The
|
|
3644
|
+
* second one claimed "no remote hosts configured" while five were sitting
|
|
3645
|
+
* there, which reads as "stop looking" to a caller that just searched.
|
|
3646
|
+
*/
|
|
3647
|
+
function renderHosts(hosts, query) {
|
|
3648
|
+
if (hosts.length === 0) return query === void 0 || query.trim() === "" ? "no remote hosts configured (add one in the Remote sidebar tab first)" : `no remote host matches "${query}" — hosts are configured, the query just filtered them all out`;
|
|
3649
|
+
return [
|
|
3650
|
+
"alias | host | port | user | auth | key | tags | description",
|
|
3651
|
+
"--- | --- | --- | --- | --- | --- | --- | ---",
|
|
3652
|
+
...hosts.map((h) => [
|
|
3653
|
+
h.alias,
|
|
3654
|
+
h.host,
|
|
3655
|
+
String(h.port),
|
|
3656
|
+
h.user,
|
|
3657
|
+
h.auth,
|
|
3658
|
+
h.auth === "key" ? h.keyReady ? "key-ok" : "key-missing" : "password",
|
|
3659
|
+
h.tags.length > 0 ? h.tags.join(",") : "-",
|
|
3660
|
+
h.description ?? ""
|
|
3661
|
+
].join(" | "))
|
|
3662
|
+
].join("\n");
|
|
3663
|
+
}
|
|
3664
|
+
/**
|
|
3665
|
+
* `ls -l`-style type letter for one entry.
|
|
3666
|
+
*
|
|
3667
|
+
* A symlink is NOT a regular file, and rendering it as one made the two
|
|
3668
|
+
* indistinguishable: `/bin`, `/lib`, `/lib64` and `/sbin` on an ordinary host
|
|
3669
|
+
* are symlinks to directories, and they came out as `- 7 bin`, which reads as a
|
|
3670
|
+
* seven-byte file. `isSymlink` is already in the entry, so the render simply has
|
|
3671
|
+
* to use it. `.spike/tool-schema-check.mjs` now renders every tool and fails if
|
|
3672
|
+
* these three kinds stop being distinguishable.
|
|
3673
|
+
*/
|
|
3674
|
+
function entryKind(entry) {
|
|
3675
|
+
if (entry.isDir) return "d";
|
|
3676
|
+
if (entry.isSymlink) return "l";
|
|
3677
|
+
return "-";
|
|
3678
|
+
}
|
|
3679
|
+
function renderExec(result) {
|
|
3680
|
+
const parts = [result.timedOut ? "[timed out]" : `[exit code: ${result.exitCode ?? "null"}]`];
|
|
3681
|
+
if (result.stdout !== "") parts.push("stdout:\n" + result.stdout);
|
|
3682
|
+
if (result.stderr !== "") parts.push("stderr:\n" + result.stderr);
|
|
3683
|
+
if (result.error !== void 0) parts.push("error: " + result.error);
|
|
3684
|
+
parts.push(`duration: ${result.durationMs} ms`);
|
|
3685
|
+
return parts.join("\n");
|
|
3686
|
+
}
|
|
3687
|
+
function remoteHostsTool(deps) {
|
|
3688
|
+
return defineTool({
|
|
3689
|
+
name: "remote_hosts",
|
|
3690
|
+
description: "List configured remote SSH hosts (alias, host, user, auth). Use remote_exec/remote_read/remote_write/remote_ls with the alias.",
|
|
3691
|
+
parameters: { query: {
|
|
3692
|
+
type: "string",
|
|
3693
|
+
description: "Optional fuzzy match against alias, host, and description."
|
|
3694
|
+
} },
|
|
3695
|
+
output: {
|
|
3696
|
+
schema: {
|
|
3697
|
+
type: "object",
|
|
3698
|
+
additionalProperties: false,
|
|
3699
|
+
properties: { hosts: {
|
|
3700
|
+
type: "array",
|
|
3701
|
+
required: true,
|
|
3702
|
+
items: {
|
|
3703
|
+
type: "object",
|
|
3704
|
+
additionalProperties: false,
|
|
3705
|
+
properties: {
|
|
3706
|
+
alias: {
|
|
3707
|
+
type: "string",
|
|
3708
|
+
required: true
|
|
3709
|
+
},
|
|
3710
|
+
host: {
|
|
3711
|
+
type: "string",
|
|
3712
|
+
required: true
|
|
3713
|
+
},
|
|
3714
|
+
port: {
|
|
3715
|
+
type: "integer",
|
|
3716
|
+
required: true
|
|
3717
|
+
},
|
|
3718
|
+
user: {
|
|
3719
|
+
type: "string",
|
|
3720
|
+
required: true
|
|
3721
|
+
},
|
|
3722
|
+
auth: {
|
|
3723
|
+
type: "string",
|
|
3724
|
+
enum: ["key", "password"],
|
|
3725
|
+
required: true
|
|
3726
|
+
},
|
|
3727
|
+
keyReady: {
|
|
3728
|
+
type: "boolean",
|
|
3729
|
+
required: true
|
|
3730
|
+
},
|
|
3731
|
+
managedKey: {
|
|
3732
|
+
type: "boolean",
|
|
3733
|
+
required: true
|
|
3734
|
+
},
|
|
3735
|
+
description: { type: "string" },
|
|
3736
|
+
tags: {
|
|
3737
|
+
type: "array",
|
|
3738
|
+
items: { type: "string" },
|
|
3739
|
+
required: true
|
|
3740
|
+
},
|
|
3741
|
+
createdAt: {
|
|
3742
|
+
type: "integer",
|
|
3743
|
+
required: true
|
|
3744
|
+
},
|
|
3745
|
+
updatedAt: {
|
|
3746
|
+
type: "integer",
|
|
3747
|
+
required: true
|
|
3748
|
+
}
|
|
3749
|
+
}
|
|
3750
|
+
}
|
|
3751
|
+
} }
|
|
3752
|
+
},
|
|
3753
|
+
render: (args, value) => text(renderHosts(value.hosts ?? [], args.query))
|
|
3754
|
+
},
|
|
3755
|
+
isConcurrencySafe: () => true,
|
|
3756
|
+
async execute(args) {
|
|
3757
|
+
const query = typeof args.query === "string" && args.query !== "" ? args.query.toLowerCase() : void 0;
|
|
3758
|
+
return { hosts: deps.store.list().map((h) => deps.store.summarize(h)).filter((h) => query === void 0 || h.alias.toLowerCase().includes(query) || h.host.toLowerCase().includes(query) || (h.description ?? "").toLowerCase().includes(query)).map(toAgentHostRow) };
|
|
3759
|
+
}
|
|
3760
|
+
});
|
|
3761
|
+
}
|
|
3762
|
+
function remoteExecTool(deps) {
|
|
3763
|
+
return defineTool({
|
|
3764
|
+
name: "remote_exec",
|
|
3765
|
+
description: "Run a non-interactive shell command on a remote SSH host. stdout/stderr are returned verbatim (may carry secrets).",
|
|
3766
|
+
parameters: {
|
|
3767
|
+
alias: {
|
|
3768
|
+
type: "string",
|
|
3769
|
+
description: "Host alias from remote_hosts."
|
|
3770
|
+
},
|
|
3771
|
+
command: {
|
|
3772
|
+
type: "string",
|
|
3773
|
+
description: "Shell command to run on the remote host."
|
|
3774
|
+
},
|
|
3775
|
+
timeoutMs: {
|
|
3776
|
+
type: "number",
|
|
3777
|
+
description: "Timeout in ms (default 60000, max 600000)."
|
|
3778
|
+
}
|
|
3779
|
+
},
|
|
3780
|
+
output: {
|
|
3781
|
+
schema: {
|
|
3782
|
+
type: "object",
|
|
3783
|
+
additionalProperties: false,
|
|
3784
|
+
properties: {
|
|
3785
|
+
success: {
|
|
3786
|
+
type: "boolean",
|
|
3787
|
+
required: true
|
|
3788
|
+
},
|
|
3789
|
+
exitCode: { type: "integer" },
|
|
3790
|
+
timedOut: {
|
|
3791
|
+
type: "boolean",
|
|
3792
|
+
required: true
|
|
3793
|
+
},
|
|
3794
|
+
stdout: {
|
|
3795
|
+
type: "string",
|
|
3796
|
+
required: true
|
|
3797
|
+
},
|
|
3798
|
+
stderr: {
|
|
3799
|
+
type: "string",
|
|
3800
|
+
required: true
|
|
3801
|
+
},
|
|
3802
|
+
durationMs: {
|
|
3803
|
+
type: "number",
|
|
3804
|
+
required: true
|
|
3805
|
+
},
|
|
3806
|
+
error: { type: "string" }
|
|
3807
|
+
}
|
|
3808
|
+
},
|
|
3809
|
+
render: (_args, value) => text(renderExec({
|
|
3810
|
+
...value,
|
|
3811
|
+
exitCode: value.exitCode ?? null
|
|
3812
|
+
}))
|
|
3813
|
+
},
|
|
3814
|
+
async execute(args) {
|
|
3815
|
+
const timeoutMs = typeof args.timeoutMs === "number" && Number.isFinite(args.timeoutMs) ? Math.min(Math.max(Math.floor(args.timeoutMs), 1e3), 6e5) : void 0;
|
|
3816
|
+
const result = await deps.pool.exec(args.alias, args.command, timeoutMs === void 0 ? {} : { timeoutMs });
|
|
3817
|
+
return {
|
|
3818
|
+
success: result.success,
|
|
3819
|
+
timedOut: result.timedOut,
|
|
3820
|
+
stdout: result.stdout,
|
|
3821
|
+
stderr: result.stderr,
|
|
3822
|
+
durationMs: result.durationMs,
|
|
3823
|
+
...result.exitCode !== null ? { exitCode: result.exitCode } : {},
|
|
3824
|
+
...result.error !== void 0 ? { error: result.error } : {}
|
|
3825
|
+
};
|
|
3826
|
+
}
|
|
3827
|
+
});
|
|
3828
|
+
}
|
|
3829
|
+
function remoteLsTool(deps) {
|
|
3830
|
+
return defineTool({
|
|
3831
|
+
name: "remote_ls",
|
|
3832
|
+
description: "List one remote directory over SFTP (absolute POSIX path).",
|
|
3833
|
+
parameters: {
|
|
3834
|
+
alias: {
|
|
3835
|
+
type: "string",
|
|
3836
|
+
description: "Host alias from remote_hosts."
|
|
3837
|
+
},
|
|
3838
|
+
path: {
|
|
3839
|
+
type: "string",
|
|
3840
|
+
description: "Absolute remote directory path."
|
|
3841
|
+
}
|
|
3842
|
+
},
|
|
3843
|
+
output: {
|
|
3844
|
+
schema: {
|
|
3845
|
+
type: "object",
|
|
3846
|
+
additionalProperties: false,
|
|
3847
|
+
properties: { entries: {
|
|
3848
|
+
type: "array",
|
|
3849
|
+
required: true,
|
|
3850
|
+
items: {
|
|
3851
|
+
type: "object",
|
|
3852
|
+
additionalProperties: false,
|
|
3853
|
+
properties: {
|
|
3854
|
+
name: {
|
|
3855
|
+
type: "string",
|
|
3856
|
+
required: true
|
|
3857
|
+
},
|
|
3858
|
+
path: {
|
|
3859
|
+
type: "string",
|
|
3860
|
+
required: true
|
|
3861
|
+
},
|
|
3862
|
+
isDir: {
|
|
3863
|
+
type: "boolean",
|
|
3864
|
+
required: true
|
|
3865
|
+
},
|
|
3866
|
+
size: {
|
|
3867
|
+
type: "integer",
|
|
3868
|
+
required: true
|
|
3869
|
+
},
|
|
3870
|
+
mtime: {
|
|
3871
|
+
type: "integer",
|
|
3872
|
+
required: true
|
|
3873
|
+
},
|
|
3874
|
+
mode: {
|
|
3875
|
+
type: "integer",
|
|
3876
|
+
required: true
|
|
3877
|
+
},
|
|
3878
|
+
isSymlink: {
|
|
3879
|
+
type: "boolean",
|
|
3880
|
+
required: true
|
|
3881
|
+
}
|
|
3882
|
+
}
|
|
3883
|
+
}
|
|
3884
|
+
} }
|
|
3885
|
+
},
|
|
3886
|
+
render: (_args, value) => text((value.entries ?? []).map((e) => `${entryKind(e)} ${e.size} ${e.name}`).join("\n") || "(empty directory)")
|
|
3887
|
+
},
|
|
3888
|
+
async execute(args) {
|
|
3889
|
+
return { entries: await new RemoteFiles(deps.pool).list(args.alias, args.path) };
|
|
3890
|
+
}
|
|
3891
|
+
});
|
|
3892
|
+
}
|
|
3893
|
+
function remoteReadTool(deps) {
|
|
3894
|
+
return defineTool({
|
|
3895
|
+
name: "remote_read",
|
|
3896
|
+
description: "Read a remote text file over SFTP (absolute POSIX path, first 8 MiB window). Refuses binary files.",
|
|
3897
|
+
parameters: {
|
|
3898
|
+
alias: {
|
|
3899
|
+
type: "string",
|
|
3900
|
+
description: "Host alias from remote_hosts."
|
|
3901
|
+
},
|
|
3902
|
+
path: {
|
|
3903
|
+
type: "string",
|
|
3904
|
+
description: "Absolute remote file path."
|
|
3905
|
+
}
|
|
3906
|
+
},
|
|
3907
|
+
output: {
|
|
3908
|
+
schema: {
|
|
3909
|
+
type: "object",
|
|
3910
|
+
additionalProperties: false,
|
|
3911
|
+
properties: {
|
|
3912
|
+
size: {
|
|
3913
|
+
type: "integer",
|
|
3914
|
+
required: true
|
|
3915
|
+
},
|
|
3916
|
+
truncated: {
|
|
3917
|
+
type: "boolean",
|
|
3918
|
+
required: true
|
|
3919
|
+
},
|
|
3920
|
+
text: {
|
|
3921
|
+
type: "string",
|
|
3922
|
+
required: true
|
|
3923
|
+
}
|
|
3924
|
+
}
|
|
3925
|
+
},
|
|
3926
|
+
render: (_args, value) => text((value.text ?? "") + (value.truncated === true ? "\n[... truncated: file exceeds 8 MiB ...]" : ""))
|
|
3927
|
+
},
|
|
3928
|
+
async execute(args) {
|
|
3929
|
+
const result = await new RemoteFiles(deps.pool).read(args.alias, args.path);
|
|
3930
|
+
if (result.binary) throw new Error("binary file: remote_read serves text only");
|
|
3931
|
+
return {
|
|
3932
|
+
size: result.size,
|
|
3933
|
+
truncated: result.truncated,
|
|
3934
|
+
text: result.text ?? ""
|
|
3935
|
+
};
|
|
3936
|
+
}
|
|
3937
|
+
});
|
|
3938
|
+
}
|
|
3939
|
+
function remoteWriteTool(deps) {
|
|
3940
|
+
return defineTool({
|
|
3941
|
+
name: "remote_write",
|
|
3942
|
+
description: "Write a remote text file over SFTP (absolute POSIX path, creates parent dirs). Overwrites without confirmation.",
|
|
3943
|
+
parameters: {
|
|
3944
|
+
alias: {
|
|
3945
|
+
type: "string",
|
|
3946
|
+
description: "Host alias from remote_hosts."
|
|
3947
|
+
},
|
|
3948
|
+
path: {
|
|
3949
|
+
type: "string",
|
|
3950
|
+
description: "Absolute remote file path."
|
|
3951
|
+
},
|
|
3952
|
+
content: {
|
|
3953
|
+
type: "string",
|
|
3954
|
+
description: "UTF-8 text content to write."
|
|
3955
|
+
}
|
|
3956
|
+
},
|
|
3957
|
+
output: {
|
|
3958
|
+
schema: {
|
|
3959
|
+
type: "object",
|
|
3960
|
+
additionalProperties: false,
|
|
3961
|
+
properties: { size: {
|
|
3962
|
+
type: "integer",
|
|
3963
|
+
required: true
|
|
3964
|
+
} }
|
|
3965
|
+
},
|
|
3966
|
+
render: (_args, value) => text(`wrote ${value.size ?? 0} bytes`)
|
|
3967
|
+
},
|
|
3968
|
+
async execute(args) {
|
|
3969
|
+
return new RemoteFiles(deps.pool).write(args.alias, args.path, args.content, "utf8");
|
|
3970
|
+
}
|
|
3971
|
+
});
|
|
3972
|
+
}
|
|
3973
|
+
//#endregion
|
|
3974
|
+
//#region src/index.ts
|
|
3975
|
+
/** Stable cordis plugin name. */
|
|
3976
|
+
const name = "remote-vs";
|
|
3977
|
+
/**
|
|
3978
|
+
* Services required before the remote surfaces can mount. MINIMAL ON PURPOSE
|
|
3979
|
+
* (2026-10-13 deadlock lesson): our routers REPLACE fs/subprocess/spillStore
|
|
3980
|
+
* at root, so anything downstream of them (workingDirectory, agents, …)
|
|
3981
|
+
* transitively waits on us — declaring them here cycles the whole Host
|
|
3982
|
+
* (remote-vs → workingDirectory → fs-router → remoteWorlds → remote-vs).
|
|
3983
|
+
* Every other service is read SOFT via ctx.get() at call time (the
|
|
3984
|
+
* tool-bash pattern: no startup wait, no throw, undefined when absent).
|
|
3985
|
+
*/
|
|
3986
|
+
const inject = [
|
|
3987
|
+
"webServer",
|
|
3988
|
+
"tools",
|
|
3989
|
+
"systemPrompt"
|
|
3990
|
+
];
|
|
3991
|
+
/**
|
|
3992
|
+
* Plugin config schema. Under the 0.1.7 settings model this schema IS the
|
|
3993
|
+
* entry's settings page; every field is `volatile()` so edits reach the
|
|
3994
|
+
* running instance without a remount.
|
|
3995
|
+
*
|
|
3996
|
+
* The two timeout fields exist because "large project" is host-specific: a cold
|
|
3997
|
+
* `git status` on a big monorepo outlived the old fixed 45 s client deadline,
|
|
3998
|
+
* so the limit is a setting rather than a constant. Both are clamped into
|
|
3999
|
+
* [MIN_TIMEOUT_MS, MAX_TIMEOUT_MS] (see `timeouts.ts`) on every read, so a bad
|
|
4000
|
+
* value degrades to the default instead of disabling the explorer.
|
|
4001
|
+
*/
|
|
4002
|
+
const Config = z.object({
|
|
4003
|
+
announceToAgent: z.boolean().default(false).volatile(),
|
|
4004
|
+
enabled: z.boolean().default(true).volatile(),
|
|
4005
|
+
routingMode: z.union([
|
|
4006
|
+
"off",
|
|
4007
|
+
"shadow",
|
|
4008
|
+
"enforce"
|
|
4009
|
+
]).default("off").volatile(),
|
|
4010
|
+
gitTimeoutMs: z.number().step(1).min(MIN_TIMEOUT_MS).max(MAX_TIMEOUT_MS).default(DEFAULT_GIT_TIMEOUT_MS).volatile(),
|
|
4011
|
+
readTimeoutMs: z.number().step(1).min(MIN_TIMEOUT_MS).max(MAX_TIMEOUT_MS).default(DEFAULT_READ_TIMEOUT_MS).volatile()
|
|
4012
|
+
});
|
|
4013
|
+
function readField(field, fallback) {
|
|
4014
|
+
if (field === void 0) return fallback;
|
|
4015
|
+
if (typeof field === "object" && field !== null && typeof field.get === "function") {
|
|
4016
|
+
const value = field.get();
|
|
4017
|
+
return value === void 0 ? fallback : value;
|
|
4018
|
+
}
|
|
4019
|
+
return field;
|
|
4020
|
+
}
|
|
4021
|
+
const TOOL_NAMES = [
|
|
4022
|
+
"remote_hosts",
|
|
4023
|
+
"remote_exec",
|
|
4024
|
+
"remote_ls",
|
|
4025
|
+
"remote_read",
|
|
4026
|
+
"remote_write"
|
|
4027
|
+
];
|
|
4028
|
+
const GUIDANCE = "本机已安装 dsh-remote-vs 插件(DSH 远程开发):侧边栏「远程」tab 管理 SSH 主机与远端工作区。能力:主机配置存 $DSH_HOME/dsh-remote.json(证书/密码,与 dsh-ssh.json 独立);remote_hosts 列出主机、remote_exec 执行远程命令、remote_ls/remote_read/remote_write 经 SFTP 读写远端文件;推送流水线做版本比对后推送 helper 可执行文件;Remote 预设提供远端 bash 工具集。限制:主机必须先在 GUI 里配好,agent 不可建主机;密码明文存于 0600 私有文件;远端输出原样返回,可能含敏感信息;只支持直连主机(跳板机/代理命令未排期)。";
|
|
4029
|
+
/**
|
|
4030
|
+
* Per-session remote mapping block (dynamic runtime context provider).
|
|
4031
|
+
* Remote sessions under enforce get their alias→host→root mapping next to
|
|
4032
|
+
* the cwd block; everything else (local route, off/shadow mode, tombstoned
|
|
4033
|
+
* binding, missing host entry details) answers '' — empty text contributes
|
|
4034
|
+
* nothing, so local sessions stay noise-free.
|
|
4035
|
+
*
|
|
4036
|
+
* Evaluated at every assembly: sync memory reads only (mode flag, route
|
|
4037
|
+
* table scan, store maps — no I/O, no RPC), and it must never throw (an
|
|
4038
|
+
* assembly provider throwing fails the model step — callers wrap in
|
|
4039
|
+
* try/catch and treat any error as '').
|
|
4040
|
+
*/
|
|
4041
|
+
function workspaceMappingText(worlds, store, cwd) {
|
|
4042
|
+
if (worlds.mode !== "enforce") return "";
|
|
4043
|
+
let route;
|
|
4044
|
+
try {
|
|
4045
|
+
route = worlds.routeDir(cwd);
|
|
4046
|
+
} catch {
|
|
4047
|
+
return "";
|
|
4048
|
+
}
|
|
4049
|
+
if (route.kind !== "remote") return "";
|
|
4050
|
+
const entry = store.get(route.alias);
|
|
4051
|
+
const host = entry !== void 0 ? `${route.alias} (${entry.user}@${entry.host}:${entry.port})` : `${route.alias} (host entry missing)`;
|
|
4052
|
+
return [
|
|
4053
|
+
`Remote workspace (transparent execution ON): session cwd ${route.aliasPath} is an empty local alias; tools resolve here:`,
|
|
4054
|
+
`- host: ${host}`,
|
|
4055
|
+
`- remote root: ${route.remotePath}`,
|
|
4056
|
+
"Relative paths and POSIX absolutes run on the remote host; the alias dir holds no real files.",
|
|
4057
|
+
"- File paths in your reply: use the remote absolute path (e.g. /home/...) or a path relative to the remote root above — never a path relative to your current directory; a click resolves against the remote root."
|
|
4058
|
+
].join("\n");
|
|
4059
|
+
}
|
|
4060
|
+
function apply(ctx, config) {
|
|
4061
|
+
const store = new RemoteStore();
|
|
4062
|
+
const pool = new RemotePool(store);
|
|
4063
|
+
const worlds = new RemoteWorlds(ctx, {
|
|
4064
|
+
store,
|
|
4065
|
+
pool,
|
|
4066
|
+
mode: () => readField(config?.routingMode, "off")
|
|
4067
|
+
});
|
|
4068
|
+
ctx.provide("remoteWorlds", worlds);
|
|
4069
|
+
ctx.effect(() => () => {
|
|
4070
|
+
pool.dispose();
|
|
4071
|
+
}, "dsh-remote-vs: pool");
|
|
4072
|
+
ctx.effect(() => () => {
|
|
4073
|
+
worlds.dispose().catch(() => void 0);
|
|
4074
|
+
}, "dsh-remote-vs: worlds");
|
|
4075
|
+
worlds.syncWorkspaces().catch((error) => {
|
|
4076
|
+
try {
|
|
4077
|
+
ctx.logger.warn(`[remote-vs] workspace sync failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
4078
|
+
} catch {}
|
|
4079
|
+
});
|
|
4080
|
+
try {
|
|
4081
|
+
const seed = seedHelperCache();
|
|
4082
|
+
if (!seed.vendorMissing && (seed.seeded.length > 0 || seed.failed.length > 0)) try {
|
|
4083
|
+
ctx.logger.info(`[remote-vs] helper cache seeded: ${seed.seeded.length} copied, ${seed.skipped.length} kept${seed.failed.length > 0 ? `, FAILED: ${seed.failed.join(", ")}` : ""}`);
|
|
4084
|
+
} catch {}
|
|
4085
|
+
} catch {}
|
|
4086
|
+
ctx.effect(() => ctx.systemPrompt.context({
|
|
4087
|
+
name: "remote-vs:workspace-mapping",
|
|
4088
|
+
order: 105,
|
|
4089
|
+
required: true,
|
|
4090
|
+
interpolate: false,
|
|
4091
|
+
text: (context) => {
|
|
4092
|
+
try {
|
|
4093
|
+
const session = context.agent?.session;
|
|
4094
|
+
if (session === void 0) return "";
|
|
4095
|
+
const workingDirectory = ctx.get("workingDirectory");
|
|
4096
|
+
if (workingDirectory === void 0) return "";
|
|
4097
|
+
let cwd;
|
|
4098
|
+
try {
|
|
4099
|
+
cwd = workingDirectory.get(session);
|
|
4100
|
+
} catch {
|
|
4101
|
+
return "";
|
|
4102
|
+
}
|
|
4103
|
+
return workspaceMappingText(worlds, store, cwd);
|
|
4104
|
+
} catch {
|
|
4105
|
+
return "";
|
|
4106
|
+
}
|
|
4107
|
+
}
|
|
4108
|
+
}), "dsh-remote-vs: workspace mapping");
|
|
4109
|
+
const resolve = () => ({
|
|
4110
|
+
announceToAgent: readField(config?.announceToAgent, false),
|
|
4111
|
+
enabled: readField(config?.enabled, true)
|
|
4112
|
+
});
|
|
4113
|
+
const resolveTimeoutsNow = () => resolveTimeouts({
|
|
4114
|
+
gitTimeoutMs: readField(config?.gitTimeoutMs, DEFAULT_GIT_TIMEOUT_MS),
|
|
4115
|
+
readTimeoutMs: readField(config?.readTimeoutMs, DEFAULT_READ_TIMEOUT_MS)
|
|
4116
|
+
});
|
|
4117
|
+
/**
|
|
4118
|
+
* Which sessions get a ledger.
|
|
4119
|
+
*
|
|
4120
|
+
* Subagent sessions are skipped on purpose, the same policy DSH's own
|
|
4121
|
+
* recorder uses: a delegated session writes into the same workspace, so its
|
|
4122
|
+
* files show up in the parent session's diff anyway, while recording it
|
|
4123
|
+
* separately would run the scans once per subagent turn (a Team run fans out,
|
|
4124
|
+
* and the scan is the expensive part).
|
|
4125
|
+
*/
|
|
4126
|
+
const ledgerTarget = (session) => {
|
|
4127
|
+
const header = session.header;
|
|
4128
|
+
if (header?.origin === "subagent" || (header?.delegationDepth ?? 0) > 0) return void 0;
|
|
4129
|
+
let cwd = header?.cwd ?? "";
|
|
4130
|
+
try {
|
|
4131
|
+
const current = ctx.get("workingDirectory")?.get?.(session);
|
|
4132
|
+
if (typeof current === "string" && current !== "") cwd = current;
|
|
4133
|
+
} catch {}
|
|
4134
|
+
return cwd === "" ? void 0 : {
|
|
4135
|
+
sessionId: session.id,
|
|
4136
|
+
cwd
|
|
4137
|
+
};
|
|
4138
|
+
};
|
|
4139
|
+
const changes = new SessionChanges({
|
|
4140
|
+
pool,
|
|
4141
|
+
worlds,
|
|
4142
|
+
timeouts: resolveTimeoutsNow,
|
|
4143
|
+
log: (level, message) => {
|
|
4144
|
+
try {
|
|
4145
|
+
if (level === "warn") ctx.logger.warn(`[remote-vs] changes: ${message}`);
|
|
4146
|
+
else ctx.logger.info(`[remote-vs] changes: ${message}`);
|
|
4147
|
+
} catch {}
|
|
4148
|
+
}
|
|
4149
|
+
});
|
|
4150
|
+
ctx.on("session/event", (session, event) => {
|
|
4151
|
+
try {
|
|
4152
|
+
if (event.type !== "turn/start" && event.type !== "turn/end") return;
|
|
4153
|
+
const target = ledgerTarget(session);
|
|
4154
|
+
if (target === void 0) return;
|
|
4155
|
+
if (event.type === "turn/start") changes.beginTurn(target);
|
|
4156
|
+
else changes.endTurn(target, event.data.turn);
|
|
4157
|
+
} catch {}
|
|
4158
|
+
});
|
|
4159
|
+
ctx.on("session/disposed", (session) => {
|
|
4160
|
+
changes.forget(session.id);
|
|
4161
|
+
});
|
|
4162
|
+
ctx.on("agent/turn-stopping", (payload) => {
|
|
4163
|
+
try {
|
|
4164
|
+
const target = ledgerTarget(payload.agent.session);
|
|
4165
|
+
if (target !== void 0) changes.endTurn(target, payload.turn);
|
|
4166
|
+
} catch {}
|
|
4167
|
+
});
|
|
4168
|
+
ctx.effect(() => () => {
|
|
4169
|
+
changes.dispose();
|
|
4170
|
+
}, "dsh-remote-vs: changes ledger");
|
|
4171
|
+
const { routes, upgrade, terminalSessions } = makeRoutes({
|
|
4172
|
+
store,
|
|
4173
|
+
pool,
|
|
4174
|
+
worlds,
|
|
4175
|
+
ctx,
|
|
4176
|
+
timeouts: resolveTimeoutsNow,
|
|
4177
|
+
changes
|
|
4178
|
+
});
|
|
4179
|
+
let disposeRoutes;
|
|
4180
|
+
const tools = [
|
|
4181
|
+
remoteHostsTool({
|
|
4182
|
+
store,
|
|
4183
|
+
pool
|
|
4184
|
+
}),
|
|
4185
|
+
remoteExecTool({
|
|
4186
|
+
store,
|
|
4187
|
+
pool
|
|
4188
|
+
}),
|
|
4189
|
+
remoteLsTool({
|
|
4190
|
+
store,
|
|
4191
|
+
pool
|
|
4192
|
+
}),
|
|
4193
|
+
remoteReadTool({
|
|
4194
|
+
store,
|
|
4195
|
+
pool
|
|
4196
|
+
}),
|
|
4197
|
+
remoteWriteTool({
|
|
4198
|
+
store,
|
|
4199
|
+
pool
|
|
4200
|
+
})
|
|
4201
|
+
];
|
|
4202
|
+
let disposeTools;
|
|
4203
|
+
let disposeSection;
|
|
4204
|
+
const sync = () => {
|
|
4205
|
+
if (disposeSection !== void 0) {
|
|
4206
|
+
disposeSection();
|
|
4207
|
+
disposeSection = void 0;
|
|
4208
|
+
}
|
|
4209
|
+
if (disposeRoutes !== void 0) {
|
|
4210
|
+
disposeRoutes();
|
|
4211
|
+
disposeRoutes = void 0;
|
|
4212
|
+
}
|
|
4213
|
+
if (disposeTools !== void 0) {
|
|
4214
|
+
disposeTools();
|
|
4215
|
+
disposeTools = void 0;
|
|
4216
|
+
}
|
|
4217
|
+
const value = resolve();
|
|
4218
|
+
if (!value.enabled) return;
|
|
4219
|
+
if (value.announceToAgent) disposeSection = ctx.systemPrompt.section({
|
|
4220
|
+
name: "plugin:dsh-remote-vs",
|
|
4221
|
+
order: 60,
|
|
4222
|
+
text: () => {
|
|
4223
|
+
if (TOOL_NAMES.filter((n) => ctx.tools.get(n) !== void 0).length === 0) return "";
|
|
4224
|
+
return GUIDANCE;
|
|
4225
|
+
}
|
|
4226
|
+
});
|
|
4227
|
+
disposeRoutes = ctx.effect(() => {
|
|
4228
|
+
const disposers = routes.map((route) => ctx.webServer.register(route));
|
|
4229
|
+
const upgradeDisposer = ctx.webServer.registerUpgrade(upgrade);
|
|
4230
|
+
return () => {
|
|
4231
|
+
for (const dispose of disposers) dispose();
|
|
4232
|
+
upgradeDisposer();
|
|
4233
|
+
terminalSessions.dispose();
|
|
4234
|
+
};
|
|
4235
|
+
}, "dsh-remote-vs: routes");
|
|
4236
|
+
disposeTools = ctx.effect(() => {
|
|
4237
|
+
const disposers = tools.map((tool) => ctx.tools.register(tool));
|
|
4238
|
+
return () => {
|
|
4239
|
+
for (const dispose of disposers) dispose();
|
|
4240
|
+
};
|
|
4241
|
+
}, "dsh-remote-vs: tools");
|
|
4242
|
+
};
|
|
4243
|
+
ctx.on("loader/volatile-update", () => {
|
|
4244
|
+
sync();
|
|
4245
|
+
});
|
|
4246
|
+
ctx.on("agent/created", (payload) => {
|
|
4247
|
+
if (payload.source !== "startup") return void 0;
|
|
4248
|
+
setTimeout(() => {
|
|
4249
|
+
(async () => {
|
|
4250
|
+
try {
|
|
4251
|
+
const presets = ctx.get("agentPresets");
|
|
4252
|
+
if (presets === void 0) return;
|
|
4253
|
+
if (worlds.mode !== "enforce") return;
|
|
4254
|
+
const current = presets.composedPreset(payload.agent.ctx);
|
|
4255
|
+
if (current !== presets.defaultId || current === "remote") return;
|
|
4256
|
+
const workingDirectory = ctx.get("workingDirectory");
|
|
4257
|
+
if (workingDirectory === void 0) return;
|
|
4258
|
+
let cwd;
|
|
4259
|
+
try {
|
|
4260
|
+
cwd = workingDirectory.get(payload.agent.session);
|
|
4261
|
+
} catch {
|
|
4262
|
+
return;
|
|
4263
|
+
}
|
|
4264
|
+
let remote = false;
|
|
4265
|
+
try {
|
|
4266
|
+
remote = worlds.routeDir(cwd).kind === "remote";
|
|
4267
|
+
} catch {
|
|
4268
|
+
return;
|
|
4269
|
+
}
|
|
4270
|
+
if (!remote) return;
|
|
4271
|
+
await presets.select(payload.agent, "remote");
|
|
4272
|
+
try {
|
|
4273
|
+
ctx.logger.info(`[remote-vs] new remote session entered ${cwd}; preset switched to remote`);
|
|
4274
|
+
} catch {}
|
|
4275
|
+
} catch (error) {
|
|
4276
|
+
if (error instanceof Error && /locked|already started/i.test(error.message)) return;
|
|
4277
|
+
try {
|
|
4278
|
+
ctx.logger.warn(`[remote-vs] preset auto-switch skipped: ${error instanceof Error ? error.message : String(error)}`);
|
|
4279
|
+
} catch {}
|
|
4280
|
+
}
|
|
4281
|
+
})();
|
|
4282
|
+
}, 2500).unref?.();
|
|
4283
|
+
});
|
|
4284
|
+
sync();
|
|
4285
|
+
}
|
|
4286
|
+
//#endregion
|
|
4287
|
+
export { Config, apply, inject, name };
|