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 CHANGED
@@ -80,7 +80,15 @@ window.__ModuleLoader__.load({
80
80
  document.head.appendChild(tag);
81
81
  }
82
82
 
83
- /** Same-origin call to this plugin's host routes; never throws on a JSON error body. */
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.json().catch(function () {
96
- throw new Error("HTTP " + response.status);
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(false);
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) throw new Error("state");
203
+ if (!payload || payload.ok !== true) {
204
+ throw new Error(payload && payload.error ? String(payload.error) : "state");
205
+ }
183
206
  setSnapshot(payload);
184
- setFailed(false);
207
+ setFailed(null);
185
208
  return payload;
186
209
  })
187
- .catch(function () {
188
- setFailed(true);
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
- setNotice({ kind: "error", text: String(error && error.message ? error.message : error) });
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
- setNotice(
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 }, function (payload) {
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
- /** Session ids this plugin is willing to touch: the canonical `session-<uuid>`. */
22
- export const SESSION_ID = /^session-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
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
- /** Every on-disk artifact directory of one session (normally zero or one). */
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 candidate = join(sessionsRoot(), workspace.name, sessionId);
93
- if (existsSync(candidate)) found.push(candidate);
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
  }
@@ -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, sessionId) : null;
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(sessionId));
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, marker-header, same-origin. The routes are destructive, so they
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
- // `Origin` is authoritative when present, but browsers omit it on same-origin
7
- // GETs — so that case falls back to Fetch Metadata's `sec-fetch-site`, which a
8
- // cross-site caller cannot forge. A request with neither signal is refused.
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
- export function isTrustedRequest(request) {
29
- if (header(request, HEADER) !== "1") return false;
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, isTrustedRequest, json, readJsonBody } from "./host/trust.js";
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
- const STATE_PATH = BASE + "/state";
64
- const DELETE_PATH = BASE + "/delete";
65
- const DELETE_ALL_PATH = BASE + "/delete-all";
66
- const RESTORE_PATH = BASE + "/restore";
67
- const EMPTY_PATH = BASE + "/empty-quarantine";
68
- const REVEAL_PATH = BASE + "/reveal";
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 cacheFile = projectionCacheFile(id);
192
+ const rawId = canonicalSessionId(id);
193
+ const cacheFile = projectionCacheFile(rawId);
133
194
  const cache = existsSync(cacheFile);
134
- const document = cache ? await readCacheDocument(id) : null;
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(sessionId);
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(sessionId) : null;
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
- if (!isTrustedRequest(request)) {
335
- json(response, 403, { error: "forbidden" });
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
- route(ctx, { path: STATE_PATH, method: "GET", handle: () => stateOf(ctx) });
354
-
355
- route(ctx, {
356
- path: DELETE_PATH,
357
- method: "POST",
358
- handle: (body) => deleteOne(ctx, body?.sessionId, body?.mode === "forever" ? "forever" : "quarantine"),
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
- route(ctx, { path: RESTORE_PATH, method: "POST", handle: (body) => restoreOne(ctx, body?.sessionId) });
372
-
373
- route(ctx, {
374
- path: EMPTY_PATH,
375
- method: "POST",
376
- handle: async () => {
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
- route(ctx, { path: REVEAL_PATH, method: "POST", handle: (body) => revealOne(ctx, body?.sessionId) });
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.0",
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",