@lelouchhe/webagent 0.8.0 → 0.9.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/README.md +4 -1
- package/dist/index.html +20 -4
- package/dist/js/app.QC7IRDTP.js +5 -0
- package/dist/js/chunk.3CLGCUHW.js +1 -0
- package/dist/js/chunk.AOTG3PL7.js +20 -0
- package/dist/js/{chunk.CT5WBNGZ.js → chunk.S5LRNRJI.js} +50 -49
- package/dist/js/{login.2WA6DTGM.js → login.WMURU4NI.js} +1 -1
- package/dist/js/{viewer.CVWXSKJM.js → viewer.GP5VXAUY.js} +1 -1
- package/dist/login.html +2 -2
- package/dist/share-viewer.html +4 -4
- package/dist/{styles.00etlpgs.css → styles.00nlhhf3.css} +167 -2
- package/lib/agent-key.js +6 -0
- package/lib/attachments.js +44 -7
- package/lib/auth-middleware.js +9 -2
- package/lib/bridge.js +115 -22
- package/lib/event-handler.js +31 -3
- package/lib/files/limits.js +15 -0
- package/lib/files/paths.js +155 -0
- package/lib/files/routes.js +232 -0
- package/lib/home-path.js +35 -0
- package/lib/http-status.js +1 -0
- package/lib/routes.js +117 -28
- package/lib/server.js +22 -20
- package/lib/session-manager.js +54 -29
- package/lib/session-state.js +29 -0
- package/lib/share/routes.js +12 -0
- package/lib/store.js +99 -18
- package/lib/title-service.js +3 -3
- package/package.json +2 -1
- package/dist/js/app.XBFXH37R.js +0 -2
- package/dist/js/chunk.UMQMOGWO.js +0 -1
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File viewer size limits.
|
|
3
|
+
*
|
|
4
|
+
* Kept as plain exported constants for now so the routes have a single,
|
|
5
|
+
* testable knob; wiring these into `[limits]` config is a later milestone.
|
|
6
|
+
* Values stay in the same order of magnitude as attachment limits and keep
|
|
7
|
+
* rendered previews bounded. Files outside preview limits stream as downloads
|
|
8
|
+
* instead of being buffered in server or browser memory.
|
|
9
|
+
*/
|
|
10
|
+
/** Directory listings cap — beyond this the response is truncated + flagged. */
|
|
11
|
+
export const MAX_LIST_ITEMS = 2000;
|
|
12
|
+
/** Text/Markdown/code preview + highlight cap; larger files download. */
|
|
13
|
+
export const MAX_TEXT_PREVIEW_BYTES = 1024 * 1024; // 1 MiB
|
|
14
|
+
/** Image render cap — larger images stream as downloads. */
|
|
15
|
+
export const MAX_IMAGE_BYTES = 20 * 1024 * 1024; // 20 MB
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Path resolution and directory listing helpers for the file viewer.
|
|
3
|
+
*
|
|
4
|
+
* Contract (confirmed design): the viewer accepts *absolute* paths or `~`
|
|
5
|
+
* prefixes only — relative paths are rejected outright so there is no base
|
|
6
|
+
* ambiguity to exploit. `~` expands to HOME, then `realpath` canonicalizes
|
|
7
|
+
* symlinks and `..` segments once; every path on the system is legal by
|
|
8
|
+
* design (single-owner personal tool), so there is deliberately no escape
|
|
9
|
+
* rejection logic — the guards here prevent blocking on special files,
|
|
10
|
+
* unbounded directory scans, and whole-file buffering.
|
|
11
|
+
*/
|
|
12
|
+
import { constants } from "node:fs";
|
|
13
|
+
import { open, opendir, realpath, stat, } from "node:fs/promises";
|
|
14
|
+
import { homedir } from "node:os";
|
|
15
|
+
import { isAbsolute, join, basename } from "node:path";
|
|
16
|
+
import { expandHomePath } from "../home-path.js";
|
|
17
|
+
import { MAX_LIST_ITEMS } from "./limits.js";
|
|
18
|
+
/** HTTP status-backed error; routes map it to a JSON error response. */
|
|
19
|
+
export class FilePathError extends Error {
|
|
20
|
+
status;
|
|
21
|
+
constructor(status, message) {
|
|
22
|
+
super(message);
|
|
23
|
+
this.status = status;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Expand a user-supplied path string into an absolute path.
|
|
28
|
+
* Accepts `/absolute/path` and `~/...` / `~`. Everything else (including
|
|
29
|
+
* `~user` and bare relative paths) is rejected with 400.
|
|
30
|
+
*/
|
|
31
|
+
export function expandPath(raw, home = homedir()) {
|
|
32
|
+
if (raw.length === 0)
|
|
33
|
+
throw new FilePathError(400, "Missing path");
|
|
34
|
+
if (raw.includes("\0"))
|
|
35
|
+
throw new FilePathError(400, "Invalid path");
|
|
36
|
+
const expanded = expandHomePath(raw, home);
|
|
37
|
+
if (raw.startsWith("~") && expanded === raw) {
|
|
38
|
+
throw new FilePathError(400, "Unsupported ~user expansion");
|
|
39
|
+
}
|
|
40
|
+
if (!isAbsolute(expanded)) {
|
|
41
|
+
throw new FilePathError(400, "Path must be absolute or start with ~");
|
|
42
|
+
}
|
|
43
|
+
return expanded;
|
|
44
|
+
}
|
|
45
|
+
/** realpath canonicalization; missing paths map to 404. */
|
|
46
|
+
export async function canonicalize(target) {
|
|
47
|
+
try {
|
|
48
|
+
return await realpath(target);
|
|
49
|
+
}
|
|
50
|
+
catch (err) {
|
|
51
|
+
if (err.code === "ENOENT") {
|
|
52
|
+
throw new FilePathError(404, "Path does not exist");
|
|
53
|
+
}
|
|
54
|
+
throw err;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/** stat a canonical path; non-regular files (fifo/socket/device) → 400. */
|
|
58
|
+
export async function statMeta(path) {
|
|
59
|
+
const s = await stat(path);
|
|
60
|
+
let kind;
|
|
61
|
+
if (s.isFile())
|
|
62
|
+
kind = "file";
|
|
63
|
+
else if (s.isDirectory())
|
|
64
|
+
kind = "dir";
|
|
65
|
+
else
|
|
66
|
+
throw new FilePathError(400, "Not a regular file or directory");
|
|
67
|
+
return {
|
|
68
|
+
path,
|
|
69
|
+
name: basename(path),
|
|
70
|
+
kind,
|
|
71
|
+
size: s.size,
|
|
72
|
+
mtime: s.mtimeMs,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* List one directory: scan at most MAX_LIST_ITEMS plus one sentinel raw entry,
|
|
77
|
+
* omit dotfiles/special nodes, then sort the bounded result dirs-first and
|
|
78
|
+
* lexicographically. Entries that vanish mid-list are skipped.
|
|
79
|
+
*/
|
|
80
|
+
export async function listDirectory(target) {
|
|
81
|
+
const dir = await opendir(target);
|
|
82
|
+
const entries = [];
|
|
83
|
+
let scanned = 0;
|
|
84
|
+
let truncated = false;
|
|
85
|
+
// Dir's async iterator closes the descriptor both at EOF and on break.
|
|
86
|
+
for await (const entry of dir) {
|
|
87
|
+
scanned++;
|
|
88
|
+
// Read one sentinel beyond the cap so `truncated` is authoritative,
|
|
89
|
+
// but never materialize/sort an unbounded directory.
|
|
90
|
+
if (scanned > MAX_LIST_ITEMS) {
|
|
91
|
+
truncated = true;
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
if (entry.name.startsWith("."))
|
|
95
|
+
continue;
|
|
96
|
+
try {
|
|
97
|
+
const s = await stat(join(target, entry.name));
|
|
98
|
+
// Only expose targets the viewer can actually open. Symlinks to
|
|
99
|
+
// regular files/dirs pass because stat follows them; fifos, sockets,
|
|
100
|
+
// and devices are omitted rather than mislabelled as files.
|
|
101
|
+
if (!s.isDirectory() && !s.isFile())
|
|
102
|
+
continue;
|
|
103
|
+
entries.push({
|
|
104
|
+
name: entry.name,
|
|
105
|
+
kind: s.isDirectory() ? "dir" : "file",
|
|
106
|
+
size: s.isFile() ? s.size : null,
|
|
107
|
+
mtime: s.mtimeMs,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
// Raced deletion — skip rather than fail the whole listing.
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
entries.sort(compareEntries);
|
|
115
|
+
return { entries, truncated };
|
|
116
|
+
}
|
|
117
|
+
function compareEntries(a, b) {
|
|
118
|
+
if (a.kind !== b.kind)
|
|
119
|
+
return a.kind === "dir" ? -1 : 1;
|
|
120
|
+
return a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
|
|
121
|
+
}
|
|
122
|
+
/** Read up to `n` bytes from the head of a regular file (mime sniffing). */
|
|
123
|
+
export async function readHead(file, n = 4096) {
|
|
124
|
+
const h = await open(file, "r");
|
|
125
|
+
try {
|
|
126
|
+
const st = await h.stat();
|
|
127
|
+
const len = Math.min(st.size, n);
|
|
128
|
+
const buf = Buffer.alloc(len);
|
|
129
|
+
if (len > 0)
|
|
130
|
+
await h.read(buf, 0, len, 0);
|
|
131
|
+
return buf;
|
|
132
|
+
}
|
|
133
|
+
finally {
|
|
134
|
+
await h.close();
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Open without following a final symlink and without blocking on a FIFO.
|
|
139
|
+
* Windows lacks equivalent POSIX flags; fstat below remains authoritative.
|
|
140
|
+
*/
|
|
141
|
+
export function openForStreaming(file) {
|
|
142
|
+
const safeFlags = process.platform === "win32"
|
|
143
|
+
? constants.O_RDONLY
|
|
144
|
+
: constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK;
|
|
145
|
+
return open(file, safeFlags);
|
|
146
|
+
}
|
|
147
|
+
/** Read mime-sniff bytes from an already validated descriptor. */
|
|
148
|
+
export async function readHandleHead(handle, size, n = 4096) {
|
|
149
|
+
const len = Math.min(size, n);
|
|
150
|
+
const buf = Buffer.alloc(len);
|
|
151
|
+
if (len === 0)
|
|
152
|
+
return buf;
|
|
153
|
+
const { bytesRead } = await handle.read(buf, 0, len, 0);
|
|
154
|
+
return buf.subarray(0, bytesRead);
|
|
155
|
+
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File viewer HTTP routes — read-only access to arbitrary local files.
|
|
3
|
+
*
|
|
4
|
+
* URL space claimed: `/api/v1/files/{info,list,content}`.
|
|
5
|
+
* Sessionless by design (confirmed): the caller passes an absolute or
|
|
6
|
+
* `~`-prefixed path; the server own `~` expansion + realpath canonicalization.
|
|
7
|
+
* Relative paths are rejected. Bearer auth is enforced by the shared
|
|
8
|
+
* `/api/**` gate in routes.ts — these paths are deliberately NOT in the
|
|
9
|
+
* public whitelist, with one exception: `content` IS whitelisted so
|
|
10
|
+
* `<img>` / `<a download>` can fetch without an Authorization header, and
|
|
11
|
+
* it instead requires an HMAC sig+exp signed URL (issued by `info`),
|
|
12
|
+
* mirroring the attachment scheme.
|
|
13
|
+
*
|
|
14
|
+
* Guards (see paths.ts + limits.ts): only regular files/dirs are served
|
|
15
|
+
* (no fifos/sockets/devices), directory scans and inline previews are bounded,
|
|
16
|
+
* downloads stream with backpressure, and responses carry nosniff + a
|
|
17
|
+
* restrictive CSP. The viewer renders text/markdown/image only — nothing here
|
|
18
|
+
* executes content.
|
|
19
|
+
*/
|
|
20
|
+
import { basename, dirname } from "node:path";
|
|
21
|
+
import { pipeline } from "node:stream/promises";
|
|
22
|
+
import { signAttachmentUrl, verifyAttachmentSig } from "../auth.js";
|
|
23
|
+
import { buildContentDisposition, sniffMime } from "../attachments.js";
|
|
24
|
+
import { HTTP_STATUS } from "../http-status.js";
|
|
25
|
+
import { abbreviateHomePath } from "../home-path.js";
|
|
26
|
+
import { log } from "../log.js";
|
|
27
|
+
import { MAX_IMAGE_BYTES, MAX_TEXT_PREVIEW_BYTES } from "./limits.js";
|
|
28
|
+
import { canonicalize, expandPath, FilePathError, listDirectory, openForStreaming, readHandleHead, readHead, statMeta, } from "./paths.js";
|
|
29
|
+
const flog = log.scope("files");
|
|
30
|
+
const CONTENT_TTL_SECONDS = 3600; // signed URLs live 1h, like attachments
|
|
31
|
+
const SNIFF_BYTES = 4096;
|
|
32
|
+
function fileSystemError(err) {
|
|
33
|
+
const code = err?.code;
|
|
34
|
+
if (code === "EACCES" || code === "EPERM") {
|
|
35
|
+
return new FilePathError(HTTP_STATUS.FORBIDDEN, "Permission denied");
|
|
36
|
+
}
|
|
37
|
+
if (code === "ENOENT" || code === "ENOTDIR") {
|
|
38
|
+
return new FilePathError(HTTP_STATUS.NOT_FOUND, "Path does not exist");
|
|
39
|
+
}
|
|
40
|
+
if (code === "ELOOP" ||
|
|
41
|
+
code === "EINVAL" ||
|
|
42
|
+
code === "ENAMETOOLONG" ||
|
|
43
|
+
code === "ERR_INVALID_ARG_VALUE") {
|
|
44
|
+
return new FilePathError(HTTP_STATUS.BAD_REQUEST, "Invalid path");
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
function json(res, status, body) {
|
|
49
|
+
res.writeHead(status, {
|
|
50
|
+
"Content-Type": "application/json",
|
|
51
|
+
"Cache-Control": "no-store",
|
|
52
|
+
});
|
|
53
|
+
res.end(JSON.stringify(body));
|
|
54
|
+
}
|
|
55
|
+
function previewLimitFor(mime) {
|
|
56
|
+
const m = mime.toLowerCase();
|
|
57
|
+
if (m.startsWith("image/"))
|
|
58
|
+
return MAX_IMAGE_BYTES;
|
|
59
|
+
if (m.startsWith("text/"))
|
|
60
|
+
return MAX_TEXT_PREVIEW_BYTES;
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
/** Expand + canonicalize a caller-supplied path; 400 on empty/relative. */
|
|
64
|
+
async function resolvePath(raw) {
|
|
65
|
+
if (!raw)
|
|
66
|
+
throw new FilePathError(400, "Missing path");
|
|
67
|
+
return canonicalize(expandPath(raw));
|
|
68
|
+
}
|
|
69
|
+
function contentBasePath(pathRaw) {
|
|
70
|
+
return `/api/v1/files/content?path=${encodeURIComponent(pathRaw)}`;
|
|
71
|
+
}
|
|
72
|
+
async function streamHandle(res, handle, size) {
|
|
73
|
+
const stream = handle.createReadStream({
|
|
74
|
+
autoClose: false,
|
|
75
|
+
start: 0,
|
|
76
|
+
...(size > 0 ? { end: size - 1 } : {}),
|
|
77
|
+
});
|
|
78
|
+
try {
|
|
79
|
+
await pipeline(stream, res);
|
|
80
|
+
}
|
|
81
|
+
catch (err) {
|
|
82
|
+
// Headers are already committed. A client disconnect or disk read error
|
|
83
|
+
// must terminate the stream, never fall through to a second JSON response.
|
|
84
|
+
if (!res.destroyed) {
|
|
85
|
+
res.destroy(err instanceof Error ? err : undefined);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
export async function handleFileRoutes(req, res, deps) {
|
|
90
|
+
const url = req.url ?? "/";
|
|
91
|
+
if (!url.startsWith("/api/v1/files"))
|
|
92
|
+
return false;
|
|
93
|
+
const m = url.match(/^\/api\/v1\/files\/(info|list|content)(?:\?(.*))?$/);
|
|
94
|
+
if (!m)
|
|
95
|
+
return false;
|
|
96
|
+
const method = req.method ?? "GET";
|
|
97
|
+
if (method !== "GET") {
|
|
98
|
+
res.setHeader("Allow", "GET");
|
|
99
|
+
json(res, HTTP_STATUS.METHOD_NOT_ALLOWED, {
|
|
100
|
+
error: "Read-only: GET only",
|
|
101
|
+
});
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
try {
|
|
105
|
+
const params = new URLSearchParams(m[2]);
|
|
106
|
+
const pathRaw = params.get("path") ?? "";
|
|
107
|
+
switch (m[1]) {
|
|
108
|
+
case "info":
|
|
109
|
+
await handleInfo(res, deps, pathRaw);
|
|
110
|
+
return true;
|
|
111
|
+
case "list":
|
|
112
|
+
await handleList(res, pathRaw);
|
|
113
|
+
return true;
|
|
114
|
+
case "content":
|
|
115
|
+
await handleContent(res, deps, pathRaw, params);
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
catch (err) {
|
|
120
|
+
if (err instanceof FilePathError) {
|
|
121
|
+
json(res, err.status, { error: err.message });
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
const fsError = fileSystemError(err);
|
|
125
|
+
if (fsError) {
|
|
126
|
+
json(res, fsError.status, { error: fsError.message });
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
flog.error("file route failed", { url, error: String(err) });
|
|
130
|
+
json(res, HTTP_STATUS.INTERNAL_SERVER_ERROR, { error: "internal_error" });
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
async function handleInfo(res, deps, pathRaw) {
|
|
136
|
+
const canonical = await resolvePath(pathRaw);
|
|
137
|
+
const meta = await statMeta(canonical);
|
|
138
|
+
const out = {
|
|
139
|
+
path: meta.path,
|
|
140
|
+
pathDisplay: abbreviateHomePath(meta.path),
|
|
141
|
+
name: meta.name,
|
|
142
|
+
kind: meta.kind,
|
|
143
|
+
size: meta.size,
|
|
144
|
+
mtime: meta.mtime,
|
|
145
|
+
};
|
|
146
|
+
if (meta.kind === "file") {
|
|
147
|
+
const mime = await sniffMime(await readHead(canonical, SNIFF_BYTES));
|
|
148
|
+
const previewLimit = previewLimitFor(mime);
|
|
149
|
+
out.mime = mime;
|
|
150
|
+
if (previewLimit !== null)
|
|
151
|
+
out.maxBytes = previewLimit;
|
|
152
|
+
if (deps.secret) {
|
|
153
|
+
const basePath = contentBasePath(canonical);
|
|
154
|
+
out.contentUrl = `${basePath}&${signAttachmentUrl(basePath, deps.secret, CONTENT_TTL_SECONDS)}`;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
json(res, HTTP_STATUS.OK, out);
|
|
158
|
+
}
|
|
159
|
+
async function handleList(res, pathRaw) {
|
|
160
|
+
const canonical = await resolvePath(pathRaw);
|
|
161
|
+
const meta = await statMeta(canonical);
|
|
162
|
+
if (meta.kind !== "dir") {
|
|
163
|
+
throw new FilePathError(HTTP_STATUS.BAD_REQUEST, "Not a directory");
|
|
164
|
+
}
|
|
165
|
+
const { entries, truncated } = await listDirectory(canonical);
|
|
166
|
+
const parent = dirname(canonical);
|
|
167
|
+
json(res, HTTP_STATUS.OK, {
|
|
168
|
+
path: canonical,
|
|
169
|
+
pathDisplay: abbreviateHomePath(canonical),
|
|
170
|
+
parent,
|
|
171
|
+
parentDisplay: abbreviateHomePath(parent),
|
|
172
|
+
truncated,
|
|
173
|
+
entries,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
async function handleContent(res, deps, pathRaw, params) {
|
|
177
|
+
const basePath = contentBasePath(pathRaw);
|
|
178
|
+
const sig = params.get("sig") ?? "";
|
|
179
|
+
const exp = params.get("exp") ?? "";
|
|
180
|
+
// content is whitelisted in auth-middleware.ts (media tags / downloads
|
|
181
|
+
// cannot send Authorization headers), so it must verify its own URL.
|
|
182
|
+
// Unlike the older attachment route, this new security-sensitive route
|
|
183
|
+
// fails closed when no signing secret is wired.
|
|
184
|
+
if (!deps.secret) {
|
|
185
|
+
json(res, HTTP_STATUS.SERVICE_UNAVAILABLE, {
|
|
186
|
+
error: "file_content_signing_unavailable",
|
|
187
|
+
});
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
if (!sig || !exp || !verifyAttachmentSig(basePath, exp, sig, deps.secret)) {
|
|
191
|
+
res.writeHead(HTTP_STATUS.UNAUTHORIZED, {
|
|
192
|
+
"Content-Type": "application/json",
|
|
193
|
+
});
|
|
194
|
+
res.end(JSON.stringify({ error: "Unauthorized" }));
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
const canonical = await resolvePath(pathRaw);
|
|
198
|
+
// `info` signs the realpath-canonical string. If that path now resolves to
|
|
199
|
+
// another target (for example it was replaced by a symlink), the old
|
|
200
|
+
// capability must not silently acquire authority over the new target.
|
|
201
|
+
if (canonical !== pathRaw) {
|
|
202
|
+
json(res, HTTP_STATUS.UNAUTHORIZED, { error: "Unauthorized" });
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
const handle = await openForStreaming(canonical);
|
|
206
|
+
try {
|
|
207
|
+
const stats = await handle.stat();
|
|
208
|
+
if (!stats.isFile()) {
|
|
209
|
+
throw new FilePathError(HTTP_STATUS.BAD_REQUEST, "Not a regular file");
|
|
210
|
+
}
|
|
211
|
+
const mime = await sniffMime(await readHandleHead(handle, stats.size, SNIFF_BYTES));
|
|
212
|
+
const previewLimit = previewLimitFor(mime);
|
|
213
|
+
const disposition = previewLimit !== null && stats.size <= previewLimit
|
|
214
|
+
? "inline"
|
|
215
|
+
: "attachment";
|
|
216
|
+
res.writeHead(HTTP_STATUS.OK, {
|
|
217
|
+
"Content-Type": mime,
|
|
218
|
+
"X-Content-Type-Options": "nosniff",
|
|
219
|
+
// Ordinary project files change in place; never let reopening show a
|
|
220
|
+
// cached pre-edit body under the same path-bound signed URL.
|
|
221
|
+
"Cache-Control": "no-store",
|
|
222
|
+
// Belt-and-braces: even if a mime mis-sniff ever lets a browser
|
|
223
|
+
// interpret this body as HTML, it can't load any subresource.
|
|
224
|
+
"Content-Security-Policy": "default-src 'none'",
|
|
225
|
+
"Content-Disposition": buildContentDisposition(disposition, basename(canonical)),
|
|
226
|
+
});
|
|
227
|
+
await streamHandle(res, handle, stats.size);
|
|
228
|
+
}
|
|
229
|
+
finally {
|
|
230
|
+
await handle.close().catch(() => { });
|
|
231
|
+
}
|
|
232
|
+
}
|
package/lib/home-path.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { isAbsolute, join, relative, sep } from "node:path";
|
|
3
|
+
const NATIVE_PATH = { isAbsolute, join, relative, sep };
|
|
4
|
+
function portableDisplayPath(input, path) {
|
|
5
|
+
return path.sep === "\\" ? input.replace(/\\/g, "/") : input;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Expand the current user's HOME shorthand with one authoritative grammar.
|
|
9
|
+
* Both `~/` and `~\` are accepted; named-user forms stay untouched.
|
|
10
|
+
*/
|
|
11
|
+
export function expandHomePath(input, home = homedir(), path = NATIVE_PATH) {
|
|
12
|
+
if (input === "~")
|
|
13
|
+
return home;
|
|
14
|
+
if (input.startsWith("~/") || input.startsWith("~\\")) {
|
|
15
|
+
const tail = input.slice(2).replace(/[\\/]/g, path.sep);
|
|
16
|
+
return path.join(home, tail);
|
|
17
|
+
}
|
|
18
|
+
return input;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Abbreviate HOME and normalize Windows display paths to portable `/`
|
|
22
|
+
* separators. Canonical filesystem paths remain native and are stored/signed
|
|
23
|
+
* separately; this function is exclusively for UI round-tripping.
|
|
24
|
+
*/
|
|
25
|
+
export function abbreviateHomePath(input, home = homedir(), path = NATIVE_PATH) {
|
|
26
|
+
const relativePath = path.relative(home, input);
|
|
27
|
+
if (relativePath === "")
|
|
28
|
+
return "~";
|
|
29
|
+
if (relativePath === ".." ||
|
|
30
|
+
relativePath.startsWith(`..${path.sep}`) ||
|
|
31
|
+
path.isAbsolute(relativePath)) {
|
|
32
|
+
return portableDisplayPath(input, path);
|
|
33
|
+
}
|
|
34
|
+
return `~/${portableDisplayPath(relativePath, path)}`;
|
|
35
|
+
}
|
package/lib/http-status.js
CHANGED
package/lib/routes.js
CHANGED
|
@@ -8,9 +8,11 @@ import { interruptBashProc, InvalidSessionDirectoryError, } from "./session-mana
|
|
|
8
8
|
import { randomUUID } from "node:crypto";
|
|
9
9
|
import { createWriteStream } from "node:fs";
|
|
10
10
|
import { handleShareRoutes } from "./share/routes.js";
|
|
11
|
+
import { handleFileRoutes } from "./files/routes.js";
|
|
11
12
|
import { authenticate, isWhitelistedPath } from "./auth-middleware.js";
|
|
12
13
|
import { enrichStoredEventsForDisplay } from "./attachment-labels.js";
|
|
13
14
|
import { agentCommandToken, resolveAgentCommand } from "./agent-commands.js";
|
|
15
|
+
import { abbreviateHomePath } from "./home-path.js";
|
|
14
16
|
import { log } from "./log.js";
|
|
15
17
|
const rlog = log.scope("routes");
|
|
16
18
|
const plog = rlog.scope("prompt");
|
|
@@ -403,6 +405,7 @@ async function handleAttachmentUpload(req, res, sessionId, deps) {
|
|
|
403
405
|
}
|
|
404
406
|
export function createRequestHandler(deps) {
|
|
405
407
|
const { store, sessions, getBridge, sseManager, titleService } = deps;
|
|
408
|
+
let bootstrapSessionPromise = null;
|
|
406
409
|
// eslint-disable-next-line complexity -- TODO: refactor main route handler into smaller handlers
|
|
407
410
|
return async (req, res) => {
|
|
408
411
|
const url = req.url ?? "/";
|
|
@@ -440,6 +443,14 @@ export function createRequestHandler(deps) {
|
|
|
440
443
|
}))) {
|
|
441
444
|
return;
|
|
442
445
|
}
|
|
446
|
+
// File viewer — sessionless read-only access to arbitrary local paths.
|
|
447
|
+
// Claims /api/v1/files/{info,list,content} before the generic /api/v1
|
|
448
|
+
// branch. info/list use the Bearer gate above; content is whitelisted
|
|
449
|
+
// only because its handler requires an HMAC-signed URL for headerless
|
|
450
|
+
// media/download fetches (see src/files/routes.ts).
|
|
451
|
+
if (await handleFileRoutes(req, res, { secret: deps.attachmentSecret })) {
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
443
454
|
// --- API routes ---
|
|
444
455
|
if (url === "/api/v1" || url.startsWith("/api/v1/")) {
|
|
445
456
|
res.setHeader("Content-Type", "application/json");
|
|
@@ -450,6 +461,7 @@ export function createRequestHandler(deps) {
|
|
|
450
461
|
endpoints: {
|
|
451
462
|
sessions: "/api/v1/sessions",
|
|
452
463
|
paths: "/api/v1/recent-paths",
|
|
464
|
+
files: "/api/v1/files",
|
|
453
465
|
config: "/api/v1/config",
|
|
454
466
|
events_stream: "/api/v1/events/stream",
|
|
455
467
|
prompt: "/api/beta/prompt",
|
|
@@ -487,7 +499,10 @@ export function createRequestHandler(deps) {
|
|
|
487
499
|
limit: isNaN(limit) ? 0 : limit,
|
|
488
500
|
ttlDays,
|
|
489
501
|
});
|
|
490
|
-
json(res, HTTP_STATUS.OK, paths)
|
|
502
|
+
json(res, HTTP_STATUS.OK, paths.map((entry) => ({
|
|
503
|
+
...entry,
|
|
504
|
+
cwdDisplay: abbreviateHomePath(entry.cwd),
|
|
505
|
+
})));
|
|
491
506
|
return;
|
|
492
507
|
}
|
|
493
508
|
// GET /api/v1/version
|
|
@@ -888,6 +903,7 @@ export function createRequestHandler(deps) {
|
|
|
888
903
|
id: session.id,
|
|
889
904
|
title: session.title,
|
|
890
905
|
cwd: session.cwd,
|
|
906
|
+
cwdDisplay: abbreviateHomePath(session.cwd),
|
|
891
907
|
model: session.model,
|
|
892
908
|
mode: session.mode,
|
|
893
909
|
createdAt: session.created_at,
|
|
@@ -1149,6 +1165,11 @@ export function createRequestHandler(deps) {
|
|
|
1149
1165
|
},
|
|
1150
1166
|
];
|
|
1151
1167
|
});
|
|
1168
|
+
// A background task can trigger unsolicited Main-agent output after
|
|
1169
|
+
// the foreground ACP prompt has ended. Its chunks remain buffered
|
|
1170
|
+
// until a real protocol boundary arrives; seal them before this user
|
|
1171
|
+
// row so they cannot merge into the next turn's assistant response.
|
|
1172
|
+
sessions.flushBuffers(sessionId);
|
|
1152
1173
|
const eventClientOpId = opId ?? randomUUID();
|
|
1153
1174
|
store.saveEvent(sessionId, "user_message", {
|
|
1154
1175
|
text: body.text,
|
|
@@ -1429,6 +1450,76 @@ export function createRequestHandler(deps) {
|
|
|
1429
1450
|
json(res, HTTP_STATUS.OK, { title: body.value });
|
|
1430
1451
|
return;
|
|
1431
1452
|
}
|
|
1453
|
+
// POST /api/v1/sessions/bootstrap — atomically return the current
|
|
1454
|
+
// agent's latest session, creating one only when none exists.
|
|
1455
|
+
if (url === "/api/v1/sessions/bootstrap" && req.method === "POST") {
|
|
1456
|
+
const bridge = getBridge?.();
|
|
1457
|
+
if (!bridge) {
|
|
1458
|
+
json(res, HTTP_STATUS.SERVICE_UNAVAILABLE, {
|
|
1459
|
+
error: "Agent not ready yet",
|
|
1460
|
+
});
|
|
1461
|
+
return;
|
|
1462
|
+
}
|
|
1463
|
+
if (!sessions) {
|
|
1464
|
+
json(res, HTTP_STATUS.SERVICE_UNAVAILABLE, {
|
|
1465
|
+
error: "Session manager not available",
|
|
1466
|
+
});
|
|
1467
|
+
return;
|
|
1468
|
+
}
|
|
1469
|
+
const sessionManager = sessions;
|
|
1470
|
+
bootstrapSessionPromise ??= (async () => {
|
|
1471
|
+
const existing = store.listSessions().at(0);
|
|
1472
|
+
if (existing) {
|
|
1473
|
+
return {
|
|
1474
|
+
id: existing.id,
|
|
1475
|
+
cwd: existing.cwd,
|
|
1476
|
+
cwdDisplay: abbreviateHomePath(existing.cwd),
|
|
1477
|
+
title: existing.title,
|
|
1478
|
+
source: existing.source,
|
|
1479
|
+
configOptions: [],
|
|
1480
|
+
agentCommands: sessionManager.getAgentCommands(existing.id),
|
|
1481
|
+
created: false,
|
|
1482
|
+
};
|
|
1483
|
+
}
|
|
1484
|
+
const { sessionId, configOptions } = await sessionManager.createSession(bridge);
|
|
1485
|
+
const session = store.getSession(sessionId);
|
|
1486
|
+
const result = {
|
|
1487
|
+
id: sessionId,
|
|
1488
|
+
cwd: session?.cwd ?? deps.dataDir,
|
|
1489
|
+
cwdDisplay: abbreviateHomePath(session?.cwd ?? deps.dataDir),
|
|
1490
|
+
title: session?.title ?? null,
|
|
1491
|
+
source: session?.source ?? "auto",
|
|
1492
|
+
configOptions,
|
|
1493
|
+
agentCommands: sessionManager.getAgentCommands(sessionId),
|
|
1494
|
+
created: true,
|
|
1495
|
+
};
|
|
1496
|
+
sseManager.broadcast({
|
|
1497
|
+
type: "session_created",
|
|
1498
|
+
sessionId,
|
|
1499
|
+
cwd: result.cwd,
|
|
1500
|
+
cwdDisplay: result.cwdDisplay,
|
|
1501
|
+
title: result.title,
|
|
1502
|
+
configOptions,
|
|
1503
|
+
agentCommands: result.agentCommands,
|
|
1504
|
+
});
|
|
1505
|
+
return result;
|
|
1506
|
+
})().finally(() => {
|
|
1507
|
+
bootstrapSessionPromise = null;
|
|
1508
|
+
});
|
|
1509
|
+
try {
|
|
1510
|
+
const result = await bootstrapSessionPromise;
|
|
1511
|
+
json(res, HTTP_STATUS.OK, {
|
|
1512
|
+
...result,
|
|
1513
|
+
clientOpId: getClientOpId(req) ?? undefined,
|
|
1514
|
+
});
|
|
1515
|
+
}
|
|
1516
|
+
catch (err) {
|
|
1517
|
+
json(res, HTTP_STATUS.INTERNAL_SERVER_ERROR, {
|
|
1518
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1519
|
+
});
|
|
1520
|
+
}
|
|
1521
|
+
return;
|
|
1522
|
+
}
|
|
1432
1523
|
// --- Session CRUD: /api/v1/sessions/:id ---
|
|
1433
1524
|
const sessionIdMatch = url.match(/^\/api\/v1\/sessions\/([^/?]+)\/?(\?.*)?$/);
|
|
1434
1525
|
if (sessionIdMatch) {
|
|
@@ -1479,33 +1570,12 @@ export function createRequestHandler(deps) {
|
|
|
1479
1570
|
});
|
|
1480
1571
|
})
|
|
1481
1572
|
.catch(() => { });
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
sessions.activePrompts.add(sessionId);
|
|
1487
|
-
sessions.syncBusy(sessionId);
|
|
1488
|
-
void resumePromise
|
|
1489
|
-
.then(() => {
|
|
1490
|
-
if (!sessions.autoRetryIfNeeded(bridge, sessionId)) {
|
|
1491
|
-
// Retry not needed after all — release the optimistic lock
|
|
1492
|
-
sessions.activePrompts.delete(sessionId);
|
|
1493
|
-
sessions.syncBusy(sessionId);
|
|
1494
|
-
}
|
|
1495
|
-
})
|
|
1496
|
-
.catch(() => {
|
|
1497
|
-
sessions.activePrompts.delete(sessionId);
|
|
1498
|
-
sessions.syncBusy(sessionId);
|
|
1499
|
-
});
|
|
1500
|
-
}
|
|
1501
|
-
else {
|
|
1502
|
-
resumePromise.catch((err) => {
|
|
1503
|
-
slog.error("background resume failed", {
|
|
1504
|
-
sessionId: sessionId.slice(0, 8) + "…",
|
|
1505
|
-
error: err,
|
|
1506
|
-
});
|
|
1573
|
+
resumePromise.catch((err) => {
|
|
1574
|
+
slog.error("background resume failed", {
|
|
1575
|
+
sessionId: sessionId.slice(0, 8) + "…",
|
|
1576
|
+
error: err,
|
|
1507
1577
|
});
|
|
1508
|
-
}
|
|
1578
|
+
});
|
|
1509
1579
|
}
|
|
1510
1580
|
}
|
|
1511
1581
|
// If cache is cold and we kicked off a resume, wait briefly so the
|
|
@@ -1547,6 +1617,7 @@ export function createRequestHandler(deps) {
|
|
|
1547
1617
|
json(res, HTTP_STATUS.OK, {
|
|
1548
1618
|
id: freshSession.id,
|
|
1549
1619
|
cwd: freshSession.cwd,
|
|
1620
|
+
cwdDisplay: abbreviateHomePath(freshSession.cwd),
|
|
1550
1621
|
title: freshSession.title,
|
|
1551
1622
|
source: freshSession.source,
|
|
1552
1623
|
model: freshSession.model,
|
|
@@ -1612,6 +1683,9 @@ export function createRequestHandler(deps) {
|
|
|
1612
1683
|
type: "session_created",
|
|
1613
1684
|
sessionId,
|
|
1614
1685
|
cwd: session?.cwd,
|
|
1686
|
+
cwdDisplay: session?.cwd
|
|
1687
|
+
? abbreviateHomePath(session.cwd)
|
|
1688
|
+
: undefined,
|
|
1615
1689
|
title: session?.title,
|
|
1616
1690
|
configOptions,
|
|
1617
1691
|
agentCommands: sessions.getAgentCommands(sessionId),
|
|
@@ -1630,6 +1704,9 @@ export function createRequestHandler(deps) {
|
|
|
1630
1704
|
json(res, HTTP_STATUS.CREATED, {
|
|
1631
1705
|
id: sessionId,
|
|
1632
1706
|
cwd: session?.cwd ?? body.cwd,
|
|
1707
|
+
cwdDisplay: session?.cwd
|
|
1708
|
+
? abbreviateHomePath(session.cwd)
|
|
1709
|
+
: undefined,
|
|
1633
1710
|
title: session?.title ?? null,
|
|
1634
1711
|
source: session?.source ?? source,
|
|
1635
1712
|
configOptions,
|
|
@@ -2185,8 +2262,20 @@ export function createRequestHandler(deps) {
|
|
|
2185
2262
|
return;
|
|
2186
2263
|
}
|
|
2187
2264
|
const cwd = typeof body.cwd === "string" ? body.cwd : undefined;
|
|
2188
|
-
const { sessionId } = await sessions.createSession(bridge, cwd, undefined, "auto");
|
|
2265
|
+
const { sessionId, configOptions } = await sessions.createSession(bridge, cwd, undefined, "auto");
|
|
2189
2266
|
const streamUrl = `/api/v1/sessions/${sessionId}/events/stream`;
|
|
2267
|
+
const session = store.getSession(sessionId);
|
|
2268
|
+
sseManager.broadcast({
|
|
2269
|
+
type: "session_created",
|
|
2270
|
+
sessionId,
|
|
2271
|
+
cwd: session?.cwd,
|
|
2272
|
+
cwdDisplay: session?.cwd
|
|
2273
|
+
? abbreviateHomePath(session.cwd)
|
|
2274
|
+
: undefined,
|
|
2275
|
+
title: session?.title,
|
|
2276
|
+
configOptions,
|
|
2277
|
+
agentCommands: sessions.getAgentCommands(sessionId),
|
|
2278
|
+
});
|
|
2190
2279
|
json(res, HTTP_STATUS.ACCEPTED, { sessionId, streamUrl });
|
|
2191
2280
|
// Fire-and-forget: send the prompt asynchronously, tracking busy state
|
|
2192
2281
|
sessions.activePrompts.add(sessionId);
|