@roamcode.ai/server 1.0.9 → 1.0.11
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/dist/index.d.ts +59 -2
- package/dist/index.js +617 -50
- package/dist/start.d.ts +28 -0
- package/dist/start.js +611 -44
- package/package.json +2 -2
package/dist/start.js
CHANGED
|
@@ -6,16 +6,20 @@ import { join as join12 } from "path";
|
|
|
6
6
|
import { existsSync as existsSync6 } from "fs";
|
|
7
7
|
|
|
8
8
|
// src/transport.ts
|
|
9
|
-
import { randomUUID as
|
|
9
|
+
import { randomUUID as randomUUID4 } from "crypto";
|
|
10
10
|
import { basename as pathBasename } from "path";
|
|
11
|
+
import { createReadStream } from "fs";
|
|
11
12
|
import Fastify from "fastify";
|
|
12
13
|
import websocket from "@fastify/websocket";
|
|
13
14
|
import multipart from "@fastify/multipart";
|
|
14
15
|
|
|
15
16
|
// src/fs-service.ts
|
|
16
|
-
import {
|
|
17
|
+
import { createWriteStream } from "fs";
|
|
18
|
+
import { readdir, readFile, stat, realpath, open, mkdir, unlink, rename, rmdir } from "fs/promises";
|
|
17
19
|
import { constants } from "fs";
|
|
18
20
|
import { resolve, join, sep, basename } from "path";
|
|
21
|
+
import { randomUUID } from "crypto";
|
|
22
|
+
import { pipeline } from "stream/promises";
|
|
19
23
|
var FsError = class extends Error {
|
|
20
24
|
code;
|
|
21
25
|
constructor(code, message) {
|
|
@@ -107,6 +111,14 @@ var FsService = class {
|
|
|
107
111
|
const data = await readFile(real);
|
|
108
112
|
return { filename: basename(file), data };
|
|
109
113
|
}
|
|
114
|
+
/** Validate and describe a real in-root file without reading it into memory. */
|
|
115
|
+
async describeFile(target) {
|
|
116
|
+
const file = this.resolveWithinRoot(target);
|
|
117
|
+
const real = await this.realWithinRoot(file);
|
|
118
|
+
const info = await stat(real);
|
|
119
|
+
if (!info.isFile()) throw new FsError("not-found", `not a file: ${target}`);
|
|
120
|
+
return { path: real, filename: basename(file), size: info.size, mtimeMs: info.mtimeMs };
|
|
121
|
+
}
|
|
110
122
|
/**
|
|
111
123
|
* Validate that `target` is a real in-root file and describe it for an attachment frame WITHOUT
|
|
112
124
|
* reading its bytes. Reuses the same resolveWithinRoot + realpath defense as readFileForDownload so
|
|
@@ -142,6 +154,95 @@ var FsService = class {
|
|
|
142
154
|
}
|
|
143
155
|
return { path: dest };
|
|
144
156
|
}
|
|
157
|
+
/** Stream an upload to a same-directory partial and atomically publish it. The caller owns size limits;
|
|
158
|
+
* this method guarantees an interrupted upload never leaves a visible half-file. */
|
|
159
|
+
async writeUploadedStream(targetDir, filename, input, beforeCommit) {
|
|
160
|
+
if (!filename || filename.includes("/") || filename.includes("\\") || filename.includes(sep)) {
|
|
161
|
+
throw new Error(`invalid upload filename (no path separators allowed): ${filename}`);
|
|
162
|
+
}
|
|
163
|
+
const dir = this.resolveWithinRoot(targetDir);
|
|
164
|
+
await this.realWithinRoot(dir);
|
|
165
|
+
const dest = this.resolveWithinRoot(join(dir, filename));
|
|
166
|
+
const partial = this.resolveWithinRoot(join(dir, `.upload-${randomUUID()}.partial`));
|
|
167
|
+
try {
|
|
168
|
+
await pipeline(
|
|
169
|
+
input,
|
|
170
|
+
createWriteStream(partial, { flags: "wx", mode: 384 })
|
|
171
|
+
);
|
|
172
|
+
if (beforeCommit && !beforeCommit()) throw new Error("upload stream rejected before commit");
|
|
173
|
+
await rename(partial, dest);
|
|
174
|
+
const info = await stat(dest);
|
|
175
|
+
return { path: dest, size: info.size };
|
|
176
|
+
} catch (err) {
|
|
177
|
+
await unlink(partial).catch(() => void 0);
|
|
178
|
+
throw err;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
/** Atomically replace one existing in-root managed file with streamed bytes. */
|
|
182
|
+
async replaceFileStream(target, input, beforeCommit) {
|
|
183
|
+
const file = this.resolveWithinRoot(target);
|
|
184
|
+
const real = await this.realWithinRoot(file);
|
|
185
|
+
const parent = resolve(real, "..");
|
|
186
|
+
await this.realWithinRoot(parent);
|
|
187
|
+
const partial = join(parent, `.edit-${randomUUID()}.partial`);
|
|
188
|
+
try {
|
|
189
|
+
await pipeline(
|
|
190
|
+
input,
|
|
191
|
+
createWriteStream(partial, { flags: "wx", mode: 384 })
|
|
192
|
+
);
|
|
193
|
+
if (beforeCommit && !beforeCommit()) throw new Error("replacement stream rejected before commit");
|
|
194
|
+
await rename(partial, real);
|
|
195
|
+
const info = await stat(real);
|
|
196
|
+
return { path: real, size: info.size };
|
|
197
|
+
} catch (err) {
|
|
198
|
+
await unlink(partial).catch(() => void 0);
|
|
199
|
+
throw err;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
/** Best-effort removal used only for server-owned managed attachment copies. */
|
|
203
|
+
async removeManagedPath(target) {
|
|
204
|
+
const file = this.resolveWithinRoot(target);
|
|
205
|
+
const real = await this.realWithinRoot(file);
|
|
206
|
+
await unlink(real);
|
|
207
|
+
await rmdir(resolve(real, "..")).catch(() => void 0);
|
|
208
|
+
}
|
|
209
|
+
/** Discover regular files in an app-owned directory for one-time legacy attachment backfill. Symlinks
|
|
210
|
+
* are ignored and traversal is deliberately shallow: old uploads are direct children, current uploads
|
|
211
|
+
* add exactly one id directory. */
|
|
212
|
+
async discoverManagedFiles(target, maxDepth = 1) {
|
|
213
|
+
let root;
|
|
214
|
+
try {
|
|
215
|
+
root = this.resolveWithinRoot(target);
|
|
216
|
+
await this.realWithinRoot(root);
|
|
217
|
+
} catch {
|
|
218
|
+
return [];
|
|
219
|
+
}
|
|
220
|
+
const found = [];
|
|
221
|
+
const walk = async (dir, depth) => {
|
|
222
|
+
let entries;
|
|
223
|
+
try {
|
|
224
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
225
|
+
} catch {
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
for (const entry of entries) {
|
|
229
|
+
if (entry.isSymbolicLink() || entry.name.endsWith(".partial")) continue;
|
|
230
|
+
const path = join(dir, entry.name);
|
|
231
|
+
if (entry.isDirectory()) {
|
|
232
|
+
if (depth < maxDepth) await walk(path, depth + 1);
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
if (!entry.isFile()) continue;
|
|
236
|
+
try {
|
|
237
|
+
const info = await stat(path);
|
|
238
|
+
found.push({ path, filename: entry.name, size: info.size, mtimeMs: info.mtimeMs });
|
|
239
|
+
} catch {
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
};
|
|
243
|
+
await walk(root, 0);
|
|
244
|
+
return found;
|
|
245
|
+
}
|
|
145
246
|
/** Ensure a directory (confined to root) exists, creating parents as needed; returns its absolute path. */
|
|
146
247
|
async ensureDirWithinRoot(target) {
|
|
147
248
|
const dir = this.resolveWithinRoot(target);
|
|
@@ -231,30 +332,35 @@ var FsService = class {
|
|
|
231
332
|
let removed = 0;
|
|
232
333
|
for (const e of entries) {
|
|
233
334
|
if (!e.isDirectory()) continue;
|
|
234
|
-
removed += await this.pruneOlderThan(join(dir, e.name), maxAgeMs, now);
|
|
335
|
+
removed += await this.pruneOlderThan(join(dir, e.name), maxAgeMs, now, true);
|
|
235
336
|
}
|
|
236
337
|
return removed;
|
|
237
338
|
}
|
|
238
339
|
/** Delete top-level regular files in `target` (confined to root) whose mtime is older than `maxAgeMs`.
|
|
239
340
|
* Best-effort — returns how many were removed; a missing dir / unreadable entry is skipped, not thrown.
|
|
240
341
|
* Subdirectories are left untouched. Used to give terminal shared-files uploads a bounded lifetime. */
|
|
241
|
-
async pruneOlderThan(target, maxAgeMs, now = Date.now()) {
|
|
342
|
+
async pruneOlderThan(target, maxAgeMs, now = Date.now(), recursive = false) {
|
|
242
343
|
let dir;
|
|
243
344
|
try {
|
|
244
345
|
dir = this.resolveWithinRoot(target);
|
|
245
346
|
} catch {
|
|
246
347
|
return 0;
|
|
247
348
|
}
|
|
248
|
-
let
|
|
349
|
+
let entries;
|
|
249
350
|
try {
|
|
250
|
-
|
|
351
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
251
352
|
} catch {
|
|
252
353
|
return 0;
|
|
253
354
|
}
|
|
254
355
|
let removed = 0;
|
|
255
|
-
for (const
|
|
256
|
-
const p = join(dir, name);
|
|
356
|
+
for (const entry of entries) {
|
|
357
|
+
const p = join(dir, entry.name);
|
|
257
358
|
try {
|
|
359
|
+
if (recursive && entry.isDirectory()) {
|
|
360
|
+
removed += await this.pruneOlderThan(p, maxAgeMs, now, true);
|
|
361
|
+
await rmdir(p).catch(() => void 0);
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
258
364
|
const s = await stat(p);
|
|
259
365
|
if (s.isFile() && now - s.mtimeMs > maxAgeMs) {
|
|
260
366
|
await unlink(p);
|
|
@@ -653,7 +759,7 @@ function terminalSharedDir(opts) {
|
|
|
653
759
|
|
|
654
760
|
// src/updater.ts
|
|
655
761
|
import { spawn as nodeSpawn } from "child_process";
|
|
656
|
-
import { randomUUID as
|
|
762
|
+
import { randomUUID as randomUUID3 } from "crypto";
|
|
657
763
|
import {
|
|
658
764
|
chmodSync as nodeChmodSync,
|
|
659
765
|
existsSync as nodeExistsSync,
|
|
@@ -667,7 +773,7 @@ import { fileURLToPath } from "url";
|
|
|
667
773
|
|
|
668
774
|
// src/managed-runtime.ts
|
|
669
775
|
import { spawn } from "child_process";
|
|
670
|
-
import { randomUUID } from "crypto";
|
|
776
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
671
777
|
import {
|
|
672
778
|
chmodSync as chmodSync3,
|
|
673
779
|
existsSync as existsSync4,
|
|
@@ -765,7 +871,7 @@ function readPreviousVersion(root) {
|
|
|
765
871
|
}
|
|
766
872
|
|
|
767
873
|
// src/updater.ts
|
|
768
|
-
var RUNNING_VERSION = "1.0.
|
|
874
|
+
var RUNNING_VERSION = "1.0.11" ? "1.0.11".replace(/^v/, "") : packageVersion();
|
|
769
875
|
var RELEASES_API = "https://api.github.com/repos/burakgon/roamcode/releases?per_page=100";
|
|
770
876
|
var RELEASE_MANIFEST_ASSET = "roamcode-release.json";
|
|
771
877
|
var CHECK_CACHE_MS = 15 * 6e4;
|
|
@@ -1064,7 +1170,7 @@ var Updater = class {
|
|
|
1064
1170
|
const path = join6(this.dataDir, "update-status.json");
|
|
1065
1171
|
const value = JSON.stringify(status, null, 2) + "\n";
|
|
1066
1172
|
if (this.fs.renameSync) {
|
|
1067
|
-
const temp = `${path}.${process.pid}.${
|
|
1173
|
+
const temp = `${path}.${process.pid}.${randomUUID3()}.tmp`;
|
|
1068
1174
|
this.fs.writeFileSync(temp, value, 384);
|
|
1069
1175
|
this.fs.renameSync(temp, path);
|
|
1070
1176
|
} else {
|
|
@@ -1127,7 +1233,7 @@ var Updater = class {
|
|
|
1127
1233
|
return { started: false, reason: error instanceof Error ? error.message : String(error) };
|
|
1128
1234
|
}
|
|
1129
1235
|
}
|
|
1130
|
-
const operationId =
|
|
1236
|
+
const operationId = randomUUID3();
|
|
1131
1237
|
const status = {
|
|
1132
1238
|
operationId,
|
|
1133
1239
|
state: "starting",
|
|
@@ -1811,6 +1917,25 @@ function brandSessionStore(store) {
|
|
|
1811
1917
|
concreteSessionStores.add(store);
|
|
1812
1918
|
return store;
|
|
1813
1919
|
}
|
|
1920
|
+
function sessionFileRowToStored(row) {
|
|
1921
|
+
return {
|
|
1922
|
+
id: row.id,
|
|
1923
|
+
sessionId: row.session_id,
|
|
1924
|
+
direction: row.direction,
|
|
1925
|
+
storage: row.storage,
|
|
1926
|
+
name: row.name,
|
|
1927
|
+
path: row.path,
|
|
1928
|
+
mimeType: row.mime_type,
|
|
1929
|
+
size: row.size,
|
|
1930
|
+
kind: row.kind,
|
|
1931
|
+
...row.caption ? { caption: row.caption } : {},
|
|
1932
|
+
createdAt: row.created_at,
|
|
1933
|
+
updatedAt: row.updated_at,
|
|
1934
|
+
expiresAt: row.expires_at,
|
|
1935
|
+
...row.derived_from_id ? { derivedFromId: row.derived_from_id } : {},
|
|
1936
|
+
...row.hidden_at !== null ? { hiddenAt: row.hidden_at } : {}
|
|
1937
|
+
};
|
|
1938
|
+
}
|
|
1814
1939
|
function parseSpawnArgs(raw) {
|
|
1815
1940
|
if (typeof raw !== "string" || raw.length === 0) return void 0;
|
|
1816
1941
|
try {
|
|
@@ -1919,6 +2044,9 @@ function cloneStoredSessionDefaults(value) {
|
|
|
1919
2044
|
updatedAt: value.updatedAt
|
|
1920
2045
|
};
|
|
1921
2046
|
}
|
|
2047
|
+
function cloneStoredSessionFile(value) {
|
|
2048
|
+
return { ...value };
|
|
2049
|
+
}
|
|
1922
2050
|
function appSettingRowToSessionDefaults(row) {
|
|
1923
2051
|
if (!row) return void 0;
|
|
1924
2052
|
try {
|
|
@@ -1933,6 +2061,7 @@ function appSettingRowToSessionDefaults(row) {
|
|
|
1933
2061
|
}
|
|
1934
2062
|
function inMemoryStore() {
|
|
1935
2063
|
const map = /* @__PURE__ */ new Map();
|
|
2064
|
+
const files = /* @__PURE__ */ new Map();
|
|
1936
2065
|
const provisionalProviderSessionIds = /* @__PURE__ */ new Map();
|
|
1937
2066
|
let sessionDefaults;
|
|
1938
2067
|
const write = (s) => {
|
|
@@ -2031,13 +2160,38 @@ function inMemoryStore() {
|
|
|
2031
2160
|
};
|
|
2032
2161
|
return cloneStoredSessionDefaults(sessionDefaults);
|
|
2033
2162
|
},
|
|
2163
|
+
putFile: (file) => files.set(`${file.sessionId}:${file.id}`, cloneStoredSessionFile(file)),
|
|
2164
|
+
getFile: (sessionId, id) => {
|
|
2165
|
+
const value = files.get(`${sessionId}:${id}`);
|
|
2166
|
+
return value ? cloneStoredSessionFile(value) : void 0;
|
|
2167
|
+
},
|
|
2168
|
+
listFiles: (sessionId, includeHidden = false) => [...files.values()].filter((file) => file.sessionId === sessionId && (includeHidden || file.hiddenAt === void 0)).sort((a, b) => b.createdAt - a.createdAt || b.id.localeCompare(a.id)).map(cloneStoredSessionFile),
|
|
2169
|
+
setFileHidden: (sessionId, id, hiddenAt) => {
|
|
2170
|
+
const file = files.get(`${sessionId}:${id}`);
|
|
2171
|
+
if (!file) return;
|
|
2172
|
+
if (hiddenAt === void 0) delete file.hiddenAt;
|
|
2173
|
+
else file.hiddenAt = hiddenAt;
|
|
2174
|
+
file.updatedAt = Date.now();
|
|
2175
|
+
},
|
|
2176
|
+
deleteFile: (sessionId, id) => void files.delete(`${sessionId}:${id}`),
|
|
2177
|
+
pruneFiles: (expiredBefore) => {
|
|
2178
|
+
const removed = [];
|
|
2179
|
+
for (const [key, file] of files) {
|
|
2180
|
+
if (file.expiresAt > expiredBefore) continue;
|
|
2181
|
+
removed.push(cloneStoredSessionFile(file));
|
|
2182
|
+
files.delete(key);
|
|
2183
|
+
}
|
|
2184
|
+
return removed;
|
|
2185
|
+
},
|
|
2034
2186
|
delete: (id) => {
|
|
2035
2187
|
provisionalProviderSessionIds.delete(id);
|
|
2036
2188
|
map.delete(id);
|
|
2189
|
+
for (const [key, file] of files) if (file.sessionId === id) files.delete(key);
|
|
2037
2190
|
},
|
|
2038
2191
|
close: () => {
|
|
2039
2192
|
provisionalProviderSessionIds.clear();
|
|
2040
2193
|
map.clear();
|
|
2194
|
+
files.clear();
|
|
2041
2195
|
sessionDefaults = void 0;
|
|
2042
2196
|
},
|
|
2043
2197
|
mode: "memory-fallback"
|
|
@@ -2115,6 +2269,28 @@ function openSessionStore(opts) {
|
|
|
2115
2269
|
revision INTEGER NOT NULL CHECK (revision > 0),
|
|
2116
2270
|
updated_at INTEGER NOT NULL
|
|
2117
2271
|
);
|
|
2272
|
+
|
|
2273
|
+
CREATE TABLE IF NOT EXISTS session_files (
|
|
2274
|
+
id TEXT NOT NULL,
|
|
2275
|
+
session_id TEXT NOT NULL,
|
|
2276
|
+
direction TEXT NOT NULL CHECK (direction IN ('sent', 'received')),
|
|
2277
|
+
storage TEXT NOT NULL CHECK (storage IN ('managed', 'workspace')),
|
|
2278
|
+
name TEXT NOT NULL,
|
|
2279
|
+
path TEXT NOT NULL,
|
|
2280
|
+
mime_type TEXT NOT NULL,
|
|
2281
|
+
size INTEGER NOT NULL,
|
|
2282
|
+
kind TEXT NOT NULL CHECK (kind IN ('image', 'pdf', 'text', 'binary')),
|
|
2283
|
+
caption TEXT,
|
|
2284
|
+
created_at INTEGER NOT NULL,
|
|
2285
|
+
updated_at INTEGER NOT NULL,
|
|
2286
|
+
expires_at INTEGER NOT NULL,
|
|
2287
|
+
derived_from_id TEXT,
|
|
2288
|
+
hidden_at INTEGER,
|
|
2289
|
+
PRIMARY KEY (session_id, id)
|
|
2290
|
+
);
|
|
2291
|
+
CREATE INDEX IF NOT EXISTS session_files_session_created_idx
|
|
2292
|
+
ON session_files(session_id, created_at DESC);
|
|
2293
|
+
CREATE INDEX IF NOT EXISTS session_files_expiry_idx ON session_files(expires_at);
|
|
2118
2294
|
`);
|
|
2119
2295
|
try {
|
|
2120
2296
|
db.exec("ALTER TABLE provider_sessions ADD COLUMN provisional_provider_session_id TEXT");
|
|
@@ -2194,6 +2370,33 @@ function openSessionStore(opts) {
|
|
|
2194
2370
|
ON CONFLICT(key) DO UPDATE SET
|
|
2195
2371
|
value_json=excluded.value_json, revision=excluded.revision, updated_at=excluded.updated_at
|
|
2196
2372
|
`);
|
|
2373
|
+
const filePutStmt = db.prepare(`
|
|
2374
|
+
INSERT INTO session_files (
|
|
2375
|
+
id, session_id, direction, storage, name, path, mime_type, size, kind, caption,
|
|
2376
|
+
created_at, updated_at, expires_at, derived_from_id, hidden_at
|
|
2377
|
+
) VALUES (
|
|
2378
|
+
@id, @session_id, @direction, @storage, @name, @path, @mime_type, @size, @kind, @caption,
|
|
2379
|
+
@created_at, @updated_at, @expires_at, @derived_from_id, @hidden_at
|
|
2380
|
+
) ON CONFLICT(session_id, id) DO UPDATE SET
|
|
2381
|
+
direction=excluded.direction, storage=excluded.storage, name=excluded.name, path=excluded.path,
|
|
2382
|
+
mime_type=excluded.mime_type, size=excluded.size, kind=excluded.kind, caption=excluded.caption,
|
|
2383
|
+
updated_at=excluded.updated_at, expires_at=excluded.expires_at,
|
|
2384
|
+
derived_from_id=excluded.derived_from_id, hidden_at=excluded.hidden_at
|
|
2385
|
+
`);
|
|
2386
|
+
const fileGetStmt = db.prepare("SELECT * FROM session_files WHERE session_id = ? AND id = ?");
|
|
2387
|
+
const fileListStmt = db.prepare(
|
|
2388
|
+
"SELECT * FROM session_files WHERE session_id = ? AND hidden_at IS NULL ORDER BY created_at DESC, id DESC"
|
|
2389
|
+
);
|
|
2390
|
+
const fileListAllStmt = db.prepare(
|
|
2391
|
+
"SELECT * FROM session_files WHERE session_id = ? ORDER BY created_at DESC, id DESC"
|
|
2392
|
+
);
|
|
2393
|
+
const fileHideStmt = db.prepare(
|
|
2394
|
+
"UPDATE session_files SET hidden_at = ?, updated_at = ? WHERE session_id = ? AND id = ?"
|
|
2395
|
+
);
|
|
2396
|
+
const fileDeleteStmt = db.prepare("DELETE FROM session_files WHERE session_id = ? AND id = ?");
|
|
2397
|
+
const filesForSessionDeleteStmt = db.prepare("DELETE FROM session_files WHERE session_id = ?");
|
|
2398
|
+
const expiredFilesStmt = db.prepare("SELECT * FROM session_files WHERE expires_at <= ?");
|
|
2399
|
+
const pruneFilesStmt = db.prepare("DELETE FROM session_files WHERE expires_at <= ?");
|
|
2197
2400
|
const ownerOf = (id) => {
|
|
2198
2401
|
const hasLegacy = legacyGetStmt.get(id) !== void 0;
|
|
2199
2402
|
const hasProvider = providerGetStmt.get(id) !== void 0;
|
|
@@ -2370,10 +2573,45 @@ function openSessionStore(opts) {
|
|
|
2370
2573
|
},
|
|
2371
2574
|
getSessionDefaults: () => appSettingRowToSessionDefaults(sessionDefaultsGetStmt.get()),
|
|
2372
2575
|
putSessionDefaults: (defaults, expectedRevision, updatedAt) => putSessionDefaultsAtomically.immediate(normalizeSessionDefaults(defaults), expectedRevision, updatedAt),
|
|
2576
|
+
putFile: (file) => {
|
|
2577
|
+
filePutStmt.run({
|
|
2578
|
+
id: file.id,
|
|
2579
|
+
session_id: file.sessionId,
|
|
2580
|
+
direction: file.direction,
|
|
2581
|
+
storage: file.storage,
|
|
2582
|
+
name: file.name,
|
|
2583
|
+
path: file.path,
|
|
2584
|
+
mime_type: file.mimeType,
|
|
2585
|
+
size: file.size,
|
|
2586
|
+
kind: file.kind,
|
|
2587
|
+
caption: file.caption ?? null,
|
|
2588
|
+
created_at: file.createdAt,
|
|
2589
|
+
updated_at: file.updatedAt,
|
|
2590
|
+
expires_at: file.expiresAt,
|
|
2591
|
+
derived_from_id: file.derivedFromId ?? null,
|
|
2592
|
+
hidden_at: file.hiddenAt ?? null
|
|
2593
|
+
});
|
|
2594
|
+
},
|
|
2595
|
+
getFile: (sessionId, id) => {
|
|
2596
|
+
const row = fileGetStmt.get(sessionId, id);
|
|
2597
|
+
return row ? sessionFileRowToStored(row) : void 0;
|
|
2598
|
+
},
|
|
2599
|
+
listFiles: (sessionId, includeHidden = false) => {
|
|
2600
|
+
const rows = includeHidden ? fileListAllStmt.all(sessionId) : fileListStmt.all(sessionId);
|
|
2601
|
+
return rows.map(sessionFileRowToStored);
|
|
2602
|
+
},
|
|
2603
|
+
setFileHidden: (sessionId, id, hiddenAt) => fileHideStmt.run(hiddenAt ?? null, Date.now(), sessionId, id),
|
|
2604
|
+
deleteFile: (sessionId, id) => void fileDeleteStmt.run(sessionId, id),
|
|
2605
|
+
pruneFiles: (expiredBefore) => {
|
|
2606
|
+
const rows = expiredFilesStmt.all(expiredBefore);
|
|
2607
|
+
pruneFilesStmt.run(expiredBefore);
|
|
2608
|
+
return rows.map(sessionFileRowToStored);
|
|
2609
|
+
},
|
|
2373
2610
|
delete: (id) => {
|
|
2374
2611
|
const owner = ownerOf(id);
|
|
2375
2612
|
if (owner === "claude") legacyDeleteStmt.run(id);
|
|
2376
2613
|
else if (owner === "codex") providerDeleteStmt.run(id);
|
|
2614
|
+
filesForSessionDeleteStmt.run(id);
|
|
2377
2615
|
},
|
|
2378
2616
|
close: () => db.close(),
|
|
2379
2617
|
mode: "sqlite"
|
|
@@ -4004,6 +4242,74 @@ var MAX_PENDING_TERMINAL_INPUT_FRAMES = 64;
|
|
|
4004
4242
|
var MAX_PENDING_TERMINAL_INPUT_BYTES = 1e6;
|
|
4005
4243
|
var MAX_TERMINAL_WS_BUFFER = 16e6;
|
|
4006
4244
|
var TERMINAL_WS_PING_MS = 25e3;
|
|
4245
|
+
var TEXT_FILE_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
4246
|
+
"txt",
|
|
4247
|
+
"md",
|
|
4248
|
+
"markdown",
|
|
4249
|
+
"json",
|
|
4250
|
+
"jsonl",
|
|
4251
|
+
"yaml",
|
|
4252
|
+
"yml",
|
|
4253
|
+
"toml",
|
|
4254
|
+
"xml",
|
|
4255
|
+
"csv",
|
|
4256
|
+
"tsv",
|
|
4257
|
+
"log",
|
|
4258
|
+
"js",
|
|
4259
|
+
"jsx",
|
|
4260
|
+
"ts",
|
|
4261
|
+
"tsx",
|
|
4262
|
+
"css",
|
|
4263
|
+
"scss",
|
|
4264
|
+
"html",
|
|
4265
|
+
"htm",
|
|
4266
|
+
"sql",
|
|
4267
|
+
"sh",
|
|
4268
|
+
"bash",
|
|
4269
|
+
"zsh",
|
|
4270
|
+
"py",
|
|
4271
|
+
"rb",
|
|
4272
|
+
"go",
|
|
4273
|
+
"rs",
|
|
4274
|
+
"java",
|
|
4275
|
+
"kt",
|
|
4276
|
+
"swift",
|
|
4277
|
+
"c",
|
|
4278
|
+
"h",
|
|
4279
|
+
"cpp",
|
|
4280
|
+
"hpp",
|
|
4281
|
+
"env",
|
|
4282
|
+
"ini",
|
|
4283
|
+
"conf"
|
|
4284
|
+
]);
|
|
4285
|
+
var IMAGE_FILE_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
4286
|
+
"png",
|
|
4287
|
+
"jpg",
|
|
4288
|
+
"jpeg",
|
|
4289
|
+
"gif",
|
|
4290
|
+
"webp",
|
|
4291
|
+
"svg",
|
|
4292
|
+
"bmp",
|
|
4293
|
+
"avif",
|
|
4294
|
+
"apng",
|
|
4295
|
+
"heic",
|
|
4296
|
+
"heif"
|
|
4297
|
+
]);
|
|
4298
|
+
function attachmentMedia(filename, declared = "application/octet-stream") {
|
|
4299
|
+
const ext = filename.includes(".") ? filename.slice(filename.lastIndexOf(".") + 1).toLowerCase() : "";
|
|
4300
|
+
if (declared.startsWith("image/") || IMAGE_FILE_EXTENSIONS.has(ext)) {
|
|
4301
|
+
const inferred = ext === "jpg" || ext === "jpeg" ? "image/jpeg" : ext === "svg" ? "image/svg+xml" : ext ? `image/${ext}` : declared;
|
|
4302
|
+
return { mimeType: declared.startsWith("image/") ? declared : inferred, kind: "image" };
|
|
4303
|
+
}
|
|
4304
|
+
if (declared === "application/pdf" || ext === "pdf") return { mimeType: "application/pdf", kind: "pdf" };
|
|
4305
|
+
if (declared.startsWith("text/") || TEXT_FILE_EXTENSIONS.has(ext)) {
|
|
4306
|
+
return { mimeType: "text/plain; charset=utf-8", kind: "text" };
|
|
4307
|
+
}
|
|
4308
|
+
return { mimeType: declared || "application/octet-stream", kind: "binary" };
|
|
4309
|
+
}
|
|
4310
|
+
function publicSessionFile(file, available = true) {
|
|
4311
|
+
return { ...file, isImage: file.kind === "image", available };
|
|
4312
|
+
}
|
|
4007
4313
|
function isDisallowedPushHost(hostname) {
|
|
4008
4314
|
const host = hostname.toLowerCase().replace(/^\[|\]$/g, "");
|
|
4009
4315
|
if (host === "localhost" || host.endsWith(".localhost")) return true;
|
|
@@ -4059,8 +4365,44 @@ function createServer(config, deps = {}) {
|
|
|
4059
4365
|
});
|
|
4060
4366
|
const fsService = new FsService({ root: config.fsRoot });
|
|
4061
4367
|
const terminalSharedRoot = terminalSharedBase({ dataDir: config.dataDir, fsRoot: config.fsRoot });
|
|
4368
|
+
const backfilledFileSessions = /* @__PURE__ */ new Set();
|
|
4369
|
+
const backfillManagedFiles = async (sessionId) => {
|
|
4370
|
+
if (backfilledFileSessions.has(sessionId)) return;
|
|
4371
|
+
backfilledFileSessions.add(sessionId);
|
|
4372
|
+
const sessionDir = terminalSharedDir({ dataDir: config.dataDir, fsRoot: config.fsRoot, sessionId });
|
|
4373
|
+
const discovered = await fsService.discoverManagedFiles(sessionDir);
|
|
4374
|
+
const knownPaths = new Set(store.listFiles(sessionId, true).map((file) => file.path));
|
|
4375
|
+
for (const file of discovered) {
|
|
4376
|
+
if (knownPaths.has(file.path)) continue;
|
|
4377
|
+
const expiresAt = file.mtimeMs + TERMINAL_FILE_TTL_MS;
|
|
4378
|
+
if (expiresAt <= Date.now()) continue;
|
|
4379
|
+
const now = Date.now();
|
|
4380
|
+
const media = attachmentMedia(file.filename);
|
|
4381
|
+
store.putFile({
|
|
4382
|
+
id: randomUUID4(),
|
|
4383
|
+
sessionId,
|
|
4384
|
+
direction: "sent",
|
|
4385
|
+
storage: "managed",
|
|
4386
|
+
name: file.filename,
|
|
4387
|
+
path: file.path,
|
|
4388
|
+
mimeType: media.mimeType,
|
|
4389
|
+
size: file.size,
|
|
4390
|
+
kind: media.kind,
|
|
4391
|
+
createdAt: file.mtimeMs,
|
|
4392
|
+
updatedAt: now,
|
|
4393
|
+
expiresAt
|
|
4394
|
+
});
|
|
4395
|
+
knownPaths.add(file.path);
|
|
4396
|
+
}
|
|
4397
|
+
};
|
|
4062
4398
|
const sweepSharedFiles = () => {
|
|
4063
|
-
void
|
|
4399
|
+
void (async () => {
|
|
4400
|
+
const expired = typeof store.pruneFiles === "function" ? store.pruneFiles(Date.now()) : [];
|
|
4401
|
+
await Promise.all(
|
|
4402
|
+
expired.filter((file) => file.storage === "managed").map((file) => fsService.removeManagedPath(file.path).catch(() => void 0))
|
|
4403
|
+
);
|
|
4404
|
+
await fsService.pruneChildDirsOlderThan(terminalSharedRoot, TERMINAL_FILE_TTL_MS).catch(() => 0);
|
|
4405
|
+
})();
|
|
4064
4406
|
};
|
|
4065
4407
|
sweepSharedFiles();
|
|
4066
4408
|
const sharedSweepTimer = setInterval(sweepSharedFiles, TERMINAL_SWEEP_INTERVAL_MS);
|
|
@@ -4305,7 +4647,7 @@ function createServer(config, deps = {}) {
|
|
|
4305
4647
|
reply.code(400).send({ error: `cwd does not exist: ${body.cwd}` });
|
|
4306
4648
|
return;
|
|
4307
4649
|
}
|
|
4308
|
-
const id =
|
|
4650
|
+
const id = randomUUID4();
|
|
4309
4651
|
const rawOptions = body.options ?? (provider === "claude" ? {
|
|
4310
4652
|
...typeof body.model === "string" ? { model: body.model } : {},
|
|
4311
4653
|
...typeof body.effort === "string" ? { effort: body.effort } : {},
|
|
@@ -4529,8 +4871,10 @@ function createServer(config, deps = {}) {
|
|
|
4529
4871
|
}
|
|
4530
4872
|
const caption = typeof body.caption === "string" ? body.caption : void 0;
|
|
4531
4873
|
let described;
|
|
4874
|
+
let fileInfo;
|
|
4532
4875
|
try {
|
|
4533
4876
|
described = await fsService.describeForAttachment(body.path);
|
|
4877
|
+
fileInfo = await fsService.describeFile(body.path);
|
|
4534
4878
|
} catch (err) {
|
|
4535
4879
|
if (err instanceof FsError) {
|
|
4536
4880
|
reply.code(err.code === "forbidden" ? 403 : 404).send({ error: err.message });
|
|
@@ -4540,14 +4884,28 @@ function createServer(config, deps = {}) {
|
|
|
4540
4884
|
return;
|
|
4541
4885
|
}
|
|
4542
4886
|
const isImage = body.kind === "image" ? true : body.kind === "file" ? false : described.isImage;
|
|
4543
|
-
const id =
|
|
4544
|
-
|
|
4545
|
-
|
|
4887
|
+
const id = randomUUID4();
|
|
4888
|
+
const now = Date.now();
|
|
4889
|
+
const media = attachmentMedia(described.name);
|
|
4890
|
+
const stored = {
|
|
4546
4891
|
id,
|
|
4547
|
-
|
|
4892
|
+
sessionId,
|
|
4893
|
+
direction: "received",
|
|
4894
|
+
storage: "workspace",
|
|
4548
4895
|
name: described.name,
|
|
4549
|
-
|
|
4550
|
-
|
|
4896
|
+
path: body.path,
|
|
4897
|
+
mimeType: media.mimeType,
|
|
4898
|
+
size: fileInfo.size,
|
|
4899
|
+
kind: isImage ? "image" : media.kind,
|
|
4900
|
+
...caption ? { caption } : {},
|
|
4901
|
+
createdAt: now,
|
|
4902
|
+
updatedAt: now,
|
|
4903
|
+
expiresAt: now + TERMINAL_FILE_TTL_MS
|
|
4904
|
+
};
|
|
4905
|
+
store.putFile(stored);
|
|
4906
|
+
terminalManager.pushControl(sessionId, {
|
|
4907
|
+
t: "attach",
|
|
4908
|
+
...publicSessionFile(stored)
|
|
4551
4909
|
});
|
|
4552
4910
|
dispatchPush({ kind: "file", sessionId, detail: described.name });
|
|
4553
4911
|
reply.code(200).send({ ok: true, id });
|
|
@@ -5078,23 +5436,126 @@ function createServer(config, deps = {}) {
|
|
|
5078
5436
|
reply.code(400).send({ error: err.message });
|
|
5079
5437
|
}
|
|
5080
5438
|
});
|
|
5439
|
+
app.get("/sessions/:id/files", async (request, reply) => {
|
|
5440
|
+
if (!terminalManager.get(request.params.id)) {
|
|
5441
|
+
reply.code(404).send({ error: "terminal session not found" });
|
|
5442
|
+
return;
|
|
5443
|
+
}
|
|
5444
|
+
await backfillManagedFiles(request.params.id);
|
|
5445
|
+
const files = await Promise.all(
|
|
5446
|
+
store.listFiles(request.params.id).map(async (file) => {
|
|
5447
|
+
const available = file.expiresAt > Date.now() && await fsService.describeFile(file.path).then(() => true).catch(() => false);
|
|
5448
|
+
return publicSessionFile(file, available);
|
|
5449
|
+
})
|
|
5450
|
+
);
|
|
5451
|
+
return {
|
|
5452
|
+
files,
|
|
5453
|
+
policy: {
|
|
5454
|
+
maxUploadBytes: config.maxUploadBytes,
|
|
5455
|
+
retentionMs: TERMINAL_FILE_TTL_MS,
|
|
5456
|
+
durable: store.mode === "sqlite"
|
|
5457
|
+
}
|
|
5458
|
+
};
|
|
5459
|
+
});
|
|
5460
|
+
app.get(
|
|
5461
|
+
"/sessions/:id/files/:fileId/content",
|
|
5462
|
+
async (request, reply) => {
|
|
5463
|
+
const file = store.getFile(request.params.id, request.params.fileId);
|
|
5464
|
+
if (!file || file.hiddenAt !== void 0) {
|
|
5465
|
+
reply.code(404).send({ error: "file not found" });
|
|
5466
|
+
return;
|
|
5467
|
+
}
|
|
5468
|
+
if (file.expiresAt <= Date.now()) {
|
|
5469
|
+
reply.code(410).send({ error: "file has expired" });
|
|
5470
|
+
return;
|
|
5471
|
+
}
|
|
5472
|
+
try {
|
|
5473
|
+
const info = await fsService.describeFile(file.path);
|
|
5474
|
+
const disposition = request.query.disposition === "inline" ? "inline" : "attachment";
|
|
5475
|
+
reply.header("accept-ranges", "bytes").header("content-type", file.mimeType).header("x-content-type-options", "nosniff").header(
|
|
5476
|
+
"content-security-policy",
|
|
5477
|
+
"sandbox; default-src 'none'; img-src 'self' data: blob:; style-src 'unsafe-inline'"
|
|
5478
|
+
).header("content-disposition", contentDisposition(info.filename, disposition));
|
|
5479
|
+
const range = request.headers.range;
|
|
5480
|
+
if (range) {
|
|
5481
|
+
const match = /^bytes=(\d+)-(\d*)$/.exec(range);
|
|
5482
|
+
if (!match) {
|
|
5483
|
+
reply.code(416).header("content-range", `bytes */${info.size}`).send();
|
|
5484
|
+
return;
|
|
5485
|
+
}
|
|
5486
|
+
const start = Number(match[1]);
|
|
5487
|
+
const end = match[2] ? Math.min(Number(match[2]), info.size - 1) : info.size - 1;
|
|
5488
|
+
if (!Number.isSafeInteger(start) || start < 0 || start > end || start >= info.size) {
|
|
5489
|
+
reply.code(416).header("content-range", `bytes */${info.size}`).send();
|
|
5490
|
+
return;
|
|
5491
|
+
}
|
|
5492
|
+
return reply.code(206).header("content-range", `bytes ${start}-${end}/${info.size}`).header("content-length", String(end - start + 1)).send(createReadStream(info.path, { start, end }));
|
|
5493
|
+
}
|
|
5494
|
+
return reply.header("content-length", String(info.size)).send(createReadStream(info.path));
|
|
5495
|
+
} catch (err) {
|
|
5496
|
+
const code = err instanceof FsError && err.code === "forbidden" ? 403 : 404;
|
|
5497
|
+
reply.code(code).send({ error: err.message });
|
|
5498
|
+
}
|
|
5499
|
+
}
|
|
5500
|
+
);
|
|
5081
5501
|
app.post("/sessions/:id/upload", async (request, reply) => {
|
|
5082
|
-
const
|
|
5083
|
-
if (!
|
|
5502
|
+
const sessionId = request.params.id;
|
|
5503
|
+
if (!terminalManager.get(sessionId)) {
|
|
5084
5504
|
reply.code(404).send({ error: "terminal session not found" });
|
|
5085
5505
|
return;
|
|
5086
5506
|
}
|
|
5087
|
-
let
|
|
5507
|
+
let data;
|
|
5508
|
+
try {
|
|
5509
|
+
data = await request.file();
|
|
5510
|
+
} catch (err) {
|
|
5511
|
+
reply.code(400).send({ error: err.message });
|
|
5512
|
+
return;
|
|
5513
|
+
}
|
|
5514
|
+
if (!data) {
|
|
5515
|
+
reply.code(400).send({ error: "no file field in the upload" });
|
|
5516
|
+
return;
|
|
5517
|
+
}
|
|
5518
|
+
const id = randomUUID4();
|
|
5088
5519
|
try {
|
|
5089
|
-
dir = await fsService.ensureDirWithinRoot(
|
|
5090
|
-
terminalSharedDir({ dataDir: config.dataDir, fsRoot: config.fsRoot, sessionId
|
|
5520
|
+
const dir = await fsService.ensureDirWithinRoot(
|
|
5521
|
+
`${terminalSharedDir({ dataDir: config.dataDir, fsRoot: config.fsRoot, sessionId })}/${id}`
|
|
5091
5522
|
);
|
|
5523
|
+
const written = await fsService.writeUploadedStream(dir, data.filename, data.file, () => !data.file.truncated);
|
|
5524
|
+
const now = Date.now();
|
|
5525
|
+
const media = attachmentMedia(data.filename, data.mimetype);
|
|
5526
|
+
const stored = {
|
|
5527
|
+
id,
|
|
5528
|
+
sessionId,
|
|
5529
|
+
direction: "sent",
|
|
5530
|
+
storage: "managed",
|
|
5531
|
+
name: data.filename,
|
|
5532
|
+
path: written.path,
|
|
5533
|
+
mimeType: media.mimeType,
|
|
5534
|
+
size: written.size,
|
|
5535
|
+
kind: media.kind,
|
|
5536
|
+
createdAt: now,
|
|
5537
|
+
updatedAt: now,
|
|
5538
|
+
expiresAt: now + TERMINAL_FILE_TTL_MS
|
|
5539
|
+
};
|
|
5540
|
+
store.putFile(stored);
|
|
5541
|
+
terminalManager.pushControl(sessionId, { t: "file", op: "added", file: publicSessionFile(stored) });
|
|
5542
|
+
reply.code(201).send({ path: stored.path, file: publicSessionFile(stored) });
|
|
5092
5543
|
} catch (err) {
|
|
5093
|
-
|
|
5094
|
-
|
|
5544
|
+
reply.code(data.file.truncated ? 413 : 400).send({
|
|
5545
|
+
error: data.file.truncated ? "file exceeds the upload size limit" : err.message
|
|
5546
|
+
});
|
|
5547
|
+
}
|
|
5548
|
+
});
|
|
5549
|
+
app.post("/sessions/:id/files/:fileId/derive", async (request, reply) => {
|
|
5550
|
+
const source = store.getFile(request.params.id, request.params.fileId);
|
|
5551
|
+
if (!source || source.hiddenAt !== void 0 || source.kind !== "image") {
|
|
5552
|
+
reply.code(404).send({ error: "source image not found" });
|
|
5553
|
+
return;
|
|
5554
|
+
}
|
|
5555
|
+
if (source.expiresAt <= Date.now()) {
|
|
5556
|
+
reply.code(410).send({ error: "source image has expired" });
|
|
5095
5557
|
return;
|
|
5096
5558
|
}
|
|
5097
|
-
await fsService.pruneOlderThan(dir, TERMINAL_FILE_TTL_MS).catch(() => 0);
|
|
5098
5559
|
let data;
|
|
5099
5560
|
try {
|
|
5100
5561
|
data = await request.file();
|
|
@@ -5102,28 +5563,134 @@ function createServer(config, deps = {}) {
|
|
|
5102
5563
|
reply.code(400).send({ error: err.message });
|
|
5103
5564
|
return;
|
|
5104
5565
|
}
|
|
5105
|
-
if (!data) {
|
|
5106
|
-
reply.code(400).send({ error: "
|
|
5566
|
+
if (!data || !data.mimetype.startsWith("image/")) {
|
|
5567
|
+
reply.code(400).send({ error: "an edited image is required" });
|
|
5107
5568
|
return;
|
|
5108
5569
|
}
|
|
5109
|
-
|
|
5570
|
+
const id = randomUUID4();
|
|
5110
5571
|
try {
|
|
5111
|
-
|
|
5572
|
+
const dir = await fsService.ensureDirWithinRoot(
|
|
5573
|
+
`${terminalSharedDir({ dataDir: config.dataDir, fsRoot: config.fsRoot, sessionId: source.sessionId })}/${id}`
|
|
5574
|
+
);
|
|
5575
|
+
const written = await fsService.writeUploadedStream(dir, data.filename, data.file, () => !data.file.truncated);
|
|
5576
|
+
const now = Date.now();
|
|
5577
|
+
const media = attachmentMedia(data.filename, data.mimetype);
|
|
5578
|
+
const stored = {
|
|
5579
|
+
id,
|
|
5580
|
+
sessionId: source.sessionId,
|
|
5581
|
+
direction: "sent",
|
|
5582
|
+
storage: "managed",
|
|
5583
|
+
name: data.filename,
|
|
5584
|
+
path: written.path,
|
|
5585
|
+
mimeType: media.mimeType,
|
|
5586
|
+
size: written.size,
|
|
5587
|
+
kind: "image",
|
|
5588
|
+
createdAt: now,
|
|
5589
|
+
updatedAt: now,
|
|
5590
|
+
expiresAt: now + TERMINAL_FILE_TTL_MS,
|
|
5591
|
+
derivedFromId: source.id
|
|
5592
|
+
};
|
|
5593
|
+
store.putFile(stored);
|
|
5594
|
+
terminalManager.pushControl(source.sessionId, { t: "file", op: "added", file: publicSessionFile(stored) });
|
|
5595
|
+
reply.code(201).send({ path: stored.path, file: publicSessionFile(stored) });
|
|
5112
5596
|
} catch (err) {
|
|
5113
|
-
reply.code(413).send({
|
|
5597
|
+
reply.code(data.file.truncated ? 413 : 400).send({
|
|
5598
|
+
error: data.file.truncated ? "file exceeds the upload size limit" : err.message
|
|
5599
|
+
});
|
|
5600
|
+
}
|
|
5601
|
+
});
|
|
5602
|
+
app.put("/sessions/:id/files/:fileId/content", async (request, reply) => {
|
|
5603
|
+
const file = store.getFile(request.params.id, request.params.fileId);
|
|
5604
|
+
if (!file || file.hiddenAt !== void 0) {
|
|
5605
|
+
reply.code(404).send({ error: "file not found" });
|
|
5114
5606
|
return;
|
|
5115
5607
|
}
|
|
5116
|
-
if (
|
|
5117
|
-
reply.code(
|
|
5608
|
+
if (file.expiresAt <= Date.now()) {
|
|
5609
|
+
reply.code(410).send({ error: "file has expired" });
|
|
5610
|
+
return;
|
|
5611
|
+
}
|
|
5612
|
+
if (file.direction !== "sent" || file.storage !== "managed" || file.kind !== "image") {
|
|
5613
|
+
reply.code(409).send({ error: "only managed sent images can be replaced" });
|
|
5118
5614
|
return;
|
|
5119
5615
|
}
|
|
5616
|
+
let data;
|
|
5120
5617
|
try {
|
|
5121
|
-
|
|
5122
|
-
reply.code(201).send({ path: written.path });
|
|
5618
|
+
data = await request.file();
|
|
5123
5619
|
} catch (err) {
|
|
5124
5620
|
reply.code(400).send({ error: err.message });
|
|
5621
|
+
return;
|
|
5622
|
+
}
|
|
5623
|
+
if (!data || !data.mimetype.startsWith("image/")) {
|
|
5624
|
+
reply.code(400).send({ error: "an image file is required" });
|
|
5625
|
+
return;
|
|
5626
|
+
}
|
|
5627
|
+
try {
|
|
5628
|
+
const written = await fsService.replaceFileStream(file.path, data.file, () => !data.file.truncated);
|
|
5629
|
+
const now = Date.now();
|
|
5630
|
+
const updated = {
|
|
5631
|
+
...file,
|
|
5632
|
+
mimeType: data.mimetype,
|
|
5633
|
+
size: written.size,
|
|
5634
|
+
updatedAt: now,
|
|
5635
|
+
expiresAt: now + TERMINAL_FILE_TTL_MS
|
|
5636
|
+
};
|
|
5637
|
+
store.putFile(updated);
|
|
5638
|
+
terminalManager.pushControl(file.sessionId, { t: "file", op: "updated", file: publicSessionFile(updated) });
|
|
5639
|
+
reply.send({ file: publicSessionFile(updated) });
|
|
5640
|
+
} catch (err) {
|
|
5641
|
+
reply.code(data.file.truncated ? 413 : 400).send({
|
|
5642
|
+
error: data.file.truncated ? "file exceeds the upload size limit" : err.message
|
|
5643
|
+
});
|
|
5125
5644
|
}
|
|
5126
5645
|
});
|
|
5646
|
+
app.patch(
|
|
5647
|
+
"/sessions/:id/files/:fileId",
|
|
5648
|
+
async (request, reply) => {
|
|
5649
|
+
const file = store.getFile(request.params.id, request.params.fileId);
|
|
5650
|
+
if (!file) {
|
|
5651
|
+
reply.code(404).send({ error: "file not found" });
|
|
5652
|
+
return;
|
|
5653
|
+
}
|
|
5654
|
+
store.setFileHidden(file.sessionId, file.id, request.body?.hidden === false ? void 0 : Date.now());
|
|
5655
|
+
terminalManager.pushControl(file.sessionId, {
|
|
5656
|
+
t: "file",
|
|
5657
|
+
op: request.body?.hidden === false ? "restored" : "hidden",
|
|
5658
|
+
id: file.id
|
|
5659
|
+
});
|
|
5660
|
+
reply.code(204).send();
|
|
5661
|
+
}
|
|
5662
|
+
);
|
|
5663
|
+
app.delete(
|
|
5664
|
+
"/sessions/:id/files/:fileId",
|
|
5665
|
+
async (request, reply) => {
|
|
5666
|
+
const file = store.getFile(request.params.id, request.params.fileId);
|
|
5667
|
+
if (!file) {
|
|
5668
|
+
reply.code(204).send();
|
|
5669
|
+
return;
|
|
5670
|
+
}
|
|
5671
|
+
if (request.query.content === "true") {
|
|
5672
|
+
if (file.direction !== "sent" || file.storage !== "managed") {
|
|
5673
|
+
reply.code(409).send({ error: "workspace files cannot be deleted by RoamCode" });
|
|
5674
|
+
return;
|
|
5675
|
+
}
|
|
5676
|
+
try {
|
|
5677
|
+
await fsService.removeManagedPath(file.path);
|
|
5678
|
+
} catch (err) {
|
|
5679
|
+
if (!(err instanceof FsError && err.code === "not-found")) {
|
|
5680
|
+
reply.code(err instanceof FsError && err.code === "forbidden" ? 403 : 500).send({
|
|
5681
|
+
error: "managed file could not be deleted"
|
|
5682
|
+
});
|
|
5683
|
+
return;
|
|
5684
|
+
}
|
|
5685
|
+
}
|
|
5686
|
+
store.deleteFile(file.sessionId, file.id);
|
|
5687
|
+
} else {
|
|
5688
|
+
store.setFileHidden(file.sessionId, file.id, Date.now());
|
|
5689
|
+
}
|
|
5690
|
+
terminalManager.pushControl(file.sessionId, { t: "file", op: "removed", id: file.id });
|
|
5691
|
+
reply.code(204).send();
|
|
5692
|
+
}
|
|
5693
|
+
);
|
|
5127
5694
|
if (deps.webDir) registerStatic(app, { webDir: deps.webDir });
|
|
5128
5695
|
app.addHook("onClose", async () => {
|
|
5129
5696
|
clearInterval(sharedSweepTimer);
|
|
@@ -5154,10 +5721,10 @@ function createServer(config, deps = {}) {
|
|
|
5154
5721
|
});
|
|
5155
5722
|
return { app, authGate, terminalManager, terminalAvailable };
|
|
5156
5723
|
}
|
|
5157
|
-
function contentDisposition(filename) {
|
|
5724
|
+
function contentDisposition(filename, disposition = "attachment") {
|
|
5158
5725
|
const ascii = filename.replace(/[\x00-\x1f\x7f"\\]/g, "_");
|
|
5159
5726
|
const encoded = encodeURIComponent(filename);
|
|
5160
|
-
return
|
|
5727
|
+
return `${disposition}; filename="${ascii}"; filename*=UTF-8''${encoded}`;
|
|
5161
5728
|
}
|
|
5162
5729
|
|
|
5163
5730
|
// src/server-config.ts
|
|
@@ -5540,7 +6107,7 @@ function createUsageService(opts) {
|
|
|
5540
6107
|
|
|
5541
6108
|
// src/claude-auth-service.ts
|
|
5542
6109
|
import { spawn as nodeSpawn2 } from "child_process";
|
|
5543
|
-
import { randomUUID as
|
|
6110
|
+
import { randomUUID as randomUUID5 } from "crypto";
|
|
5544
6111
|
function parseAuthStatus(stdout) {
|
|
5545
6112
|
try {
|
|
5546
6113
|
const o = JSON.parse(stdout);
|
|
@@ -5617,7 +6184,7 @@ var ClaudeAuthService = class {
|
|
|
5617
6184
|
reject(err instanceof Error ? err : new Error("failed to spawn claude"));
|
|
5618
6185
|
return;
|
|
5619
6186
|
}
|
|
5620
|
-
const id =
|
|
6187
|
+
const id = randomUUID5();
|
|
5621
6188
|
let settled = false;
|
|
5622
6189
|
let buf = "";
|
|
5623
6190
|
const onData = (d) => {
|
|
@@ -6979,7 +7546,7 @@ var CodexMetadataService = class {
|
|
|
6979
7546
|
|
|
6980
7547
|
// src/providers/claude-metadata-service.ts
|
|
6981
7548
|
import { execFile as nodeExecFile2, spawn as nodeSpawn3 } from "child_process";
|
|
6982
|
-
import { randomUUID as
|
|
7549
|
+
import { randomUUID as randomUUID6 } from "crypto";
|
|
6983
7550
|
import { StringDecoder } from "string_decoder";
|
|
6984
7551
|
var SAFE_VALUE = /^[A-Za-z0-9][A-Za-z0-9._:/\u005b\u005d-]*$/;
|
|
6985
7552
|
var MAX_MODELS = 64;
|
|
@@ -7187,7 +7754,7 @@ function createClaudeMetadataRunner(options) {
|
|
|
7187
7754
|
reject(metadataUnavailable2());
|
|
7188
7755
|
return;
|
|
7189
7756
|
}
|
|
7190
|
-
const requestId = `roamcode-models-${
|
|
7757
|
+
const requestId = `roamcode-models-${randomUUID6()}`;
|
|
7191
7758
|
const decoder = new StringDecoder("utf8");
|
|
7192
7759
|
let stdoutBuffer = "";
|
|
7193
7760
|
let outputBytes = 0;
|