dsh-archived 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +173 -0
- package/cordis.patch.yml +5 -0
- package/lib/client.js +931 -0
- package/lib/host/metadata.js +144 -0
- package/lib/host/paths.js +117 -0
- package/lib/host/quarantine.js +185 -0
- package/lib/host/trust.js +68 -0
- package/lib/index.js +410 -0
- package/package.json +106 -0
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// Read-only metadata about archived sessions.
|
|
2
|
+
//
|
|
3
|
+
// The Settings page must be able to describe a row even when the browser's
|
|
4
|
+
// session store has no summary for it — that is exactly the "index-only
|
|
5
|
+
// residue" case (the archive set names an id whose log is already gone, or
|
|
6
|
+
// whose log is present but not loaded in this client). The projection cache
|
|
7
|
+
// document carries what a row needs: `record.rows.title.val` and
|
|
8
|
+
// `record.identity.{createdAt, cwd, formatVersion}`.
|
|
9
|
+
|
|
10
|
+
import { readFile, stat } from "node:fs/promises";
|
|
11
|
+
import { legacyAggregateFile, projectionCacheFile } from "./paths.js";
|
|
12
|
+
|
|
13
|
+
/** Parse one projection-cache document; null when it is missing or unreadable. */
|
|
14
|
+
export async function readCacheDocument(sessionId) {
|
|
15
|
+
try {
|
|
16
|
+
const text = await readFile(projectionCacheFile(sessionId), "utf8");
|
|
17
|
+
const parsed = JSON.parse(text);
|
|
18
|
+
const record = parsed?.record;
|
|
19
|
+
if (record === null || typeof record !== "object") return null;
|
|
20
|
+
const title = record.rows?.title?.val;
|
|
21
|
+
const identity = record.identity ?? {};
|
|
22
|
+
return {
|
|
23
|
+
title: typeof title === "string" && title.length > 0 ? title : null,
|
|
24
|
+
createdAt: typeof identity.createdAt === "number" ? identity.createdAt : null,
|
|
25
|
+
cwd: typeof identity.cwd === "string" ? identity.cwd : null,
|
|
26
|
+
formatVersion: typeof identity.formatVersion === "number" ? identity.formatVersion : null,
|
|
27
|
+
};
|
|
28
|
+
} catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Session ids still present in the pre-per-record aggregate. The domain moved
|
|
35
|
+
* to one document per session and this file stopped being written (its mtime on
|
|
36
|
+
* a live machine trails the per-record directory by weeks), so it is reported
|
|
37
|
+
* and never rewritten: it is host-owned state we only read.
|
|
38
|
+
*
|
|
39
|
+
* @returns the id set, or null when the file is absent or unreadable.
|
|
40
|
+
*/
|
|
41
|
+
let legacyCache = { mtimeMs: -1, rows: null };
|
|
42
|
+
export async function legacyAggregateRows() {
|
|
43
|
+
const file = legacyAggregateFile();
|
|
44
|
+
try {
|
|
45
|
+
const info = await stat(file);
|
|
46
|
+
if (info.mtimeMs === legacyCache.mtimeMs) return legacyCache.rows;
|
|
47
|
+
const parsed = JSON.parse(await readFile(file, "utf8"));
|
|
48
|
+
const rows = parsed?.tables?.sessions;
|
|
49
|
+
const ids = rows !== null && typeof rows === "object" ? new Set(Object.keys(rows)) : new Set();
|
|
50
|
+
legacyCache = { mtimeMs: info.mtimeMs, rows: ids };
|
|
51
|
+
return ids;
|
|
52
|
+
} catch {
|
|
53
|
+
legacyCache = { mtimeMs: -1, rows: null };
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Whether the session is running a turn right now. */
|
|
59
|
+
export function isRunning(ctx, sessionId) {
|
|
60
|
+
try {
|
|
61
|
+
return ctx.agents?.get?.(sessionId)?.status === "running";
|
|
62
|
+
} catch {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The subagent lineage of one live agent: itself first, then its ancestors. */
|
|
68
|
+
function lineageOfAgent(ctx, agent) {
|
|
69
|
+
const chain = [];
|
|
70
|
+
const seen = new Set();
|
|
71
|
+
let header = agent?.session?.header;
|
|
72
|
+
while (header !== undefined && header !== null && !seen.has(header.id)) {
|
|
73
|
+
chain.push(header.id);
|
|
74
|
+
seen.add(header.id);
|
|
75
|
+
if (header.origin !== "subagent" || header.parentSession === undefined) break;
|
|
76
|
+
const parent = ctx.sessions?.get?.(header.parentSession);
|
|
77
|
+
if (parent === undefined || parent === null) {
|
|
78
|
+
chain.push(header.parentSession);
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
header = parent.header;
|
|
82
|
+
}
|
|
83
|
+
return chain;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Running subagent descendants of one session. A parent may be deleted while
|
|
88
|
+
* its children keep working — their parent session would vanish underneath
|
|
89
|
+
* them — so a delete is refused while any of them is mid-turn.
|
|
90
|
+
*
|
|
91
|
+
* @returns ids of running descendants.
|
|
92
|
+
*/
|
|
93
|
+
export function runningDescendants(ctx, sessionId) {
|
|
94
|
+
const found = [];
|
|
95
|
+
let agents;
|
|
96
|
+
try {
|
|
97
|
+
agents = ctx.agents?.list?.() ?? [];
|
|
98
|
+
} catch {
|
|
99
|
+
return found;
|
|
100
|
+
}
|
|
101
|
+
for (const agent of agents) {
|
|
102
|
+
if (agent?.status !== "running") continue;
|
|
103
|
+
if (agent.id === sessionId) continue;
|
|
104
|
+
if (lineageOfAgent(ctx, agent).includes(sessionId)) found.push(agent.id);
|
|
105
|
+
}
|
|
106
|
+
return found;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Ancestor session ids of one session, from the durable header chain. */
|
|
110
|
+
export function ancestorsOf(ctx, sessionId) {
|
|
111
|
+
const header = ctx.sessions?.get?.(sessionId)?.header;
|
|
112
|
+
if (header === undefined || header === null) return [];
|
|
113
|
+
if (header.origin !== "subagent" || header.parentSession === undefined) return [];
|
|
114
|
+
const chain = [];
|
|
115
|
+
const seen = new Set([sessionId]);
|
|
116
|
+
let cursor = header.parentSession;
|
|
117
|
+
while (cursor !== undefined && cursor !== null && !seen.has(cursor)) {
|
|
118
|
+
seen.add(cursor);
|
|
119
|
+
chain.push(cursor);
|
|
120
|
+
const parent = ctx.sessions?.get?.(cursor);
|
|
121
|
+
if (parent === undefined || parent === null) break;
|
|
122
|
+
const parentHeader = parent.header;
|
|
123
|
+
if (parentHeader?.origin !== "subagent" || parentHeader.parentSession === undefined) break;
|
|
124
|
+
cursor = parentHeader.parentSession;
|
|
125
|
+
}
|
|
126
|
+
return chain;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Live subagent children of one session, whether or not they are running. */
|
|
130
|
+
export function liveChildrenOf(ctx, sessionId) {
|
|
131
|
+
const children = [];
|
|
132
|
+
let agents;
|
|
133
|
+
try {
|
|
134
|
+
agents = ctx.agents?.list?.() ?? [];
|
|
135
|
+
} catch {
|
|
136
|
+
return children;
|
|
137
|
+
}
|
|
138
|
+
for (const agent of agents) {
|
|
139
|
+
if (agent?.id === sessionId) continue;
|
|
140
|
+
const header = agent?.session?.header;
|
|
141
|
+
if (header?.origin === "subagent" && header.parentSession === sessionId) children.push(agent.id);
|
|
142
|
+
}
|
|
143
|
+
return children;
|
|
144
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// Path derivation for everything one archived session owns on disk.
|
|
2
|
+
//
|
|
3
|
+
// DSH 0.2.0-rc.2 keeps three pieces of per-session state, and a permanent
|
|
4
|
+
// delete has to answer for all three:
|
|
5
|
+
//
|
|
6
|
+
// 1. the artifact directory $DSH_HOME/sessions/<encoded-workspace>/session-<id>/
|
|
7
|
+
// (session.v4.jsonl.zstd + session.lock)
|
|
8
|
+
// 2. the projection cache doc $DSH_HOME/storages/session_projcache/sessions/<id>.json
|
|
9
|
+
// 3. the archive index entry workspace registry archivedSessionIds
|
|
10
|
+
//
|
|
11
|
+
// Path resolution is inlined instead of importing @deepseek-ai/dsh-home-paths:
|
|
12
|
+
// this plugin is developed out of tree and linked into the profile, so a bare
|
|
13
|
+
// package import would not resolve from its real path. The rule mirrored here
|
|
14
|
+
// is the documented one — $DSH_HOME when non-blank, otherwise ~/.dsh.
|
|
15
|
+
|
|
16
|
+
import { existsSync } from "node:fs";
|
|
17
|
+
import { readdir, stat } from "node:fs/promises";
|
|
18
|
+
import { homedir } from "node:os";
|
|
19
|
+
import { join, resolve } from "node:path";
|
|
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;
|
|
23
|
+
|
|
24
|
+
export function dshHome() {
|
|
25
|
+
const configured = process.env.DSH_HOME;
|
|
26
|
+
if (typeof configured === "string" && configured.trim() !== "") return resolve(configured.trim());
|
|
27
|
+
return join(homedir(), ".dsh");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Root holding one directory per encoded workspace, each with `session-*` children. */
|
|
31
|
+
export function sessionsRoot() {
|
|
32
|
+
return join(dshHome(), "sessions");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Root holding one projection-cache document per session (the `per-record` json layout). */
|
|
36
|
+
export function projectionCacheDir() {
|
|
37
|
+
return join(dshHome(), "storages", "session_projcache", "sessions");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function projectionCacheFile(sessionId) {
|
|
41
|
+
return join(projectionCacheDir(), sessionId + ".json");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The pre-per-record aggregate of the same domain. It stopped being written
|
|
46
|
+
* when the domain moved to one document per session (`unit.version: 3`), so it
|
|
47
|
+
* is host-owned history we only report on, never rewrite.
|
|
48
|
+
*/
|
|
49
|
+
export function legacyAggregateFile() {
|
|
50
|
+
return join(dshHome(), "storages", "session_projcache.json");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The durable workspace registry store; the archive index lives in its `global` block. */
|
|
54
|
+
export function workspaceStoreFile() {
|
|
55
|
+
return join(dshHome(), "storages", "workspace.json");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Deleted-but-recoverable sessions live here, beside the state they came from. */
|
|
59
|
+
export function quarantineRoot() {
|
|
60
|
+
return join(dshHome(), ".archived-sessions-quarantine");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function quarantineEntry(sessionId) {
|
|
64
|
+
return join(quarantineRoot(), sessionId);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Where a quarantined session's original artifact directory is parked. */
|
|
68
|
+
export function quarantineArtifactDir(sessionId) {
|
|
69
|
+
return join(quarantineEntry(sessionId), "session");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Where a quarantined session's projection-cache document is parked. */
|
|
73
|
+
export function quarantineCacheFile(sessionId) {
|
|
74
|
+
return join(quarantineEntry(sessionId), "cache.json");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function quarantineMetaFile(sessionId) {
|
|
78
|
+
return join(quarantineEntry(sessionId), "meta.json");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Every on-disk artifact directory of one session (normally zero or one). */
|
|
82
|
+
export async function locateArtifacts(sessionId) {
|
|
83
|
+
const found = [];
|
|
84
|
+
let workspaces;
|
|
85
|
+
try {
|
|
86
|
+
workspaces = await readdir(sessionsRoot(), { withFileTypes: true });
|
|
87
|
+
} catch {
|
|
88
|
+
return found;
|
|
89
|
+
}
|
|
90
|
+
for (const workspace of workspaces) {
|
|
91
|
+
if (!workspace.isDirectory()) continue;
|
|
92
|
+
const candidate = join(sessionsRoot(), workspace.name, sessionId);
|
|
93
|
+
if (existsSync(candidate)) found.push(candidate);
|
|
94
|
+
}
|
|
95
|
+
return found;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Recursive byte size of one artifact directory; unreadable entries count as 0. */
|
|
99
|
+
export async function directorySize(path) {
|
|
100
|
+
let total = 0;
|
|
101
|
+
let entries;
|
|
102
|
+
try {
|
|
103
|
+
entries = await readdir(path, { withFileTypes: true });
|
|
104
|
+
} catch {
|
|
105
|
+
return 0;
|
|
106
|
+
}
|
|
107
|
+
for (const entry of entries) {
|
|
108
|
+
const child = join(path, entry.name);
|
|
109
|
+
try {
|
|
110
|
+
if (entry.isDirectory()) total += await directorySize(child);
|
|
111
|
+
else if (entry.isFile()) total += (await stat(child)).size;
|
|
112
|
+
} catch {
|
|
113
|
+
// A vanished or unreadable child contributes nothing to the estimate.
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return total;
|
|
117
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// Deleted-but-recoverable sessions.
|
|
2
|
+
//
|
|
3
|
+
// "Delete forever" is the only irreversible thing this plugin does, and the one
|
|
4
|
+
// mistake a user cannot undo. So a delete is a move, not an unlink: the
|
|
5
|
+
// artifact directory and the projection-cache document are parked under
|
|
6
|
+
// `$DSH_HOME/.archived-sessions-quarantine/<id>/` together with a `meta.json`
|
|
7
|
+
// describing where they came from. `forever` skips the park and unlinks.
|
|
8
|
+
//
|
|
9
|
+
// The quarantine lives under `$DSH_HOME`, beside `sessions/`, so the move is a
|
|
10
|
+
// same-volume `rename`; the copy fallback only exists for a `DSH_HOME` that
|
|
11
|
+
// spans volumes.
|
|
12
|
+
|
|
13
|
+
import { constants } from "node:fs";
|
|
14
|
+
import { access, cp, mkdir, readdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
15
|
+
import { dirname, join } from "node:path";
|
|
16
|
+
import {
|
|
17
|
+
directorySize,
|
|
18
|
+
projectionCacheFile,
|
|
19
|
+
quarantineArtifactDir,
|
|
20
|
+
quarantineCacheFile,
|
|
21
|
+
quarantineEntry,
|
|
22
|
+
quarantineMetaFile,
|
|
23
|
+
quarantineRoot,
|
|
24
|
+
sessionsRoot,
|
|
25
|
+
} from "./paths.js";
|
|
26
|
+
|
|
27
|
+
async function exists(path) {
|
|
28
|
+
try {
|
|
29
|
+
await access(path, constants.F_OK);
|
|
30
|
+
return true;
|
|
31
|
+
} catch {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Move one path, falling back to copy+unlink when the two ends are on different volumes. */
|
|
37
|
+
async function movePath(from, to) {
|
|
38
|
+
await mkdir(dirname(to), { recursive: true });
|
|
39
|
+
try {
|
|
40
|
+
await rename(from, to);
|
|
41
|
+
return "rename";
|
|
42
|
+
} catch (error) {
|
|
43
|
+
if (error?.code !== "EXDEV") throw error;
|
|
44
|
+
await cp(from, to, { recursive: true, force: true, preserveTimestamps: true });
|
|
45
|
+
await rm(from, { recursive: true, force: true });
|
|
46
|
+
return "copy";
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Park one session's artifacts + cache document; returns what was moved. */
|
|
51
|
+
export async function quarantineSession(sessionId, { dirs, cacheFile, meta }) {
|
|
52
|
+
const root = quarantineEntry(sessionId);
|
|
53
|
+
let bytes = 0;
|
|
54
|
+
for (const dir of dirs) bytes += await directorySize(dir);
|
|
55
|
+
// The projection document is state too: a delete that frees it should say so.
|
|
56
|
+
if (cacheFile !== undefined && (await exists(cacheFile))) {
|
|
57
|
+
try {
|
|
58
|
+
bytes += (await stat(cacheFile)).size;
|
|
59
|
+
} catch {
|
|
60
|
+
// A vanished document contributes nothing to the estimate.
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
await mkdir(root, { recursive: true });
|
|
64
|
+
|
|
65
|
+
const moved = { directories: 0, bytes, cache: false };
|
|
66
|
+
for (const dir of dirs) {
|
|
67
|
+
await movePath(dir, quarantineArtifactDir(sessionId));
|
|
68
|
+
moved.directories += 1;
|
|
69
|
+
}
|
|
70
|
+
if (cacheFile !== undefined && (await exists(cacheFile))) {
|
|
71
|
+
await movePath(cacheFile, quarantineCacheFile(sessionId));
|
|
72
|
+
moved.cache = true;
|
|
73
|
+
}
|
|
74
|
+
await writeFile(
|
|
75
|
+
quarantineMetaFile(sessionId),
|
|
76
|
+
JSON.stringify({ ...meta, sessionId, bytes, directories: moved.directories, cache: moved.cache }, null, 2),
|
|
77
|
+
"utf8",
|
|
78
|
+
);
|
|
79
|
+
return moved;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The quarantine as the panel sees it. Entries whose payload vanished (a user
|
|
84
|
+
* cleaned the directory by hand) are dropped rather than reported as broken.
|
|
85
|
+
*/
|
|
86
|
+
export async function listQuarantine() {
|
|
87
|
+
const entries = [];
|
|
88
|
+
let children;
|
|
89
|
+
try {
|
|
90
|
+
children = await readdir(quarantineRoot(), { withFileTypes: true });
|
|
91
|
+
} catch {
|
|
92
|
+
return entries;
|
|
93
|
+
}
|
|
94
|
+
for (const child of children) {
|
|
95
|
+
if (!child.isDirectory()) continue;
|
|
96
|
+
const sessionId = child.name;
|
|
97
|
+
let meta = null;
|
|
98
|
+
try {
|
|
99
|
+
meta = JSON.parse(await readFile(quarantineMetaFile(sessionId), "utf8"));
|
|
100
|
+
} catch {
|
|
101
|
+
meta = null;
|
|
102
|
+
}
|
|
103
|
+
const hasArtifact = await exists(quarantineArtifactDir(sessionId));
|
|
104
|
+
const hasCache = await exists(quarantineCacheFile(sessionId));
|
|
105
|
+
if (!hasArtifact && !hasCache) continue;
|
|
106
|
+
entries.push({
|
|
107
|
+
id: sessionId,
|
|
108
|
+
deletedAt: typeof meta?.deletedAt === "number" ? meta.deletedAt : null,
|
|
109
|
+
title: typeof meta?.title === "string" ? meta.title : null,
|
|
110
|
+
cwd: typeof meta?.cwd === "string" ? meta.cwd : null,
|
|
111
|
+
workspaceDir: typeof meta?.workspaceDir === "string" ? meta.workspaceDir : null,
|
|
112
|
+
bytes: typeof meta?.bytes === "number" ? meta.bytes : await directorySize(quarantineArtifactDir(sessionId)),
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
entries.sort((a, b) => (b.deletedAt ?? 0) - (a.deletedAt ?? 0));
|
|
116
|
+
return entries;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export async function quarantineEntryFor(sessionId) {
|
|
120
|
+
const entries = await listQuarantine();
|
|
121
|
+
return entries.find((entry) => entry.id === sessionId) ?? null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Put a quarantined session back where it came from.
|
|
126
|
+
*
|
|
127
|
+
* @returns the restored location and whether it could be re-archived. A failed
|
|
128
|
+
* re-archive is not a failed restore: the session is back on disk and visible
|
|
129
|
+
* in the ordinary session list, which is strictly better than losing it.
|
|
130
|
+
*/
|
|
131
|
+
export async function restoreSession(ctx, sessionId, entry) {
|
|
132
|
+
const root = quarantineEntry(sessionId);
|
|
133
|
+
const artifact = quarantineArtifactDir(sessionId);
|
|
134
|
+
const cache = quarantineCacheFile(sessionId);
|
|
135
|
+
const restored = { directories: 0, cache: false, rearchived: false };
|
|
136
|
+
|
|
137
|
+
const target = entry.workspaceDir !== null ? join(entry.workspaceDir, sessionId) : null;
|
|
138
|
+
if (await exists(artifact)) {
|
|
139
|
+
if (target === null) throw new Error("quarantine metadata has no workspaceDir");
|
|
140
|
+
await mkdir(dirname(target), { recursive: true });
|
|
141
|
+
await movePath(artifact, target);
|
|
142
|
+
restored.directories = 1;
|
|
143
|
+
}
|
|
144
|
+
if (await exists(cache)) {
|
|
145
|
+
await movePath(cache, projectionCacheFile(sessionId));
|
|
146
|
+
restored.cache = true;
|
|
147
|
+
}
|
|
148
|
+
await rm(root, { recursive: true, force: true });
|
|
149
|
+
|
|
150
|
+
try {
|
|
151
|
+
await ctx.workspaceRegistry?.archiveSession?.(sessionId, {});
|
|
152
|
+
restored.rearchived = true;
|
|
153
|
+
} catch (error) {
|
|
154
|
+
ctx.logger?.warn?.(`[dsh-archived] restore ${sessionId}: re-archive failed:`, error);
|
|
155
|
+
}
|
|
156
|
+
return restored;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Unlink one quarantined session for good. */
|
|
160
|
+
export async function purgeQuarantineEntry(sessionId) {
|
|
161
|
+
const bytes = await directorySize(quarantineEntry(sessionId));
|
|
162
|
+
await rm(quarantineEntry(sessionId), { recursive: true, force: true });
|
|
163
|
+
return bytes;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Drop quarantine entries older than `maxAgeMs`; returns the ids dropped. */
|
|
167
|
+
export async function expireQuarantine(maxAgeMs, now = Date.now()) {
|
|
168
|
+
const dropped = [];
|
|
169
|
+
for (const entry of await listQuarantine()) {
|
|
170
|
+
if (entry.deletedAt === null) continue;
|
|
171
|
+
if (now - entry.deletedAt < maxAgeMs) continue;
|
|
172
|
+
await purgeQuarantineEntry(entry.id);
|
|
173
|
+
dropped.push(entry.id);
|
|
174
|
+
}
|
|
175
|
+
return dropped;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Workspace bucket directory names currently on disk, for orphan reporting. */
|
|
179
|
+
export async function sessionBuckets() {
|
|
180
|
+
try {
|
|
181
|
+
return (await readdir(sessionsRoot(), { withFileTypes: true })).filter((e) => e.isDirectory()).map((e) => e.name);
|
|
182
|
+
} catch {
|
|
183
|
+
return [];
|
|
184
|
+
}
|
|
185
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// Request trust for the destructive routes.
|
|
2
|
+
//
|
|
3
|
+
// Loopback, marker-header, same-origin. The routes are destructive, so they
|
|
4
|
+
// refuse anything that is not this browser talking to this server.
|
|
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.
|
|
9
|
+
|
|
10
|
+
export const HEADER = "x-dsh-archived";
|
|
11
|
+
|
|
12
|
+
export function header(request, key) {
|
|
13
|
+
const value = request.headers?.[key];
|
|
14
|
+
return Array.isArray(value) ? value[0] : value;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function isLoopbackAddress(value) {
|
|
18
|
+
const address = String(value || "").toLowerCase().replace(/^\[|\]$/g, "");
|
|
19
|
+
return (
|
|
20
|
+
address === "localhost" ||
|
|
21
|
+
address === "localhost." ||
|
|
22
|
+
address === "::1" ||
|
|
23
|
+
address.startsWith("127.") ||
|
|
24
|
+
address.startsWith("::ffff:127.")
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
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";
|
|
37
|
+
try {
|
|
38
|
+
const url = new URL(origin);
|
|
39
|
+
return (
|
|
40
|
+
(url.protocol === "http:" || url.protocol === "https:") &&
|
|
41
|
+
isLoopbackAddress(url.hostname) &&
|
|
42
|
+
url.host === host
|
|
43
|
+
);
|
|
44
|
+
} catch {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function json(response, statusCode, value) {
|
|
50
|
+
response.writeHead(statusCode, {
|
|
51
|
+
"content-type": "application/json; charset=utf-8",
|
|
52
|
+
"cache-control": "no-store",
|
|
53
|
+
});
|
|
54
|
+
response.end(JSON.stringify(value));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Short operator-facing cause for a 500 body; these routes are loopback-only. */
|
|
58
|
+
export function failureDetail(error) {
|
|
59
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
60
|
+
return message.slice(0, 200);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function readJsonBody(request) {
|
|
64
|
+
const chunks = [];
|
|
65
|
+
for await (const chunk of request) chunks.push(chunk);
|
|
66
|
+
if (chunks.length === 0) return {};
|
|
67
|
+
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
68
|
+
}
|