dsh-archived 0.3.0 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/client.js +70 -40
- package/lib/host/paths.js +114 -5
- package/lib/host/quarantine.js +8 -2
- package/lib/host/trust.js +61 -13
- package/lib/index.js +116 -44
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -80,7 +80,15 @@ window.__ModuleLoader__.load({
|
|
|
80
80
|
document.head.appendChild(tag);
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
-
/**
|
|
83
|
+
/**
|
|
84
|
+
* Same-origin call to this plugin's host routes.
|
|
85
|
+
*
|
|
86
|
+
* A JSON error body is returned for the caller to localize, but a transport
|
|
87
|
+
* failure must reject: a 403 (refused by the trust gate) or a 500 used to
|
|
88
|
+
* resolve into whatever JSON it carried, and every caller then read a field
|
|
89
|
+
* that wasn't there — the panel ended up showing a generic failure that hid
|
|
90
|
+
* the real reason.
|
|
91
|
+
*/
|
|
84
92
|
function request(path, options) {
|
|
85
93
|
var init = {
|
|
86
94
|
method: options && options.method ? options.method : "GET",
|
|
@@ -92,9 +100,22 @@ window.__ModuleLoader__.load({
|
|
|
92
100
|
init.body = JSON.stringify(options.body);
|
|
93
101
|
}
|
|
94
102
|
return fetch(path, init).then(function (response) {
|
|
95
|
-
return response
|
|
96
|
-
|
|
97
|
-
|
|
103
|
+
return response
|
|
104
|
+
.json()
|
|
105
|
+
.catch(function () {
|
|
106
|
+
return { error: "internal" };
|
|
107
|
+
})
|
|
108
|
+
.then(function (payload) {
|
|
109
|
+
if (!response.ok) {
|
|
110
|
+
// Localizing needs the panel's `t`, so carry the host's error code
|
|
111
|
+
// and status out and let the caller translate them.
|
|
112
|
+
var error = new Error("HTTP " + response.status);
|
|
113
|
+
error.code = payload && payload.error ? String(payload.error) : "internal";
|
|
114
|
+
error.status = response.status;
|
|
115
|
+
throw error;
|
|
116
|
+
}
|
|
117
|
+
return payload;
|
|
118
|
+
});
|
|
98
119
|
});
|
|
99
120
|
}
|
|
100
121
|
|
|
@@ -165,7 +186,7 @@ window.__ModuleLoader__.load({
|
|
|
165
186
|
: [];
|
|
166
187
|
|
|
167
188
|
var [snapshot, setSnapshot] = useState(null);
|
|
168
|
-
var [failed, setFailed] = useState(
|
|
189
|
+
var [failed, setFailed] = useState(null);
|
|
169
190
|
var [query, setQuery] = useState("");
|
|
170
191
|
var [selected, setSelected] = useState({});
|
|
171
192
|
var [expanded, setExpanded] = useState({});
|
|
@@ -179,13 +200,17 @@ window.__ModuleLoader__.load({
|
|
|
179
200
|
function reload() {
|
|
180
201
|
return request(STATE_PATH)
|
|
181
202
|
.then(function (payload) {
|
|
182
|
-
if (!payload || payload.ok !== true)
|
|
203
|
+
if (!payload || payload.ok !== true) {
|
|
204
|
+
throw new Error(payload && payload.error ? String(payload.error) : "state");
|
|
205
|
+
}
|
|
183
206
|
setSnapshot(payload);
|
|
184
|
-
setFailed(
|
|
207
|
+
setFailed(null);
|
|
185
208
|
return payload;
|
|
186
209
|
})
|
|
187
|
-
.catch(function () {
|
|
188
|
-
|
|
210
|
+
.catch(function (error) {
|
|
211
|
+
// Say why: a bare "cannot read host state" is undebuggable from the
|
|
212
|
+
// outside, which is exactly how the Desktop-app failure hid.
|
|
213
|
+
setFailed(String(error && error.message ? error.message : error));
|
|
189
214
|
setSnapshot(null);
|
|
190
215
|
});
|
|
191
216
|
}
|
|
@@ -272,7 +297,10 @@ window.__ModuleLoader__.load({
|
|
|
272
297
|
return payload;
|
|
273
298
|
})
|
|
274
299
|
.catch(function (error) {
|
|
275
|
-
|
|
300
|
+
// A transport failure carries the host's error code; a payload-level
|
|
301
|
+
// business error already carries a localized message.
|
|
302
|
+
var text = error && error.code ? describeError(error.code) : String(error && error.message ? error.message : error);
|
|
303
|
+
setNotice({ kind: "error", text: text });
|
|
276
304
|
})
|
|
277
305
|
.then(function () {
|
|
278
306
|
setBusy(null);
|
|
@@ -293,24 +321,39 @@ window.__ModuleLoader__.load({
|
|
|
293
321
|
});
|
|
294
322
|
}
|
|
295
323
|
|
|
324
|
+
/**
|
|
325
|
+
* Report one batch reply. The host's `delete-all` answers with a per-row
|
|
326
|
+
* `results` array; reading it unguarded turned any other reply into a
|
|
327
|
+
* TypeError that replaced the real reason on screen.
|
|
328
|
+
*/
|
|
329
|
+
function reportBatch(payload) {
|
|
330
|
+
var results = payload && Array.isArray(payload.results) ? payload.results : [];
|
|
331
|
+
if (results.length === 0) {
|
|
332
|
+
setNotice({ kind: "error", text: t("error.generic") });
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
var ok = 0;
|
|
336
|
+
var bytes = 0;
|
|
337
|
+
for (var i = 0; i < results.length; i += 1) {
|
|
338
|
+
if (results[i] && results[i].ok === true) {
|
|
339
|
+
ok += 1;
|
|
340
|
+
if (results[i].removed) bytes += results[i].removed.bytes || 0;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
setNotice(
|
|
344
|
+
ok === results.length
|
|
345
|
+
? { kind: "ok", text: t("deletedMany", { n: ok, size: formatBytes(bytes) }) }
|
|
346
|
+
: { kind: "error", text: t("deletedPartial", { ok: ok, failed: results.length - ok }) },
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
|
|
296
350
|
function deleteSelected(mode) {
|
|
297
351
|
var ids = selectedIds;
|
|
298
352
|
if (ids.length === 0) return;
|
|
299
353
|
setBusy("__selected__");
|
|
300
354
|
run(DELETE_ALL_PATH, { ids: ids, mode: mode }, function (payload) {
|
|
301
|
-
var ok = payload.results.filter(function (result) {
|
|
302
|
-
return result.ok === true;
|
|
303
|
-
}).length;
|
|
304
|
-
var bytes = 0;
|
|
305
|
-
for (var i = 0; i < payload.results.length; i += 1) {
|
|
306
|
-
if (payload.results[i].ok && payload.results[i].removed) bytes += payload.results[i].removed.bytes || 0;
|
|
307
|
-
}
|
|
308
355
|
setSelected({});
|
|
309
|
-
|
|
310
|
-
ok === payload.results.length
|
|
311
|
-
? { kind: "ok", text: t("deletedMany", { n: ok, size: formatBytes(bytes) }) }
|
|
312
|
-
: { kind: "error", text: t("deletedPartial", { ok: ok, failed: payload.results.length - ok }) },
|
|
313
|
-
);
|
|
356
|
+
reportBatch(payload);
|
|
314
357
|
});
|
|
315
358
|
}
|
|
316
359
|
|
|
@@ -319,20 +362,7 @@ window.__ModuleLoader__.load({
|
|
|
319
362
|
return row.id;
|
|
320
363
|
});
|
|
321
364
|
setBusy("__all__");
|
|
322
|
-
run(DELETE_ALL_PATH, { ids: ids, mode: mode },
|
|
323
|
-
var ok = payload.results.filter(function (result) {
|
|
324
|
-
return result.ok === true;
|
|
325
|
-
}).length;
|
|
326
|
-
var bytes = 0;
|
|
327
|
-
for (var i = 0; i < payload.results.length; i += 1) {
|
|
328
|
-
if (payload.results[i].ok && payload.results[i].removed) bytes += payload.results[i].removed.bytes || 0;
|
|
329
|
-
}
|
|
330
|
-
setNotice(
|
|
331
|
-
ok === payload.results.length
|
|
332
|
-
? { kind: "ok", text: t("deletedMany", { n: ok, size: formatBytes(bytes) }) }
|
|
333
|
-
: { kind: "error", text: t("deletedPartial", { ok: ok, failed: payload.results.length - ok }) },
|
|
334
|
-
);
|
|
335
|
-
});
|
|
365
|
+
run(DELETE_ALL_PATH, { ids: ids, mode: mode }, reportBatch);
|
|
336
366
|
}
|
|
337
367
|
|
|
338
368
|
function restoreRow(entry) {
|
|
@@ -366,10 +396,10 @@ window.__ModuleLoader__.load({
|
|
|
366
396
|
});
|
|
367
397
|
}
|
|
368
398
|
|
|
369
|
-
if (failed) {
|
|
399
|
+
if (failed !== null) {
|
|
370
400
|
return h("div", { className: "dasm_section" }, [
|
|
371
401
|
h("h2", { className: "dasm_title", key: "title" }, t("nav")),
|
|
372
|
-
h("p", { className: "dasm_status", key: "failed" }, t("hostUnavailable")),
|
|
402
|
+
h("p", { className: "dasm_status", key: "failed" }, t("hostUnavailable", { reason: failed })),
|
|
373
403
|
]);
|
|
374
404
|
}
|
|
375
405
|
|
|
@@ -751,7 +781,7 @@ window.__ModuleLoader__.load({
|
|
|
751
781
|
nav: "已归档",
|
|
752
782
|
search: "搜索已归档会话",
|
|
753
783
|
loading: "正在读取会话…",
|
|
754
|
-
hostUnavailable: "
|
|
784
|
+
hostUnavailable: "读不到宿主状态({reason}):请确认插件已随宿主加载,然后刷新页面。",
|
|
755
785
|
apiMismatch: "插件的前后端版本不一致:请重启 DSH 后再试。",
|
|
756
786
|
empty: "暂无已归档会话。",
|
|
757
787
|
emptySearch: "没有匹配的会话。",
|
|
@@ -820,7 +850,7 @@ window.__ModuleLoader__.load({
|
|
|
820
850
|
nav: "Archived",
|
|
821
851
|
search: "Search archived sessions",
|
|
822
852
|
loading: "Reading sessions…",
|
|
823
|
-
hostUnavailable: "Cannot read host state — check that the plugin loaded, then refresh.",
|
|
853
|
+
hostUnavailable: "Cannot read host state ({reason}) — check that the plugin loaded, then refresh.",
|
|
824
854
|
apiMismatch: "The plugin's two halves disagree on the API version — restart DSH and try again.",
|
|
825
855
|
empty: "No archived sessions.",
|
|
826
856
|
emptySearch: "No matching sessions.",
|
package/lib/host/paths.js
CHANGED
|
@@ -18,8 +18,87 @@ import { readdir, stat } from "node:fs/promises";
|
|
|
18
18
|
import { homedir } from "node:os";
|
|
19
19
|
import { join, resolve } from "node:path";
|
|
20
20
|
|
|
21
|
-
/**
|
|
22
|
-
|
|
21
|
+
/**
|
|
22
|
+
* Ids the *store* and the *host API* accept. A session is `session-<uuid>`, but
|
|
23
|
+
* a session whose header carries no prefix at all is a bare uuid (imported
|
|
24
|
+
* transcripts and delegate imports both produce those).
|
|
25
|
+
*/
|
|
26
|
+
const UUID = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}";
|
|
27
|
+
const IMPORT_PREFIXES = ["import-session-", "import-"];
|
|
28
|
+
const CANONICAL_PREFIX = "session-";
|
|
29
|
+
export const HOST_ID = new RegExp(`^(?:${CANONICAL_PREFIX})?${UUID}$`, "i");
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Ids an archived *row* may be filed under.
|
|
33
|
+
*
|
|
34
|
+
* DSH's own sessions are `session-<uuid>`, but the import feature namespaces the
|
|
35
|
+
* id it files in the archive index: `import-session-<uuid>` for an imported
|
|
36
|
+
* transcript and `import-<uuid>` for a delegate-imported session. Every one of
|
|
37
|
+
* those is a real archived row the panel draws, so all of them must be accepted.
|
|
38
|
+
* Refusing the import namespaces was a silent, total failure: the rows rendered
|
|
39
|
+
* fine and every delete came back `invalid-session-id`.
|
|
40
|
+
*
|
|
41
|
+
* The path-safety argument is unchanged — a bare uuid is the only shape any form
|
|
42
|
+
* may carry, so no form can introduce a separator, a `..`, or a NUL.
|
|
43
|
+
*/
|
|
44
|
+
export const SESSION_ID = new RegExp(`^(?:import-session-|import-|${CANONICAL_PREFIX})?${UUID}$`, "i");
|
|
45
|
+
|
|
46
|
+
/** The raw id a registry entry names, with its import namespace peeled off. */
|
|
47
|
+
export function canonicalSessionId(raw) {
|
|
48
|
+
const text = String(raw);
|
|
49
|
+
for (const prefix of IMPORT_PREFIXES) {
|
|
50
|
+
if (text.startsWith(prefix)) return text.slice(prefix.length);
|
|
51
|
+
}
|
|
52
|
+
return text;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Ordered ids the *filesystem* may know one archived row by.
|
|
57
|
+
*
|
|
58
|
+
* The archive index is namespaced; the session store is not. `import-<uuid>` and
|
|
59
|
+
* `import-session-<uuid>` both name a session whose directory (and whose log
|
|
60
|
+
* header `id`) is the bare uuid or `session-<uuid>`. `locateArtifacts` only ever
|
|
61
|
+
* accepts a directory whose decoded name is one of these, so a namespace
|
|
62
|
+
* collision cannot make the plugin delete a different session's log.
|
|
63
|
+
*/
|
|
64
|
+
export function artifactIdCandidates(raw) {
|
|
65
|
+
const text = String(raw);
|
|
66
|
+
const out = [text];
|
|
67
|
+
const add = (value) => {
|
|
68
|
+
if (value.length > 0 && !out.includes(value)) out.push(value);
|
|
69
|
+
};
|
|
70
|
+
// The import namespace wraps the store shape, so peeling it off is the first
|
|
71
|
+
// and usually only step. The other store form is added as a fallback, because
|
|
72
|
+
// each import form has been seen wrapping each store shape. Peeling `import-`
|
|
73
|
+
// off `import-session-<uuid>` would leave `session-session-<uuid>`, a name
|
|
74
|
+
// nothing on disk carries.
|
|
75
|
+
for (const prefix of IMPORT_PREFIXES) {
|
|
76
|
+
if (!text.startsWith(prefix)) continue;
|
|
77
|
+
const rest = text.slice(prefix.length);
|
|
78
|
+
add(rest);
|
|
79
|
+
add(CANONICAL_PREFIX + rest);
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Inverse of DSH's `encodeSegment`: safe units stay literal and every other code
|
|
86
|
+
* unit is `~XXXX`. Lets a directory name be compared against the candidate ids
|
|
87
|
+
* instead of guessing at string prefixes.
|
|
88
|
+
*/
|
|
89
|
+
export function decodeSegment(name) {
|
|
90
|
+
let out = "";
|
|
91
|
+
for (let i = 0; i < name.length; i += 1) {
|
|
92
|
+
const ch = name[i];
|
|
93
|
+
if (ch !== "~") {
|
|
94
|
+
out += ch;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
out += String.fromCharCode(parseInt(name.slice(i + 1, i + 5), 16));
|
|
98
|
+
i += 4;
|
|
99
|
+
}
|
|
100
|
+
return out;
|
|
101
|
+
}
|
|
23
102
|
|
|
24
103
|
export function dshHome() {
|
|
25
104
|
const configured = process.env.DSH_HOME;
|
|
@@ -78,8 +157,20 @@ export function quarantineMetaFile(sessionId) {
|
|
|
78
157
|
return join(quarantineEntry(sessionId), "meta.json");
|
|
79
158
|
}
|
|
80
159
|
|
|
81
|
-
/**
|
|
160
|
+
/**
|
|
161
|
+
* Every on-disk artifact directory of one session (normally zero or one).
|
|
162
|
+
*
|
|
163
|
+
* The directory is named after the id inside the session log's header, which is
|
|
164
|
+
* NOT always the id the archive index files the row under: an imported row is
|
|
165
|
+
* indexed as `import-session-<uuid>` / `import-<uuid>` while its directory is
|
|
166
|
+
* `session-<uuid>` or the bare uuid. Looking up the raw registry id therefore
|
|
167
|
+
* found nothing, so a delete freed the projection cache and the index entry and
|
|
168
|
+
* silently left the session log on disk — the exact "fake success" this plugin
|
|
169
|
+
* exists to prevent. Match on the decoded directory name instead.
|
|
170
|
+
*/
|
|
82
171
|
export async function locateArtifacts(sessionId) {
|
|
172
|
+
const candidates = artifactIdCandidates(sessionId);
|
|
173
|
+
const wanted = new Set(candidates.map((id) => id.toLowerCase()));
|
|
83
174
|
const found = [];
|
|
84
175
|
let workspaces;
|
|
85
176
|
try {
|
|
@@ -89,8 +180,26 @@ export async function locateArtifacts(sessionId) {
|
|
|
89
180
|
}
|
|
90
181
|
for (const workspace of workspaces) {
|
|
91
182
|
if (!workspace.isDirectory()) continue;
|
|
92
|
-
const
|
|
93
|
-
|
|
183
|
+
const bucket = join(sessionsRoot(), workspace.name);
|
|
184
|
+
// The pre-encoded name is the common case; only fall back to decoding the
|
|
185
|
+
// bucket's entries when it is absent (an id that needed `encodeSegment`).
|
|
186
|
+
const direct = candidates.map((id) => join(bucket, id)).find((path) => existsSync(path));
|
|
187
|
+
if (direct !== undefined) {
|
|
188
|
+
found.push(direct);
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
let entries;
|
|
192
|
+
try {
|
|
193
|
+
entries = await readdir(bucket, { withFileTypes: true });
|
|
194
|
+
} catch {
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
for (const entry of entries) {
|
|
198
|
+
if (!entry.isDirectory()) continue;
|
|
199
|
+
if (!wanted.has(decodeSegment(entry.name).toLowerCase())) continue;
|
|
200
|
+
found.push(join(bucket, entry.name));
|
|
201
|
+
break;
|
|
202
|
+
}
|
|
94
203
|
}
|
|
95
204
|
return found;
|
|
96
205
|
}
|
package/lib/host/quarantine.js
CHANGED
|
@@ -14,6 +14,7 @@ import { constants } from "node:fs";
|
|
|
14
14
|
import { access, cp, mkdir, readdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
15
15
|
import { dirname, join } from "node:path";
|
|
16
16
|
import {
|
|
17
|
+
canonicalSessionId,
|
|
17
18
|
directorySize,
|
|
18
19
|
projectionCacheFile,
|
|
19
20
|
quarantineArtifactDir,
|
|
@@ -133,8 +134,13 @@ export async function restoreSession(ctx, sessionId, entry) {
|
|
|
133
134
|
const artifact = quarantineArtifactDir(sessionId);
|
|
134
135
|
const cache = quarantineCacheFile(sessionId);
|
|
135
136
|
const restored = { directories: 0, cache: false, rearchived: false };
|
|
137
|
+
// The registry id may be namespaced (`import-…`), while the log directory and
|
|
138
|
+
// the projection-cache document are named after the raw session id. Restoring
|
|
139
|
+
// under the registry id would put the directory back under a name nothing
|
|
140
|
+
// looks for again.
|
|
141
|
+
const rawId = canonicalSessionId(sessionId);
|
|
136
142
|
|
|
137
|
-
const target = entry.workspaceDir !== null ? join(entry.workspaceDir,
|
|
143
|
+
const target = entry.workspaceDir !== null ? join(entry.workspaceDir, rawId) : null;
|
|
138
144
|
if (await exists(artifact)) {
|
|
139
145
|
if (target === null) throw new Error("quarantine metadata has no workspaceDir");
|
|
140
146
|
await mkdir(dirname(target), { recursive: true });
|
|
@@ -142,7 +148,7 @@ export async function restoreSession(ctx, sessionId, entry) {
|
|
|
142
148
|
restored.directories = 1;
|
|
143
149
|
}
|
|
144
150
|
if (await exists(cache)) {
|
|
145
|
-
await movePath(cache, projectionCacheFile(
|
|
151
|
+
await movePath(cache, projectionCacheFile(rawId));
|
|
146
152
|
restored.cache = true;
|
|
147
153
|
}
|
|
148
154
|
await rm(root, { recursive: true, force: true });
|
package/lib/host/trust.js
CHANGED
|
@@ -1,14 +1,29 @@
|
|
|
1
1
|
// Request trust for the destructive routes.
|
|
2
2
|
//
|
|
3
|
-
// Loopback,
|
|
3
|
+
// Loopback, and provably not cross-site. The routes are destructive, so they
|
|
4
4
|
// refuse anything that is not this browser talking to this server.
|
|
5
5
|
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
6
|
+
// The shipped web client is not the only client: the Desktop app talks to the
|
|
7
|
+
// same host, and a request that reaches us through the app's own pipeline can
|
|
8
|
+
// arrive without the Fetch Metadata headers a page-initiated fetch carries.
|
|
9
|
+
// Requiring `sec-fetch-site: same-origin` outright therefore rejected the
|
|
10
|
+
// Desktop app's perfectly legitimate same-origin call. What actually has to
|
|
11
|
+
// hold is: loopback, not cross-site, and either the marker header or a proven
|
|
12
|
+
// same-origin signal.
|
|
13
|
+
//
|
|
14
|
+
// * cross-site is always refused — that is the CSRF case, and every browser
|
|
15
|
+
// that matters sends `sec-fetch-site` for it;
|
|
16
|
+
// * `Origin`, when present, must match `Host` (browsers omit it on
|
|
17
|
+
// same-origin GETs, which is why it cannot be required);
|
|
18
|
+
// * a request with no signals at all needs the marker header, which a
|
|
19
|
+
// cross-origin page cannot set without a preflight this server never
|
|
20
|
+
// approves.
|
|
9
21
|
|
|
10
22
|
export const HEADER = "x-dsh-archived";
|
|
11
23
|
|
|
24
|
+
/** The pre-rename marker, still accepted so an open tab survives the rename. */
|
|
25
|
+
export const LEGACY_HEADER = "x-dsh-archived-sessions";
|
|
26
|
+
|
|
12
27
|
export function header(request, key) {
|
|
13
28
|
const value = request.headers?.[key];
|
|
14
29
|
return Array.isArray(value) ? value[0] : value;
|
|
@@ -25,15 +40,8 @@ export function isLoopbackAddress(value) {
|
|
|
25
40
|
);
|
|
26
41
|
}
|
|
27
42
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
if (!isLoopbackAddress(request.socket?.remoteAddress)) return false;
|
|
31
|
-
const site = header(request, "sec-fetch-site");
|
|
32
|
-
if (site !== undefined && site !== "same-origin") return false;
|
|
33
|
-
const host = header(request, "host");
|
|
34
|
-
if (!host) return false;
|
|
35
|
-
const origin = header(request, "origin");
|
|
36
|
-
if (origin === undefined) return site === "same-origin";
|
|
43
|
+
/** Whether the request's `Origin` names this very host. */
|
|
44
|
+
function originMatchesHost(origin, host) {
|
|
37
45
|
try {
|
|
38
46
|
const url = new URL(origin);
|
|
39
47
|
return (
|
|
@@ -46,6 +54,46 @@ export function isTrustedRequest(request) {
|
|
|
46
54
|
}
|
|
47
55
|
}
|
|
48
56
|
|
|
57
|
+
/**
|
|
58
|
+
* The full trust decision, with the evidence that produced it.
|
|
59
|
+
*
|
|
60
|
+
* @returns `{ trusted, ... }` — the reasons are kept so a refused request can
|
|
61
|
+
* be reported instead of vanishing, which is what made the Desktop-app failure
|
|
62
|
+
* invisible for a whole round of debugging.
|
|
63
|
+
*/
|
|
64
|
+
export function trustReport(request) {
|
|
65
|
+
const marker = header(request, HEADER) ?? header(request, LEGACY_HEADER);
|
|
66
|
+
const site = header(request, "sec-fetch-site");
|
|
67
|
+
const host = header(request, "host");
|
|
68
|
+
const origin = header(request, "origin");
|
|
69
|
+
const loopback = isLoopbackAddress(request.socket?.remoteAddress);
|
|
70
|
+
// A present Origin or Fetch Metadata signal is authoritative and cannot be
|
|
71
|
+
// overridden by the marker: a mismatch is fatal on its own.
|
|
72
|
+
const originOk = origin === undefined || originMatchesHost(origin, host);
|
|
73
|
+
const siteOk = site === undefined || site === "same-origin";
|
|
74
|
+
// A same-origin signal is something a cross-origin page cannot produce, so it
|
|
75
|
+
// proves the caller on its own. The marker covers the case where no Fetch
|
|
76
|
+
// Metadata header arrives at all (a non-browser client, or the Desktop app's
|
|
77
|
+
// own request pipeline).
|
|
78
|
+
const provenSameOrigin = (origin !== undefined && originOk) || site === "same-origin";
|
|
79
|
+
const trusted = loopback && Boolean(host) && originOk && siteOk && (marker === "1" || provenSameOrigin);
|
|
80
|
+
return {
|
|
81
|
+
trusted,
|
|
82
|
+
marker: marker ?? null,
|
|
83
|
+
loopback,
|
|
84
|
+
host: host ?? null,
|
|
85
|
+
site: site ?? null,
|
|
86
|
+
origin: origin ?? null,
|
|
87
|
+
originOk,
|
|
88
|
+
siteOk,
|
|
89
|
+
provenSameOrigin,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function isTrustedRequest(request) {
|
|
94
|
+
return trustReport(request).trusted;
|
|
95
|
+
}
|
|
96
|
+
|
|
49
97
|
export function json(response, statusCode, value) {
|
|
50
98
|
response.writeHead(statusCode, {
|
|
51
99
|
"content-type": "application/json; charset=utf-8",
|
package/lib/index.js
CHANGED
|
@@ -31,6 +31,8 @@ import { dirname } from "node:path";
|
|
|
31
31
|
|
|
32
32
|
import {
|
|
33
33
|
SESSION_ID,
|
|
34
|
+
artifactIdCandidates,
|
|
35
|
+
canonicalSessionId,
|
|
34
36
|
directorySize,
|
|
35
37
|
dshHome,
|
|
36
38
|
legacyAggregateFile,
|
|
@@ -54,18 +56,29 @@ import {
|
|
|
54
56
|
quarantineSession,
|
|
55
57
|
restoreSession,
|
|
56
58
|
} from "./host/quarantine.js";
|
|
57
|
-
import { failureDetail,
|
|
59
|
+
import { failureDetail, json, readJsonBody, trustReport } from "./host/trust.js";
|
|
58
60
|
|
|
59
61
|
export const name = "dsh-archived";
|
|
60
62
|
export const inject = ["webServer", "workspaceRegistry", "agents", "sessions"];
|
|
61
63
|
|
|
62
64
|
const BASE = "/api/dsh-archived";
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The pre-rename prefix, still served so a tab that loaded the 0.2.0 client
|
|
68
|
+
* keeps working across the rename instead of stranding the user on an error
|
|
69
|
+
* page until they reload. The 0.2.0 client used the same six verbs, so the
|
|
70
|
+
* alias is a prefix swap and nothing more.
|
|
71
|
+
*/
|
|
72
|
+
const LEGACY_BASE = "/api/dsh-archived-sessions";
|
|
73
|
+
/** Route suffixes, registered under both the current and the legacy prefix. */
|
|
74
|
+
const SUFFIX = {
|
|
75
|
+
state: "/state",
|
|
76
|
+
delete: "/delete",
|
|
77
|
+
deleteAll: "/delete-all",
|
|
78
|
+
restore: "/restore",
|
|
79
|
+
empty: "/empty-quarantine",
|
|
80
|
+
reveal: "/reveal",
|
|
81
|
+
};
|
|
69
82
|
|
|
70
83
|
/** Wire version of the route contract, so the panel can detect a half-upgraded host. */
|
|
71
84
|
export const API_VERSION = 2;
|
|
@@ -82,6 +95,26 @@ const sweeps = new Set();
|
|
|
82
95
|
/** Session ids with a delete in flight, so two tabs cannot interleave on one row. */
|
|
83
96
|
const inFlight = new Set();
|
|
84
97
|
|
|
98
|
+
/**
|
|
99
|
+
* The last few refused requests, with the evidence that refused them. A 403
|
|
100
|
+
* that leaves no trace is undebuggable from the outside — which is exactly how
|
|
101
|
+
* the Desktop app ended up looking broken while every probe said it worked.
|
|
102
|
+
*/
|
|
103
|
+
const refusals = [];
|
|
104
|
+
const MAX_REFUSALS = 20;
|
|
105
|
+
|
|
106
|
+
function recordRefusal(path, request, report) {
|
|
107
|
+
refusals.push({
|
|
108
|
+
at: new Date().toISOString(),
|
|
109
|
+
path,
|
|
110
|
+
method: request.method,
|
|
111
|
+
remote: request.socket?.remoteAddress ?? null,
|
|
112
|
+
userAgent: String(request.headers?.["user-agent"] ?? "").slice(0, 80),
|
|
113
|
+
...report,
|
|
114
|
+
});
|
|
115
|
+
if (refusals.length > MAX_REFUSALS) refusals.splice(0, refusals.length - MAX_REFUSALS);
|
|
116
|
+
}
|
|
117
|
+
|
|
85
118
|
/** The archive set's owner. `ctx.get` is the documented safe read; the property stays as a fallback. */
|
|
86
119
|
function registryOf(ctx) {
|
|
87
120
|
try {
|
|
@@ -99,6 +132,33 @@ function archivedIds(ctx) {
|
|
|
99
132
|
return Array.isArray(ids) ? [...ids] : [];
|
|
100
133
|
}
|
|
101
134
|
|
|
135
|
+
/**
|
|
136
|
+
* Ids other than the archived row that could name the *live agent* of the same
|
|
137
|
+
* session. An imported row is indexed under a namespaced id
|
|
138
|
+
* (`import-session-<uuid>` / `import-<uuid>`) while the agent table, the session
|
|
139
|
+
* store and the on-disk directory all know the bare uuid, so a running-turn
|
|
140
|
+
* check done only against the archived id would miss a live agent and delete a
|
|
141
|
+
* session out from under it.
|
|
142
|
+
*/
|
|
143
|
+
function agentIdCandidates(sessionId) {
|
|
144
|
+
return artifactIdCandidates(sessionId).filter((id) => id !== sessionId);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Is any candidate id for this row a live agent running a turn? The archived id
|
|
149
|
+
* is checked by the caller (it is the form the host may broadcast), so only the
|
|
150
|
+
* remaining candidates are consulted here.
|
|
151
|
+
*/
|
|
152
|
+
function runningAlias(ctx, sessionId) {
|
|
153
|
+
return agentIdCandidates(sessionId).find((id) => isRunning(ctx, id)) ?? null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** The raw ids a delete had to look under, for an operator-facing log line. */
|
|
157
|
+
function artifactHints(sessionId) {
|
|
158
|
+
const hints = artifactIdCandidates(sessionId).filter((id) => id !== sessionId);
|
|
159
|
+
return hints.length > 0 ? `, looked up as ${hints.join(" | ")}` : "";
|
|
160
|
+
}
|
|
161
|
+
|
|
102
162
|
/**
|
|
103
163
|
* Whether this host still offers what a destructive route needs. A host whose
|
|
104
164
|
* registry lost `unarchiveSession` cannot clear the archive index, and a delete
|
|
@@ -129,9 +189,10 @@ async function stateOf(ctx) {
|
|
|
129
189
|
const dirs = await locateArtifacts(id);
|
|
130
190
|
let bytes = 0;
|
|
131
191
|
for (const dir of dirs) bytes += await directorySize(dir);
|
|
132
|
-
const
|
|
192
|
+
const rawId = canonicalSessionId(id);
|
|
193
|
+
const cacheFile = projectionCacheFile(rawId);
|
|
133
194
|
const cache = existsSync(cacheFile);
|
|
134
|
-
const document = cache ? await readCacheDocument(
|
|
195
|
+
const document = cache ? await readCacheDocument(rawId) : null;
|
|
135
196
|
items.push({
|
|
136
197
|
id,
|
|
137
198
|
archived: true,
|
|
@@ -155,6 +216,7 @@ async function stateOf(ctx) {
|
|
|
155
216
|
home: dshHome(),
|
|
156
217
|
items,
|
|
157
218
|
quarantine: { count: parked.length, bytes: sumBytes(parked), items: parked },
|
|
219
|
+
refusals: refusals.slice().reverse(),
|
|
158
220
|
legacy: {
|
|
159
221
|
present: existsSync(legacyAggregateFile()),
|
|
160
222
|
rows: legacyRows === null ? null : legacyRows.size,
|
|
@@ -193,7 +255,7 @@ async function sweepResidue(ctx, sessionId) {
|
|
|
193
255
|
if (archivedIds(ctx).includes(sessionId)) return;
|
|
194
256
|
if (isRunning(ctx, sessionId)) return;
|
|
195
257
|
if ((await locateArtifacts(sessionId)).length > 0) return;
|
|
196
|
-
const cacheFile = projectionCacheFile(sessionId);
|
|
258
|
+
const cacheFile = projectionCacheFile(canonicalSessionId(sessionId));
|
|
197
259
|
if (existsSync(cacheFile)) await rm(cacheFile, { force: true });
|
|
198
260
|
}
|
|
199
261
|
|
|
@@ -217,12 +279,17 @@ async function deleteOne(ctx, sessionId, mode) {
|
|
|
217
279
|
if (!hostSupportsDelete(ctx)) return refuse("no-registry", { sessionId });
|
|
218
280
|
if (!archivedIds(ctx).includes(sessionId)) return refuse("not-archived", { sessionId });
|
|
219
281
|
if (isRunning(ctx, sessionId)) return refuse("session-running", { sessionId });
|
|
282
|
+
const alias = runningAlias(ctx, sessionId);
|
|
283
|
+
if (alias !== null) return refuse("session-running", { sessionId, runningAs: alias });
|
|
220
284
|
const descendants = runningDescendants(ctx, sessionId);
|
|
221
285
|
if (descendants.length > 0) return refuse("subagent-running", { sessionId, descendants });
|
|
222
286
|
|
|
223
287
|
return withLock(sessionId, async () => {
|
|
288
|
+
// The archive index is namespaced; the log directory, the projection cache
|
|
289
|
+
// document and the host's own session store all use the raw id.
|
|
290
|
+
const rawId = canonicalSessionId(sessionId);
|
|
224
291
|
const dirs = await locateArtifacts(sessionId);
|
|
225
|
-
const cacheFile = projectionCacheFile(
|
|
292
|
+
const cacheFile = projectionCacheFile(rawId);
|
|
226
293
|
let bytes = 0;
|
|
227
294
|
for (const dir of dirs) bytes += await directorySize(dir);
|
|
228
295
|
|
|
@@ -241,7 +308,7 @@ async function deleteOne(ctx, sessionId, mode) {
|
|
|
241
308
|
// and that the panel would not even show.
|
|
242
309
|
removed = { directories: 0, bytes: 0, cache: false, parked: false };
|
|
243
310
|
} else {
|
|
244
|
-
const document = existsSync(cacheFile) ? await readCacheDocument(
|
|
311
|
+
const document = existsSync(cacheFile) ? await readCacheDocument(rawId) : null;
|
|
245
312
|
removed = await quarantineSession(sessionId, {
|
|
246
313
|
dirs,
|
|
247
314
|
cacheFile,
|
|
@@ -258,7 +325,7 @@ async function deleteOne(ctx, sessionId, mode) {
|
|
|
258
325
|
await registryOf(ctx).unarchiveSession(sessionId);
|
|
259
326
|
scheduleSweep(ctx, sessionId);
|
|
260
327
|
ctx.logger?.info?.(
|
|
261
|
-
`[dsh-archived] delete ${sessionId} (${mode}): ${removed.directories} dir(s), ${removed.bytes} bytes`,
|
|
328
|
+
`[dsh-archived] delete ${sessionId} (${mode}): ${removed.directories} dir(s), ${removed.bytes} bytes${artifactHints(sessionId)}`,
|
|
262
329
|
);
|
|
263
330
|
return { ok: true, sessionId, mode, removed, archivedSessionIds: archivedIds(ctx) };
|
|
264
331
|
});
|
|
@@ -331,8 +398,11 @@ function route(ctx, { path, method, handle }) {
|
|
|
331
398
|
json(response, 405, { error: "method-not-allowed" });
|
|
332
399
|
return;
|
|
333
400
|
}
|
|
334
|
-
|
|
335
|
-
|
|
401
|
+
const report = trustReport(request);
|
|
402
|
+
if (!report.trusted) {
|
|
403
|
+
recordRefusal(path, request, report);
|
|
404
|
+
ctx.logger?.warn?.(`[dsh-archived] refused ${request.method} ${path}: ${JSON.stringify(report)}`);
|
|
405
|
+
json(response, 403, { error: "forbidden", detail: report });
|
|
336
406
|
return;
|
|
337
407
|
}
|
|
338
408
|
try {
|
|
@@ -350,37 +420,39 @@ function route(ctx, { path, method, handle }) {
|
|
|
350
420
|
}
|
|
351
421
|
|
|
352
422
|
export function apply(ctx) {
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
});
|
|
360
|
-
|
|
361
|
-
route(ctx, {
|
|
362
|
-
path: DELETE_ALL_PATH,
|
|
363
|
-
method: "POST",
|
|
364
|
-
handle: (body) => {
|
|
365
|
-
const mode = body?.mode === "forever" ? "forever" : "quarantine";
|
|
366
|
-
const ids = Array.isArray(body?.ids) ? body.ids : archivedIds(ctx);
|
|
367
|
-
return deleteMany(ctx, ids, mode);
|
|
423
|
+
const specs = [
|
|
424
|
+
{ suffix: SUFFIX.state, method: "GET", handle: () => stateOf(ctx) },
|
|
425
|
+
{
|
|
426
|
+
suffix: SUFFIX.delete,
|
|
427
|
+
method: "POST",
|
|
428
|
+
handle: (body) => deleteOne(ctx, body?.sessionId, body?.mode === "forever" ? "forever" : "quarantine"),
|
|
368
429
|
},
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
const parked = await listQuarantine();
|
|
378
|
-
for (const entry of parked) await purgeQuarantineEntry(entry.id);
|
|
379
|
-
return { ok: true, purged: parked.length, bytes: sumBytes(parked) };
|
|
430
|
+
{
|
|
431
|
+
suffix: SUFFIX.deleteAll,
|
|
432
|
+
method: "POST",
|
|
433
|
+
handle: (body) => {
|
|
434
|
+
const mode = body?.mode === "forever" ? "forever" : "quarantine";
|
|
435
|
+
const ids = Array.isArray(body?.ids) ? body.ids : archivedIds(ctx);
|
|
436
|
+
return deleteMany(ctx, ids, mode);
|
|
437
|
+
},
|
|
380
438
|
},
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
439
|
+
{ suffix: SUFFIX.restore, method: "POST", handle: (body) => restoreOne(ctx, body?.sessionId) },
|
|
440
|
+
{
|
|
441
|
+
suffix: SUFFIX.empty,
|
|
442
|
+
method: "POST",
|
|
443
|
+
handle: async () => {
|
|
444
|
+
const parked = await listQuarantine();
|
|
445
|
+
for (const entry of parked) await purgeQuarantineEntry(entry.id);
|
|
446
|
+
return { ok: true, purged: parked.length, bytes: sumBytes(parked) };
|
|
447
|
+
},
|
|
448
|
+
},
|
|
449
|
+
{ suffix: SUFFIX.reveal, method: "POST", handle: (body) => revealOne(ctx, body?.sessionId) },
|
|
450
|
+
];
|
|
451
|
+
for (const spec of specs) {
|
|
452
|
+
for (const base of [BASE, LEGACY_BASE]) {
|
|
453
|
+
route(ctx, { path: base + spec.suffix, method: spec.method, handle: spec.handle });
|
|
454
|
+
}
|
|
455
|
+
}
|
|
384
456
|
|
|
385
457
|
// A parked session is recoverable for a bounded window, then it expires on
|
|
386
458
|
// its own so the quarantine can never become a second archive nobody drains.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-archived",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
4
4
|
"description": "Archived-sessions page for DeepSeek Harness: per-row and batch deletion that clears the session log, the projection cache and the archive index together, with a 30-day recycle bin, subagent-aware refusals and a per-row disk footprint.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|