@roamcode.ai/server 1.0.10 → 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/index.js
CHANGED
|
@@ -287,9 +287,12 @@ var AuthGate = class {
|
|
|
287
287
|
};
|
|
288
288
|
|
|
289
289
|
// src/fs-service.ts
|
|
290
|
-
import {
|
|
290
|
+
import { createWriteStream } from "fs";
|
|
291
|
+
import { readdir, readFile, stat, realpath, open, mkdir, unlink, rename, rmdir } from "fs/promises";
|
|
291
292
|
import { constants } from "fs";
|
|
292
293
|
import { resolve, join as join3, sep, basename } from "path";
|
|
294
|
+
import { randomUUID } from "crypto";
|
|
295
|
+
import { pipeline } from "stream/promises";
|
|
293
296
|
var FsError = class extends Error {
|
|
294
297
|
code;
|
|
295
298
|
constructor(code, message) {
|
|
@@ -381,6 +384,14 @@ var FsService = class {
|
|
|
381
384
|
const data = await readFile(real);
|
|
382
385
|
return { filename: basename(file), data };
|
|
383
386
|
}
|
|
387
|
+
/** Validate and describe a real in-root file without reading it into memory. */
|
|
388
|
+
async describeFile(target) {
|
|
389
|
+
const file = this.resolveWithinRoot(target);
|
|
390
|
+
const real = await this.realWithinRoot(file);
|
|
391
|
+
const info = await stat(real);
|
|
392
|
+
if (!info.isFile()) throw new FsError("not-found", `not a file: ${target}`);
|
|
393
|
+
return { path: real, filename: basename(file), size: info.size, mtimeMs: info.mtimeMs };
|
|
394
|
+
}
|
|
384
395
|
/**
|
|
385
396
|
* Validate that `target` is a real in-root file and describe it for an attachment frame WITHOUT
|
|
386
397
|
* reading its bytes. Reuses the same resolveWithinRoot + realpath defense as readFileForDownload so
|
|
@@ -416,6 +427,95 @@ var FsService = class {
|
|
|
416
427
|
}
|
|
417
428
|
return { path: dest };
|
|
418
429
|
}
|
|
430
|
+
/** Stream an upload to a same-directory partial and atomically publish it. The caller owns size limits;
|
|
431
|
+
* this method guarantees an interrupted upload never leaves a visible half-file. */
|
|
432
|
+
async writeUploadedStream(targetDir, filename, input, beforeCommit) {
|
|
433
|
+
if (!filename || filename.includes("/") || filename.includes("\\") || filename.includes(sep)) {
|
|
434
|
+
throw new Error(`invalid upload filename (no path separators allowed): ${filename}`);
|
|
435
|
+
}
|
|
436
|
+
const dir = this.resolveWithinRoot(targetDir);
|
|
437
|
+
await this.realWithinRoot(dir);
|
|
438
|
+
const dest = this.resolveWithinRoot(join3(dir, filename));
|
|
439
|
+
const partial = this.resolveWithinRoot(join3(dir, `.upload-${randomUUID()}.partial`));
|
|
440
|
+
try {
|
|
441
|
+
await pipeline(
|
|
442
|
+
input,
|
|
443
|
+
createWriteStream(partial, { flags: "wx", mode: 384 })
|
|
444
|
+
);
|
|
445
|
+
if (beforeCommit && !beforeCommit()) throw new Error("upload stream rejected before commit");
|
|
446
|
+
await rename(partial, dest);
|
|
447
|
+
const info = await stat(dest);
|
|
448
|
+
return { path: dest, size: info.size };
|
|
449
|
+
} catch (err) {
|
|
450
|
+
await unlink(partial).catch(() => void 0);
|
|
451
|
+
throw err;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
/** Atomically replace one existing in-root managed file with streamed bytes. */
|
|
455
|
+
async replaceFileStream(target, input, beforeCommit) {
|
|
456
|
+
const file = this.resolveWithinRoot(target);
|
|
457
|
+
const real = await this.realWithinRoot(file);
|
|
458
|
+
const parent = resolve(real, "..");
|
|
459
|
+
await this.realWithinRoot(parent);
|
|
460
|
+
const partial = join3(parent, `.edit-${randomUUID()}.partial`);
|
|
461
|
+
try {
|
|
462
|
+
await pipeline(
|
|
463
|
+
input,
|
|
464
|
+
createWriteStream(partial, { flags: "wx", mode: 384 })
|
|
465
|
+
);
|
|
466
|
+
if (beforeCommit && !beforeCommit()) throw new Error("replacement stream rejected before commit");
|
|
467
|
+
await rename(partial, real);
|
|
468
|
+
const info = await stat(real);
|
|
469
|
+
return { path: real, size: info.size };
|
|
470
|
+
} catch (err) {
|
|
471
|
+
await unlink(partial).catch(() => void 0);
|
|
472
|
+
throw err;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
/** Best-effort removal used only for server-owned managed attachment copies. */
|
|
476
|
+
async removeManagedPath(target) {
|
|
477
|
+
const file = this.resolveWithinRoot(target);
|
|
478
|
+
const real = await this.realWithinRoot(file);
|
|
479
|
+
await unlink(real);
|
|
480
|
+
await rmdir(resolve(real, "..")).catch(() => void 0);
|
|
481
|
+
}
|
|
482
|
+
/** Discover regular files in an app-owned directory for one-time legacy attachment backfill. Symlinks
|
|
483
|
+
* are ignored and traversal is deliberately shallow: old uploads are direct children, current uploads
|
|
484
|
+
* add exactly one id directory. */
|
|
485
|
+
async discoverManagedFiles(target, maxDepth = 1) {
|
|
486
|
+
let root;
|
|
487
|
+
try {
|
|
488
|
+
root = this.resolveWithinRoot(target);
|
|
489
|
+
await this.realWithinRoot(root);
|
|
490
|
+
} catch {
|
|
491
|
+
return [];
|
|
492
|
+
}
|
|
493
|
+
const found = [];
|
|
494
|
+
const walk = async (dir, depth) => {
|
|
495
|
+
let entries;
|
|
496
|
+
try {
|
|
497
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
498
|
+
} catch {
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
for (const entry of entries) {
|
|
502
|
+
if (entry.isSymbolicLink() || entry.name.endsWith(".partial")) continue;
|
|
503
|
+
const path = join3(dir, entry.name);
|
|
504
|
+
if (entry.isDirectory()) {
|
|
505
|
+
if (depth < maxDepth) await walk(path, depth + 1);
|
|
506
|
+
continue;
|
|
507
|
+
}
|
|
508
|
+
if (!entry.isFile()) continue;
|
|
509
|
+
try {
|
|
510
|
+
const info = await stat(path);
|
|
511
|
+
found.push({ path, filename: entry.name, size: info.size, mtimeMs: info.mtimeMs });
|
|
512
|
+
} catch {
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
};
|
|
516
|
+
await walk(root, 0);
|
|
517
|
+
return found;
|
|
518
|
+
}
|
|
419
519
|
/** Ensure a directory (confined to root) exists, creating parents as needed; returns its absolute path. */
|
|
420
520
|
async ensureDirWithinRoot(target) {
|
|
421
521
|
const dir = this.resolveWithinRoot(target);
|
|
@@ -505,30 +605,35 @@ var FsService = class {
|
|
|
505
605
|
let removed = 0;
|
|
506
606
|
for (const e of entries) {
|
|
507
607
|
if (!e.isDirectory()) continue;
|
|
508
|
-
removed += await this.pruneOlderThan(join3(dir, e.name), maxAgeMs, now);
|
|
608
|
+
removed += await this.pruneOlderThan(join3(dir, e.name), maxAgeMs, now, true);
|
|
509
609
|
}
|
|
510
610
|
return removed;
|
|
511
611
|
}
|
|
512
612
|
/** Delete top-level regular files in `target` (confined to root) whose mtime is older than `maxAgeMs`.
|
|
513
613
|
* Best-effort — returns how many were removed; a missing dir / unreadable entry is skipped, not thrown.
|
|
514
614
|
* Subdirectories are left untouched. Used to give terminal shared-files uploads a bounded lifetime. */
|
|
515
|
-
async pruneOlderThan(target, maxAgeMs, now = Date.now()) {
|
|
615
|
+
async pruneOlderThan(target, maxAgeMs, now = Date.now(), recursive = false) {
|
|
516
616
|
let dir;
|
|
517
617
|
try {
|
|
518
618
|
dir = this.resolveWithinRoot(target);
|
|
519
619
|
} catch {
|
|
520
620
|
return 0;
|
|
521
621
|
}
|
|
522
|
-
let
|
|
622
|
+
let entries;
|
|
523
623
|
try {
|
|
524
|
-
|
|
624
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
525
625
|
} catch {
|
|
526
626
|
return 0;
|
|
527
627
|
}
|
|
528
628
|
let removed = 0;
|
|
529
|
-
for (const
|
|
530
|
-
const p = join3(dir, name);
|
|
629
|
+
for (const entry of entries) {
|
|
630
|
+
const p = join3(dir, entry.name);
|
|
531
631
|
try {
|
|
632
|
+
if (recursive && entry.isDirectory()) {
|
|
633
|
+
removed += await this.pruneOlderThan(p, maxAgeMs, now, true);
|
|
634
|
+
await rmdir(p).catch(() => void 0);
|
|
635
|
+
continue;
|
|
636
|
+
}
|
|
532
637
|
const s = await stat(p);
|
|
533
638
|
if (s.isFile() && now - s.mtimeMs > maxAgeMs) {
|
|
534
639
|
await unlink(p);
|
|
@@ -745,6 +850,25 @@ function brandSessionStore(store) {
|
|
|
745
850
|
concreteSessionStores.add(store);
|
|
746
851
|
return store;
|
|
747
852
|
}
|
|
853
|
+
function sessionFileRowToStored(row) {
|
|
854
|
+
return {
|
|
855
|
+
id: row.id,
|
|
856
|
+
sessionId: row.session_id,
|
|
857
|
+
direction: row.direction,
|
|
858
|
+
storage: row.storage,
|
|
859
|
+
name: row.name,
|
|
860
|
+
path: row.path,
|
|
861
|
+
mimeType: row.mime_type,
|
|
862
|
+
size: row.size,
|
|
863
|
+
kind: row.kind,
|
|
864
|
+
...row.caption ? { caption: row.caption } : {},
|
|
865
|
+
createdAt: row.created_at,
|
|
866
|
+
updatedAt: row.updated_at,
|
|
867
|
+
expiresAt: row.expires_at,
|
|
868
|
+
...row.derived_from_id ? { derivedFromId: row.derived_from_id } : {},
|
|
869
|
+
...row.hidden_at !== null ? { hiddenAt: row.hidden_at } : {}
|
|
870
|
+
};
|
|
871
|
+
}
|
|
748
872
|
function parseSpawnArgs(raw) {
|
|
749
873
|
if (typeof raw !== "string" || raw.length === 0) return void 0;
|
|
750
874
|
try {
|
|
@@ -853,6 +977,9 @@ function cloneStoredSessionDefaults(value) {
|
|
|
853
977
|
updatedAt: value.updatedAt
|
|
854
978
|
};
|
|
855
979
|
}
|
|
980
|
+
function cloneStoredSessionFile(value) {
|
|
981
|
+
return { ...value };
|
|
982
|
+
}
|
|
856
983
|
function appSettingRowToSessionDefaults(row) {
|
|
857
984
|
if (!row) return void 0;
|
|
858
985
|
try {
|
|
@@ -867,6 +994,7 @@ function appSettingRowToSessionDefaults(row) {
|
|
|
867
994
|
}
|
|
868
995
|
function inMemoryStore() {
|
|
869
996
|
const map = /* @__PURE__ */ new Map();
|
|
997
|
+
const files = /* @__PURE__ */ new Map();
|
|
870
998
|
const provisionalProviderSessionIds = /* @__PURE__ */ new Map();
|
|
871
999
|
let sessionDefaults;
|
|
872
1000
|
const write = (s) => {
|
|
@@ -965,13 +1093,38 @@ function inMemoryStore() {
|
|
|
965
1093
|
};
|
|
966
1094
|
return cloneStoredSessionDefaults(sessionDefaults);
|
|
967
1095
|
},
|
|
1096
|
+
putFile: (file) => files.set(`${file.sessionId}:${file.id}`, cloneStoredSessionFile(file)),
|
|
1097
|
+
getFile: (sessionId, id) => {
|
|
1098
|
+
const value = files.get(`${sessionId}:${id}`);
|
|
1099
|
+
return value ? cloneStoredSessionFile(value) : void 0;
|
|
1100
|
+
},
|
|
1101
|
+
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),
|
|
1102
|
+
setFileHidden: (sessionId, id, hiddenAt) => {
|
|
1103
|
+
const file = files.get(`${sessionId}:${id}`);
|
|
1104
|
+
if (!file) return;
|
|
1105
|
+
if (hiddenAt === void 0) delete file.hiddenAt;
|
|
1106
|
+
else file.hiddenAt = hiddenAt;
|
|
1107
|
+
file.updatedAt = Date.now();
|
|
1108
|
+
},
|
|
1109
|
+
deleteFile: (sessionId, id) => void files.delete(`${sessionId}:${id}`),
|
|
1110
|
+
pruneFiles: (expiredBefore) => {
|
|
1111
|
+
const removed = [];
|
|
1112
|
+
for (const [key, file] of files) {
|
|
1113
|
+
if (file.expiresAt > expiredBefore) continue;
|
|
1114
|
+
removed.push(cloneStoredSessionFile(file));
|
|
1115
|
+
files.delete(key);
|
|
1116
|
+
}
|
|
1117
|
+
return removed;
|
|
1118
|
+
},
|
|
968
1119
|
delete: (id) => {
|
|
969
1120
|
provisionalProviderSessionIds.delete(id);
|
|
970
1121
|
map.delete(id);
|
|
1122
|
+
for (const [key, file] of files) if (file.sessionId === id) files.delete(key);
|
|
971
1123
|
},
|
|
972
1124
|
close: () => {
|
|
973
1125
|
provisionalProviderSessionIds.clear();
|
|
974
1126
|
map.clear();
|
|
1127
|
+
files.clear();
|
|
975
1128
|
sessionDefaults = void 0;
|
|
976
1129
|
},
|
|
977
1130
|
mode: "memory-fallback"
|
|
@@ -1049,6 +1202,28 @@ function openSessionStore(opts) {
|
|
|
1049
1202
|
revision INTEGER NOT NULL CHECK (revision > 0),
|
|
1050
1203
|
updated_at INTEGER NOT NULL
|
|
1051
1204
|
);
|
|
1205
|
+
|
|
1206
|
+
CREATE TABLE IF NOT EXISTS session_files (
|
|
1207
|
+
id TEXT NOT NULL,
|
|
1208
|
+
session_id TEXT NOT NULL,
|
|
1209
|
+
direction TEXT NOT NULL CHECK (direction IN ('sent', 'received')),
|
|
1210
|
+
storage TEXT NOT NULL CHECK (storage IN ('managed', 'workspace')),
|
|
1211
|
+
name TEXT NOT NULL,
|
|
1212
|
+
path TEXT NOT NULL,
|
|
1213
|
+
mime_type TEXT NOT NULL,
|
|
1214
|
+
size INTEGER NOT NULL,
|
|
1215
|
+
kind TEXT NOT NULL CHECK (kind IN ('image', 'pdf', 'text', 'binary')),
|
|
1216
|
+
caption TEXT,
|
|
1217
|
+
created_at INTEGER NOT NULL,
|
|
1218
|
+
updated_at INTEGER NOT NULL,
|
|
1219
|
+
expires_at INTEGER NOT NULL,
|
|
1220
|
+
derived_from_id TEXT,
|
|
1221
|
+
hidden_at INTEGER,
|
|
1222
|
+
PRIMARY KEY (session_id, id)
|
|
1223
|
+
);
|
|
1224
|
+
CREATE INDEX IF NOT EXISTS session_files_session_created_idx
|
|
1225
|
+
ON session_files(session_id, created_at DESC);
|
|
1226
|
+
CREATE INDEX IF NOT EXISTS session_files_expiry_idx ON session_files(expires_at);
|
|
1052
1227
|
`);
|
|
1053
1228
|
try {
|
|
1054
1229
|
db.exec("ALTER TABLE provider_sessions ADD COLUMN provisional_provider_session_id TEXT");
|
|
@@ -1128,6 +1303,33 @@ function openSessionStore(opts) {
|
|
|
1128
1303
|
ON CONFLICT(key) DO UPDATE SET
|
|
1129
1304
|
value_json=excluded.value_json, revision=excluded.revision, updated_at=excluded.updated_at
|
|
1130
1305
|
`);
|
|
1306
|
+
const filePutStmt = db.prepare(`
|
|
1307
|
+
INSERT INTO session_files (
|
|
1308
|
+
id, session_id, direction, storage, name, path, mime_type, size, kind, caption,
|
|
1309
|
+
created_at, updated_at, expires_at, derived_from_id, hidden_at
|
|
1310
|
+
) VALUES (
|
|
1311
|
+
@id, @session_id, @direction, @storage, @name, @path, @mime_type, @size, @kind, @caption,
|
|
1312
|
+
@created_at, @updated_at, @expires_at, @derived_from_id, @hidden_at
|
|
1313
|
+
) ON CONFLICT(session_id, id) DO UPDATE SET
|
|
1314
|
+
direction=excluded.direction, storage=excluded.storage, name=excluded.name, path=excluded.path,
|
|
1315
|
+
mime_type=excluded.mime_type, size=excluded.size, kind=excluded.kind, caption=excluded.caption,
|
|
1316
|
+
updated_at=excluded.updated_at, expires_at=excluded.expires_at,
|
|
1317
|
+
derived_from_id=excluded.derived_from_id, hidden_at=excluded.hidden_at
|
|
1318
|
+
`);
|
|
1319
|
+
const fileGetStmt = db.prepare("SELECT * FROM session_files WHERE session_id = ? AND id = ?");
|
|
1320
|
+
const fileListStmt = db.prepare(
|
|
1321
|
+
"SELECT * FROM session_files WHERE session_id = ? AND hidden_at IS NULL ORDER BY created_at DESC, id DESC"
|
|
1322
|
+
);
|
|
1323
|
+
const fileListAllStmt = db.prepare(
|
|
1324
|
+
"SELECT * FROM session_files WHERE session_id = ? ORDER BY created_at DESC, id DESC"
|
|
1325
|
+
);
|
|
1326
|
+
const fileHideStmt = db.prepare(
|
|
1327
|
+
"UPDATE session_files SET hidden_at = ?, updated_at = ? WHERE session_id = ? AND id = ?"
|
|
1328
|
+
);
|
|
1329
|
+
const fileDeleteStmt = db.prepare("DELETE FROM session_files WHERE session_id = ? AND id = ?");
|
|
1330
|
+
const filesForSessionDeleteStmt = db.prepare("DELETE FROM session_files WHERE session_id = ?");
|
|
1331
|
+
const expiredFilesStmt = db.prepare("SELECT * FROM session_files WHERE expires_at <= ?");
|
|
1332
|
+
const pruneFilesStmt = db.prepare("DELETE FROM session_files WHERE expires_at <= ?");
|
|
1131
1333
|
const ownerOf = (id) => {
|
|
1132
1334
|
const hasLegacy = legacyGetStmt.get(id) !== void 0;
|
|
1133
1335
|
const hasProvider = providerGetStmt.get(id) !== void 0;
|
|
@@ -1304,10 +1506,45 @@ function openSessionStore(opts) {
|
|
|
1304
1506
|
},
|
|
1305
1507
|
getSessionDefaults: () => appSettingRowToSessionDefaults(sessionDefaultsGetStmt.get()),
|
|
1306
1508
|
putSessionDefaults: (defaults, expectedRevision, updatedAt) => putSessionDefaultsAtomically.immediate(normalizeSessionDefaults(defaults), expectedRevision, updatedAt),
|
|
1509
|
+
putFile: (file) => {
|
|
1510
|
+
filePutStmt.run({
|
|
1511
|
+
id: file.id,
|
|
1512
|
+
session_id: file.sessionId,
|
|
1513
|
+
direction: file.direction,
|
|
1514
|
+
storage: file.storage,
|
|
1515
|
+
name: file.name,
|
|
1516
|
+
path: file.path,
|
|
1517
|
+
mime_type: file.mimeType,
|
|
1518
|
+
size: file.size,
|
|
1519
|
+
kind: file.kind,
|
|
1520
|
+
caption: file.caption ?? null,
|
|
1521
|
+
created_at: file.createdAt,
|
|
1522
|
+
updated_at: file.updatedAt,
|
|
1523
|
+
expires_at: file.expiresAt,
|
|
1524
|
+
derived_from_id: file.derivedFromId ?? null,
|
|
1525
|
+
hidden_at: file.hiddenAt ?? null
|
|
1526
|
+
});
|
|
1527
|
+
},
|
|
1528
|
+
getFile: (sessionId, id) => {
|
|
1529
|
+
const row = fileGetStmt.get(sessionId, id);
|
|
1530
|
+
return row ? sessionFileRowToStored(row) : void 0;
|
|
1531
|
+
},
|
|
1532
|
+
listFiles: (sessionId, includeHidden = false) => {
|
|
1533
|
+
const rows = includeHidden ? fileListAllStmt.all(sessionId) : fileListStmt.all(sessionId);
|
|
1534
|
+
return rows.map(sessionFileRowToStored);
|
|
1535
|
+
},
|
|
1536
|
+
setFileHidden: (sessionId, id, hiddenAt) => fileHideStmt.run(hiddenAt ?? null, Date.now(), sessionId, id),
|
|
1537
|
+
deleteFile: (sessionId, id) => void fileDeleteStmt.run(sessionId, id),
|
|
1538
|
+
pruneFiles: (expiredBefore) => {
|
|
1539
|
+
const rows = expiredFilesStmt.all(expiredBefore);
|
|
1540
|
+
pruneFilesStmt.run(expiredBefore);
|
|
1541
|
+
return rows.map(sessionFileRowToStored);
|
|
1542
|
+
},
|
|
1307
1543
|
delete: (id) => {
|
|
1308
1544
|
const owner = ownerOf(id);
|
|
1309
1545
|
if (owner === "claude") legacyDeleteStmt.run(id);
|
|
1310
1546
|
else if (owner === "codex") providerDeleteStmt.run(id);
|
|
1547
|
+
filesForSessionDeleteStmt.run(id);
|
|
1311
1548
|
},
|
|
1312
1549
|
close: () => db.close(),
|
|
1313
1550
|
mode: "sqlite"
|
|
@@ -1394,8 +1631,9 @@ function resolveVapidKeys(opts) {
|
|
|
1394
1631
|
}
|
|
1395
1632
|
|
|
1396
1633
|
// src/transport.ts
|
|
1397
|
-
import { randomUUID as
|
|
1634
|
+
import { randomUUID as randomUUID4 } from "crypto";
|
|
1398
1635
|
import { basename as pathBasename } from "path";
|
|
1636
|
+
import { createReadStream } from "fs";
|
|
1399
1637
|
import Fastify from "fastify";
|
|
1400
1638
|
import websocket from "@fastify/websocket";
|
|
1401
1639
|
import multipart from "@fastify/multipart";
|
|
@@ -1548,7 +1786,7 @@ function terminalSharedDir(opts) {
|
|
|
1548
1786
|
|
|
1549
1787
|
// src/updater.ts
|
|
1550
1788
|
import { spawn as nodeSpawn } from "child_process";
|
|
1551
|
-
import { randomUUID as
|
|
1789
|
+
import { randomUUID as randomUUID3 } from "crypto";
|
|
1552
1790
|
import {
|
|
1553
1791
|
chmodSync as nodeChmodSync,
|
|
1554
1792
|
existsSync as nodeExistsSync,
|
|
@@ -1562,7 +1800,7 @@ import { fileURLToPath } from "url";
|
|
|
1562
1800
|
|
|
1563
1801
|
// src/managed-runtime.ts
|
|
1564
1802
|
import { spawn } from "child_process";
|
|
1565
|
-
import { randomUUID } from "crypto";
|
|
1803
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
1566
1804
|
import {
|
|
1567
1805
|
chmodSync as chmodSync4,
|
|
1568
1806
|
existsSync as existsSync4,
|
|
@@ -1839,7 +2077,7 @@ function compareVersions(a, b) {
|
|
|
1839
2077
|
}
|
|
1840
2078
|
function atomicWrite(path, value, mode = 384) {
|
|
1841
2079
|
mkdirSync3(dirname2(path), { recursive: true, mode: 448 });
|
|
1842
|
-
const temp = `${path}.${process.pid}.${
|
|
2080
|
+
const temp = `${path}.${process.pid}.${randomUUID2()}.tmp`;
|
|
1843
2081
|
writeFileSync4(temp, value, { mode });
|
|
1844
2082
|
chmodSync4(temp, mode);
|
|
1845
2083
|
renameSync(temp, path);
|
|
@@ -1983,7 +2221,7 @@ async function npmIntegrity(npmCommand, packageName, version, nodePath, log) {
|
|
|
1983
2221
|
return parsed;
|
|
1984
2222
|
}
|
|
1985
2223
|
async function smokeServer(serverEntry, dataDir, nodePath, log) {
|
|
1986
|
-
const smokeDir = join7(tmpdir(), `roamcode-smoke-${process.pid}-${
|
|
2224
|
+
const smokeDir = join7(tmpdir(), `roamcode-smoke-${process.pid}-${randomUUID2()}`);
|
|
1987
2225
|
mkdirSync3(smokeDir, { recursive: true, mode: 448 });
|
|
1988
2226
|
let output = "";
|
|
1989
2227
|
const child = spawn(nodePath, [serverEntry], {
|
|
@@ -1991,7 +2229,7 @@ async function smokeServer(serverEntry, dataDir, nodePath, log) {
|
|
|
1991
2229
|
...process.env,
|
|
1992
2230
|
PORT: "0",
|
|
1993
2231
|
BIND_ADDRESS: "127.0.0.1",
|
|
1994
|
-
ACCESS_TOKEN: `rc-smoke-${
|
|
2232
|
+
ACCESS_TOKEN: `rc-smoke-${randomUUID2()}`,
|
|
1995
2233
|
ROAMCODE_DATA_DIR: smokeDir,
|
|
1996
2234
|
RC_TMUX_SOCKET: `rc-smoke-${process.pid}`,
|
|
1997
2235
|
ROAMCODE_INSTALL_ROOT: ""
|
|
@@ -2031,7 +2269,7 @@ async function smokeServer(serverEntry, dataDir, nodePath, log) {
|
|
|
2031
2269
|
}
|
|
2032
2270
|
}
|
|
2033
2271
|
function replaceSymlink(link, target) {
|
|
2034
|
-
const temp = `${link}.${process.pid}.${
|
|
2272
|
+
const temp = `${link}.${process.pid}.${randomUUID2()}.new`;
|
|
2035
2273
|
try {
|
|
2036
2274
|
unlinkSync(temp);
|
|
2037
2275
|
} catch {
|
|
@@ -2047,7 +2285,7 @@ function trimLog(log) {
|
|
|
2047
2285
|
async function installManagedRelease(opts) {
|
|
2048
2286
|
if (!isStableVersion(opts.version)) throw new Error(`invalid release version: ${opts.version}`);
|
|
2049
2287
|
const now = opts.now ?? Date.now;
|
|
2050
|
-
const operationId = opts.operationId ??
|
|
2288
|
+
const operationId = opts.operationId ?? randomUUID2();
|
|
2051
2289
|
const nodePath = opts.nodePath ?? process.execPath;
|
|
2052
2290
|
const npmCommand = opts.npmCommand ?? process.env.npm_execpath ?? "npm";
|
|
2053
2291
|
const expectedIntegrities = opts.expectedIntegrities ?? (opts.expectedIntegrity ? { roamcode: opts.expectedIntegrity } : {});
|
|
@@ -2056,7 +2294,7 @@ async function installManagedRelease(opts) {
|
|
|
2056
2294
|
mkdirSync3(paths.releases, { recursive: true, mode: 448 });
|
|
2057
2295
|
mkdirSync3(paths.staging, { recursive: true, mode: 448 });
|
|
2058
2296
|
const release = join7(paths.releases, opts.version);
|
|
2059
|
-
const stage = join7(paths.staging, `${opts.version}-${process.pid}-${
|
|
2297
|
+
const stage = join7(paths.staging, `${opts.version}-${process.pid}-${randomUUID2()}`);
|
|
2060
2298
|
let logText = "";
|
|
2061
2299
|
const log = (line) => {
|
|
2062
2300
|
logText += line.endsWith("\n") ? line : `${line}
|
|
@@ -2191,7 +2429,7 @@ async function installManagedRelease(opts) {
|
|
|
2191
2429
|
}
|
|
2192
2430
|
|
|
2193
2431
|
// src/updater.ts
|
|
2194
|
-
var RUNNING_VERSION = "1.0.
|
|
2432
|
+
var RUNNING_VERSION = "1.0.11" ? "1.0.11".replace(/^v/, "") : packageVersion();
|
|
2195
2433
|
var RUNNING_BUILD = RUNNING_VERSION;
|
|
2196
2434
|
var RELEASES_API = "https://api.github.com/repos/burakgon/roamcode/releases?per_page=100";
|
|
2197
2435
|
var RELEASE_MANIFEST_ASSET = "roamcode-release.json";
|
|
@@ -2492,7 +2730,7 @@ var Updater = class {
|
|
|
2492
2730
|
const path = join8(this.dataDir, "update-status.json");
|
|
2493
2731
|
const value = JSON.stringify(status, null, 2) + "\n";
|
|
2494
2732
|
if (this.fs.renameSync) {
|
|
2495
|
-
const temp = `${path}.${process.pid}.${
|
|
2733
|
+
const temp = `${path}.${process.pid}.${randomUUID3()}.tmp`;
|
|
2496
2734
|
this.fs.writeFileSync(temp, value, 384);
|
|
2497
2735
|
this.fs.renameSync(temp, path);
|
|
2498
2736
|
} else {
|
|
@@ -2555,7 +2793,7 @@ var Updater = class {
|
|
|
2555
2793
|
return { started: false, reason: error instanceof Error ? error.message : String(error) };
|
|
2556
2794
|
}
|
|
2557
2795
|
}
|
|
2558
|
-
const operationId =
|
|
2796
|
+
const operationId = randomUUID3();
|
|
2559
2797
|
const status = {
|
|
2560
2798
|
operationId,
|
|
2561
2799
|
state: "starting",
|
|
@@ -4617,6 +4855,74 @@ var MAX_PENDING_TERMINAL_INPUT_FRAMES = 64;
|
|
|
4617
4855
|
var MAX_PENDING_TERMINAL_INPUT_BYTES = 1e6;
|
|
4618
4856
|
var MAX_TERMINAL_WS_BUFFER = 16e6;
|
|
4619
4857
|
var TERMINAL_WS_PING_MS = 25e3;
|
|
4858
|
+
var TEXT_FILE_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
4859
|
+
"txt",
|
|
4860
|
+
"md",
|
|
4861
|
+
"markdown",
|
|
4862
|
+
"json",
|
|
4863
|
+
"jsonl",
|
|
4864
|
+
"yaml",
|
|
4865
|
+
"yml",
|
|
4866
|
+
"toml",
|
|
4867
|
+
"xml",
|
|
4868
|
+
"csv",
|
|
4869
|
+
"tsv",
|
|
4870
|
+
"log",
|
|
4871
|
+
"js",
|
|
4872
|
+
"jsx",
|
|
4873
|
+
"ts",
|
|
4874
|
+
"tsx",
|
|
4875
|
+
"css",
|
|
4876
|
+
"scss",
|
|
4877
|
+
"html",
|
|
4878
|
+
"htm",
|
|
4879
|
+
"sql",
|
|
4880
|
+
"sh",
|
|
4881
|
+
"bash",
|
|
4882
|
+
"zsh",
|
|
4883
|
+
"py",
|
|
4884
|
+
"rb",
|
|
4885
|
+
"go",
|
|
4886
|
+
"rs",
|
|
4887
|
+
"java",
|
|
4888
|
+
"kt",
|
|
4889
|
+
"swift",
|
|
4890
|
+
"c",
|
|
4891
|
+
"h",
|
|
4892
|
+
"cpp",
|
|
4893
|
+
"hpp",
|
|
4894
|
+
"env",
|
|
4895
|
+
"ini",
|
|
4896
|
+
"conf"
|
|
4897
|
+
]);
|
|
4898
|
+
var IMAGE_FILE_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
4899
|
+
"png",
|
|
4900
|
+
"jpg",
|
|
4901
|
+
"jpeg",
|
|
4902
|
+
"gif",
|
|
4903
|
+
"webp",
|
|
4904
|
+
"svg",
|
|
4905
|
+
"bmp",
|
|
4906
|
+
"avif",
|
|
4907
|
+
"apng",
|
|
4908
|
+
"heic",
|
|
4909
|
+
"heif"
|
|
4910
|
+
]);
|
|
4911
|
+
function attachmentMedia(filename, declared = "application/octet-stream") {
|
|
4912
|
+
const ext = filename.includes(".") ? filename.slice(filename.lastIndexOf(".") + 1).toLowerCase() : "";
|
|
4913
|
+
if (declared.startsWith("image/") || IMAGE_FILE_EXTENSIONS.has(ext)) {
|
|
4914
|
+
const inferred = ext === "jpg" || ext === "jpeg" ? "image/jpeg" : ext === "svg" ? "image/svg+xml" : ext ? `image/${ext}` : declared;
|
|
4915
|
+
return { mimeType: declared.startsWith("image/") ? declared : inferred, kind: "image" };
|
|
4916
|
+
}
|
|
4917
|
+
if (declared === "application/pdf" || ext === "pdf") return { mimeType: "application/pdf", kind: "pdf" };
|
|
4918
|
+
if (declared.startsWith("text/") || TEXT_FILE_EXTENSIONS.has(ext)) {
|
|
4919
|
+
return { mimeType: "text/plain; charset=utf-8", kind: "text" };
|
|
4920
|
+
}
|
|
4921
|
+
return { mimeType: declared || "application/octet-stream", kind: "binary" };
|
|
4922
|
+
}
|
|
4923
|
+
function publicSessionFile(file, available = true) {
|
|
4924
|
+
return { ...file, isImage: file.kind === "image", available };
|
|
4925
|
+
}
|
|
4620
4926
|
function isDisallowedPushHost(hostname) {
|
|
4621
4927
|
const host = hostname.toLowerCase().replace(/^\[|\]$/g, "");
|
|
4622
4928
|
if (host === "localhost" || host.endsWith(".localhost")) return true;
|
|
@@ -4672,8 +4978,44 @@ function createServer(config, deps = {}) {
|
|
|
4672
4978
|
});
|
|
4673
4979
|
const fsService = new FsService({ root: config.fsRoot });
|
|
4674
4980
|
const terminalSharedRoot = terminalSharedBase({ dataDir: config.dataDir, fsRoot: config.fsRoot });
|
|
4981
|
+
const backfilledFileSessions = /* @__PURE__ */ new Set();
|
|
4982
|
+
const backfillManagedFiles = async (sessionId) => {
|
|
4983
|
+
if (backfilledFileSessions.has(sessionId)) return;
|
|
4984
|
+
backfilledFileSessions.add(sessionId);
|
|
4985
|
+
const sessionDir = terminalSharedDir({ dataDir: config.dataDir, fsRoot: config.fsRoot, sessionId });
|
|
4986
|
+
const discovered = await fsService.discoverManagedFiles(sessionDir);
|
|
4987
|
+
const knownPaths = new Set(store.listFiles(sessionId, true).map((file) => file.path));
|
|
4988
|
+
for (const file of discovered) {
|
|
4989
|
+
if (knownPaths.has(file.path)) continue;
|
|
4990
|
+
const expiresAt = file.mtimeMs + TERMINAL_FILE_TTL_MS;
|
|
4991
|
+
if (expiresAt <= Date.now()) continue;
|
|
4992
|
+
const now = Date.now();
|
|
4993
|
+
const media = attachmentMedia(file.filename);
|
|
4994
|
+
store.putFile({
|
|
4995
|
+
id: randomUUID4(),
|
|
4996
|
+
sessionId,
|
|
4997
|
+
direction: "sent",
|
|
4998
|
+
storage: "managed",
|
|
4999
|
+
name: file.filename,
|
|
5000
|
+
path: file.path,
|
|
5001
|
+
mimeType: media.mimeType,
|
|
5002
|
+
size: file.size,
|
|
5003
|
+
kind: media.kind,
|
|
5004
|
+
createdAt: file.mtimeMs,
|
|
5005
|
+
updatedAt: now,
|
|
5006
|
+
expiresAt
|
|
5007
|
+
});
|
|
5008
|
+
knownPaths.add(file.path);
|
|
5009
|
+
}
|
|
5010
|
+
};
|
|
4675
5011
|
const sweepSharedFiles = () => {
|
|
4676
|
-
void
|
|
5012
|
+
void (async () => {
|
|
5013
|
+
const expired = typeof store.pruneFiles === "function" ? store.pruneFiles(Date.now()) : [];
|
|
5014
|
+
await Promise.all(
|
|
5015
|
+
expired.filter((file) => file.storage === "managed").map((file) => fsService.removeManagedPath(file.path).catch(() => void 0))
|
|
5016
|
+
);
|
|
5017
|
+
await fsService.pruneChildDirsOlderThan(terminalSharedRoot, TERMINAL_FILE_TTL_MS).catch(() => 0);
|
|
5018
|
+
})();
|
|
4677
5019
|
};
|
|
4678
5020
|
sweepSharedFiles();
|
|
4679
5021
|
const sharedSweepTimer = setInterval(sweepSharedFiles, TERMINAL_SWEEP_INTERVAL_MS);
|
|
@@ -4918,7 +5260,7 @@ function createServer(config, deps = {}) {
|
|
|
4918
5260
|
reply.code(400).send({ error: `cwd does not exist: ${body.cwd}` });
|
|
4919
5261
|
return;
|
|
4920
5262
|
}
|
|
4921
|
-
const id =
|
|
5263
|
+
const id = randomUUID4();
|
|
4922
5264
|
const rawOptions = body.options ?? (provider === "claude" ? {
|
|
4923
5265
|
...typeof body.model === "string" ? { model: body.model } : {},
|
|
4924
5266
|
...typeof body.effort === "string" ? { effort: body.effort } : {},
|
|
@@ -5142,8 +5484,10 @@ function createServer(config, deps = {}) {
|
|
|
5142
5484
|
}
|
|
5143
5485
|
const caption = typeof body.caption === "string" ? body.caption : void 0;
|
|
5144
5486
|
let described;
|
|
5487
|
+
let fileInfo;
|
|
5145
5488
|
try {
|
|
5146
5489
|
described = await fsService.describeForAttachment(body.path);
|
|
5490
|
+
fileInfo = await fsService.describeFile(body.path);
|
|
5147
5491
|
} catch (err) {
|
|
5148
5492
|
if (err instanceof FsError) {
|
|
5149
5493
|
reply.code(err.code === "forbidden" ? 403 : 404).send({ error: err.message });
|
|
@@ -5153,14 +5497,28 @@ function createServer(config, deps = {}) {
|
|
|
5153
5497
|
return;
|
|
5154
5498
|
}
|
|
5155
5499
|
const isImage = body.kind === "image" ? true : body.kind === "file" ? false : described.isImage;
|
|
5156
|
-
const id =
|
|
5157
|
-
|
|
5158
|
-
|
|
5500
|
+
const id = randomUUID4();
|
|
5501
|
+
const now = Date.now();
|
|
5502
|
+
const media = attachmentMedia(described.name);
|
|
5503
|
+
const stored = {
|
|
5159
5504
|
id,
|
|
5160
|
-
|
|
5505
|
+
sessionId,
|
|
5506
|
+
direction: "received",
|
|
5507
|
+
storage: "workspace",
|
|
5161
5508
|
name: described.name,
|
|
5162
|
-
|
|
5163
|
-
|
|
5509
|
+
path: body.path,
|
|
5510
|
+
mimeType: media.mimeType,
|
|
5511
|
+
size: fileInfo.size,
|
|
5512
|
+
kind: isImage ? "image" : media.kind,
|
|
5513
|
+
...caption ? { caption } : {},
|
|
5514
|
+
createdAt: now,
|
|
5515
|
+
updatedAt: now,
|
|
5516
|
+
expiresAt: now + TERMINAL_FILE_TTL_MS
|
|
5517
|
+
};
|
|
5518
|
+
store.putFile(stored);
|
|
5519
|
+
terminalManager.pushControl(sessionId, {
|
|
5520
|
+
t: "attach",
|
|
5521
|
+
...publicSessionFile(stored)
|
|
5164
5522
|
});
|
|
5165
5523
|
dispatchPush({ kind: "file", sessionId, detail: described.name });
|
|
5166
5524
|
reply.code(200).send({ ok: true, id });
|
|
@@ -5691,23 +6049,126 @@ function createServer(config, deps = {}) {
|
|
|
5691
6049
|
reply.code(400).send({ error: err.message });
|
|
5692
6050
|
}
|
|
5693
6051
|
});
|
|
6052
|
+
app.get("/sessions/:id/files", async (request, reply) => {
|
|
6053
|
+
if (!terminalManager.get(request.params.id)) {
|
|
6054
|
+
reply.code(404).send({ error: "terminal session not found" });
|
|
6055
|
+
return;
|
|
6056
|
+
}
|
|
6057
|
+
await backfillManagedFiles(request.params.id);
|
|
6058
|
+
const files = await Promise.all(
|
|
6059
|
+
store.listFiles(request.params.id).map(async (file) => {
|
|
6060
|
+
const available = file.expiresAt > Date.now() && await fsService.describeFile(file.path).then(() => true).catch(() => false);
|
|
6061
|
+
return publicSessionFile(file, available);
|
|
6062
|
+
})
|
|
6063
|
+
);
|
|
6064
|
+
return {
|
|
6065
|
+
files,
|
|
6066
|
+
policy: {
|
|
6067
|
+
maxUploadBytes: config.maxUploadBytes,
|
|
6068
|
+
retentionMs: TERMINAL_FILE_TTL_MS,
|
|
6069
|
+
durable: store.mode === "sqlite"
|
|
6070
|
+
}
|
|
6071
|
+
};
|
|
6072
|
+
});
|
|
6073
|
+
app.get(
|
|
6074
|
+
"/sessions/:id/files/:fileId/content",
|
|
6075
|
+
async (request, reply) => {
|
|
6076
|
+
const file = store.getFile(request.params.id, request.params.fileId);
|
|
6077
|
+
if (!file || file.hiddenAt !== void 0) {
|
|
6078
|
+
reply.code(404).send({ error: "file not found" });
|
|
6079
|
+
return;
|
|
6080
|
+
}
|
|
6081
|
+
if (file.expiresAt <= Date.now()) {
|
|
6082
|
+
reply.code(410).send({ error: "file has expired" });
|
|
6083
|
+
return;
|
|
6084
|
+
}
|
|
6085
|
+
try {
|
|
6086
|
+
const info = await fsService.describeFile(file.path);
|
|
6087
|
+
const disposition = request.query.disposition === "inline" ? "inline" : "attachment";
|
|
6088
|
+
reply.header("accept-ranges", "bytes").header("content-type", file.mimeType).header("x-content-type-options", "nosniff").header(
|
|
6089
|
+
"content-security-policy",
|
|
6090
|
+
"sandbox; default-src 'none'; img-src 'self' data: blob:; style-src 'unsafe-inline'"
|
|
6091
|
+
).header("content-disposition", contentDisposition(info.filename, disposition));
|
|
6092
|
+
const range = request.headers.range;
|
|
6093
|
+
if (range) {
|
|
6094
|
+
const match = /^bytes=(\d+)-(\d*)$/.exec(range);
|
|
6095
|
+
if (!match) {
|
|
6096
|
+
reply.code(416).header("content-range", `bytes */${info.size}`).send();
|
|
6097
|
+
return;
|
|
6098
|
+
}
|
|
6099
|
+
const start = Number(match[1]);
|
|
6100
|
+
const end = match[2] ? Math.min(Number(match[2]), info.size - 1) : info.size - 1;
|
|
6101
|
+
if (!Number.isSafeInteger(start) || start < 0 || start > end || start >= info.size) {
|
|
6102
|
+
reply.code(416).header("content-range", `bytes */${info.size}`).send();
|
|
6103
|
+
return;
|
|
6104
|
+
}
|
|
6105
|
+
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 }));
|
|
6106
|
+
}
|
|
6107
|
+
return reply.header("content-length", String(info.size)).send(createReadStream(info.path));
|
|
6108
|
+
} catch (err) {
|
|
6109
|
+
const code = err instanceof FsError && err.code === "forbidden" ? 403 : 404;
|
|
6110
|
+
reply.code(code).send({ error: err.message });
|
|
6111
|
+
}
|
|
6112
|
+
}
|
|
6113
|
+
);
|
|
5694
6114
|
app.post("/sessions/:id/upload", async (request, reply) => {
|
|
5695
|
-
const
|
|
5696
|
-
if (!
|
|
6115
|
+
const sessionId = request.params.id;
|
|
6116
|
+
if (!terminalManager.get(sessionId)) {
|
|
5697
6117
|
reply.code(404).send({ error: "terminal session not found" });
|
|
5698
6118
|
return;
|
|
5699
6119
|
}
|
|
5700
|
-
let
|
|
6120
|
+
let data;
|
|
6121
|
+
try {
|
|
6122
|
+
data = await request.file();
|
|
6123
|
+
} catch (err) {
|
|
6124
|
+
reply.code(400).send({ error: err.message });
|
|
6125
|
+
return;
|
|
6126
|
+
}
|
|
6127
|
+
if (!data) {
|
|
6128
|
+
reply.code(400).send({ error: "no file field in the upload" });
|
|
6129
|
+
return;
|
|
6130
|
+
}
|
|
6131
|
+
const id = randomUUID4();
|
|
5701
6132
|
try {
|
|
5702
|
-
dir = await fsService.ensureDirWithinRoot(
|
|
5703
|
-
terminalSharedDir({ dataDir: config.dataDir, fsRoot: config.fsRoot, sessionId
|
|
6133
|
+
const dir = await fsService.ensureDirWithinRoot(
|
|
6134
|
+
`${terminalSharedDir({ dataDir: config.dataDir, fsRoot: config.fsRoot, sessionId })}/${id}`
|
|
5704
6135
|
);
|
|
6136
|
+
const written = await fsService.writeUploadedStream(dir, data.filename, data.file, () => !data.file.truncated);
|
|
6137
|
+
const now = Date.now();
|
|
6138
|
+
const media = attachmentMedia(data.filename, data.mimetype);
|
|
6139
|
+
const stored = {
|
|
6140
|
+
id,
|
|
6141
|
+
sessionId,
|
|
6142
|
+
direction: "sent",
|
|
6143
|
+
storage: "managed",
|
|
6144
|
+
name: data.filename,
|
|
6145
|
+
path: written.path,
|
|
6146
|
+
mimeType: media.mimeType,
|
|
6147
|
+
size: written.size,
|
|
6148
|
+
kind: media.kind,
|
|
6149
|
+
createdAt: now,
|
|
6150
|
+
updatedAt: now,
|
|
6151
|
+
expiresAt: now + TERMINAL_FILE_TTL_MS
|
|
6152
|
+
};
|
|
6153
|
+
store.putFile(stored);
|
|
6154
|
+
terminalManager.pushControl(sessionId, { t: "file", op: "added", file: publicSessionFile(stored) });
|
|
6155
|
+
reply.code(201).send({ path: stored.path, file: publicSessionFile(stored) });
|
|
5705
6156
|
} catch (err) {
|
|
5706
|
-
|
|
5707
|
-
|
|
6157
|
+
reply.code(data.file.truncated ? 413 : 400).send({
|
|
6158
|
+
error: data.file.truncated ? "file exceeds the upload size limit" : err.message
|
|
6159
|
+
});
|
|
6160
|
+
}
|
|
6161
|
+
});
|
|
6162
|
+
app.post("/sessions/:id/files/:fileId/derive", async (request, reply) => {
|
|
6163
|
+
const source = store.getFile(request.params.id, request.params.fileId);
|
|
6164
|
+
if (!source || source.hiddenAt !== void 0 || source.kind !== "image") {
|
|
6165
|
+
reply.code(404).send({ error: "source image not found" });
|
|
6166
|
+
return;
|
|
6167
|
+
}
|
|
6168
|
+
if (source.expiresAt <= Date.now()) {
|
|
6169
|
+
reply.code(410).send({ error: "source image has expired" });
|
|
5708
6170
|
return;
|
|
5709
6171
|
}
|
|
5710
|
-
await fsService.pruneOlderThan(dir, TERMINAL_FILE_TTL_MS).catch(() => 0);
|
|
5711
6172
|
let data;
|
|
5712
6173
|
try {
|
|
5713
6174
|
data = await request.file();
|
|
@@ -5715,28 +6176,134 @@ function createServer(config, deps = {}) {
|
|
|
5715
6176
|
reply.code(400).send({ error: err.message });
|
|
5716
6177
|
return;
|
|
5717
6178
|
}
|
|
5718
|
-
if (!data) {
|
|
5719
|
-
reply.code(400).send({ error: "
|
|
6179
|
+
if (!data || !data.mimetype.startsWith("image/")) {
|
|
6180
|
+
reply.code(400).send({ error: "an edited image is required" });
|
|
5720
6181
|
return;
|
|
5721
6182
|
}
|
|
5722
|
-
|
|
6183
|
+
const id = randomUUID4();
|
|
5723
6184
|
try {
|
|
5724
|
-
|
|
6185
|
+
const dir = await fsService.ensureDirWithinRoot(
|
|
6186
|
+
`${terminalSharedDir({ dataDir: config.dataDir, fsRoot: config.fsRoot, sessionId: source.sessionId })}/${id}`
|
|
6187
|
+
);
|
|
6188
|
+
const written = await fsService.writeUploadedStream(dir, data.filename, data.file, () => !data.file.truncated);
|
|
6189
|
+
const now = Date.now();
|
|
6190
|
+
const media = attachmentMedia(data.filename, data.mimetype);
|
|
6191
|
+
const stored = {
|
|
6192
|
+
id,
|
|
6193
|
+
sessionId: source.sessionId,
|
|
6194
|
+
direction: "sent",
|
|
6195
|
+
storage: "managed",
|
|
6196
|
+
name: data.filename,
|
|
6197
|
+
path: written.path,
|
|
6198
|
+
mimeType: media.mimeType,
|
|
6199
|
+
size: written.size,
|
|
6200
|
+
kind: "image",
|
|
6201
|
+
createdAt: now,
|
|
6202
|
+
updatedAt: now,
|
|
6203
|
+
expiresAt: now + TERMINAL_FILE_TTL_MS,
|
|
6204
|
+
derivedFromId: source.id
|
|
6205
|
+
};
|
|
6206
|
+
store.putFile(stored);
|
|
6207
|
+
terminalManager.pushControl(source.sessionId, { t: "file", op: "added", file: publicSessionFile(stored) });
|
|
6208
|
+
reply.code(201).send({ path: stored.path, file: publicSessionFile(stored) });
|
|
5725
6209
|
} catch (err) {
|
|
5726
|
-
reply.code(413).send({
|
|
6210
|
+
reply.code(data.file.truncated ? 413 : 400).send({
|
|
6211
|
+
error: data.file.truncated ? "file exceeds the upload size limit" : err.message
|
|
6212
|
+
});
|
|
6213
|
+
}
|
|
6214
|
+
});
|
|
6215
|
+
app.put("/sessions/:id/files/:fileId/content", async (request, reply) => {
|
|
6216
|
+
const file = store.getFile(request.params.id, request.params.fileId);
|
|
6217
|
+
if (!file || file.hiddenAt !== void 0) {
|
|
6218
|
+
reply.code(404).send({ error: "file not found" });
|
|
5727
6219
|
return;
|
|
5728
6220
|
}
|
|
5729
|
-
if (
|
|
5730
|
-
reply.code(
|
|
6221
|
+
if (file.expiresAt <= Date.now()) {
|
|
6222
|
+
reply.code(410).send({ error: "file has expired" });
|
|
6223
|
+
return;
|
|
6224
|
+
}
|
|
6225
|
+
if (file.direction !== "sent" || file.storage !== "managed" || file.kind !== "image") {
|
|
6226
|
+
reply.code(409).send({ error: "only managed sent images can be replaced" });
|
|
5731
6227
|
return;
|
|
5732
6228
|
}
|
|
6229
|
+
let data;
|
|
5733
6230
|
try {
|
|
5734
|
-
|
|
5735
|
-
reply.code(201).send({ path: written.path });
|
|
6231
|
+
data = await request.file();
|
|
5736
6232
|
} catch (err) {
|
|
5737
6233
|
reply.code(400).send({ error: err.message });
|
|
6234
|
+
return;
|
|
6235
|
+
}
|
|
6236
|
+
if (!data || !data.mimetype.startsWith("image/")) {
|
|
6237
|
+
reply.code(400).send({ error: "an image file is required" });
|
|
6238
|
+
return;
|
|
6239
|
+
}
|
|
6240
|
+
try {
|
|
6241
|
+
const written = await fsService.replaceFileStream(file.path, data.file, () => !data.file.truncated);
|
|
6242
|
+
const now = Date.now();
|
|
6243
|
+
const updated = {
|
|
6244
|
+
...file,
|
|
6245
|
+
mimeType: data.mimetype,
|
|
6246
|
+
size: written.size,
|
|
6247
|
+
updatedAt: now,
|
|
6248
|
+
expiresAt: now + TERMINAL_FILE_TTL_MS
|
|
6249
|
+
};
|
|
6250
|
+
store.putFile(updated);
|
|
6251
|
+
terminalManager.pushControl(file.sessionId, { t: "file", op: "updated", file: publicSessionFile(updated) });
|
|
6252
|
+
reply.send({ file: publicSessionFile(updated) });
|
|
6253
|
+
} catch (err) {
|
|
6254
|
+
reply.code(data.file.truncated ? 413 : 400).send({
|
|
6255
|
+
error: data.file.truncated ? "file exceeds the upload size limit" : err.message
|
|
6256
|
+
});
|
|
5738
6257
|
}
|
|
5739
6258
|
});
|
|
6259
|
+
app.patch(
|
|
6260
|
+
"/sessions/:id/files/:fileId",
|
|
6261
|
+
async (request, reply) => {
|
|
6262
|
+
const file = store.getFile(request.params.id, request.params.fileId);
|
|
6263
|
+
if (!file) {
|
|
6264
|
+
reply.code(404).send({ error: "file not found" });
|
|
6265
|
+
return;
|
|
6266
|
+
}
|
|
6267
|
+
store.setFileHidden(file.sessionId, file.id, request.body?.hidden === false ? void 0 : Date.now());
|
|
6268
|
+
terminalManager.pushControl(file.sessionId, {
|
|
6269
|
+
t: "file",
|
|
6270
|
+
op: request.body?.hidden === false ? "restored" : "hidden",
|
|
6271
|
+
id: file.id
|
|
6272
|
+
});
|
|
6273
|
+
reply.code(204).send();
|
|
6274
|
+
}
|
|
6275
|
+
);
|
|
6276
|
+
app.delete(
|
|
6277
|
+
"/sessions/:id/files/:fileId",
|
|
6278
|
+
async (request, reply) => {
|
|
6279
|
+
const file = store.getFile(request.params.id, request.params.fileId);
|
|
6280
|
+
if (!file) {
|
|
6281
|
+
reply.code(204).send();
|
|
6282
|
+
return;
|
|
6283
|
+
}
|
|
6284
|
+
if (request.query.content === "true") {
|
|
6285
|
+
if (file.direction !== "sent" || file.storage !== "managed") {
|
|
6286
|
+
reply.code(409).send({ error: "workspace files cannot be deleted by RoamCode" });
|
|
6287
|
+
return;
|
|
6288
|
+
}
|
|
6289
|
+
try {
|
|
6290
|
+
await fsService.removeManagedPath(file.path);
|
|
6291
|
+
} catch (err) {
|
|
6292
|
+
if (!(err instanceof FsError && err.code === "not-found")) {
|
|
6293
|
+
reply.code(err instanceof FsError && err.code === "forbidden" ? 403 : 500).send({
|
|
6294
|
+
error: "managed file could not be deleted"
|
|
6295
|
+
});
|
|
6296
|
+
return;
|
|
6297
|
+
}
|
|
6298
|
+
}
|
|
6299
|
+
store.deleteFile(file.sessionId, file.id);
|
|
6300
|
+
} else {
|
|
6301
|
+
store.setFileHidden(file.sessionId, file.id, Date.now());
|
|
6302
|
+
}
|
|
6303
|
+
terminalManager.pushControl(file.sessionId, { t: "file", op: "removed", id: file.id });
|
|
6304
|
+
reply.code(204).send();
|
|
6305
|
+
}
|
|
6306
|
+
);
|
|
5740
6307
|
if (deps.webDir) registerStatic(app, { webDir: deps.webDir });
|
|
5741
6308
|
app.addHook("onClose", async () => {
|
|
5742
6309
|
clearInterval(sharedSweepTimer);
|
|
@@ -5767,10 +6334,10 @@ function createServer(config, deps = {}) {
|
|
|
5767
6334
|
});
|
|
5768
6335
|
return { app, authGate, terminalManager, terminalAvailable };
|
|
5769
6336
|
}
|
|
5770
|
-
function contentDisposition(filename) {
|
|
6337
|
+
function contentDisposition(filename, disposition = "attachment") {
|
|
5771
6338
|
const ascii = filename.replace(/[\x00-\x1f\x7f"\\]/g, "_");
|
|
5772
6339
|
const encoded = encodeURIComponent(filename);
|
|
5773
|
-
return
|
|
6340
|
+
return `${disposition}; filename="${ascii}"; filename*=UTF-8''${encoded}`;
|
|
5774
6341
|
}
|
|
5775
6342
|
|
|
5776
6343
|
// src/start.ts
|
|
@@ -6079,7 +6646,7 @@ function createUsageService(opts) {
|
|
|
6079
6646
|
|
|
6080
6647
|
// src/claude-auth-service.ts
|
|
6081
6648
|
import { spawn as nodeSpawn2 } from "child_process";
|
|
6082
|
-
import { randomUUID as
|
|
6649
|
+
import { randomUUID as randomUUID5 } from "crypto";
|
|
6083
6650
|
function parseAuthStatus(stdout) {
|
|
6084
6651
|
try {
|
|
6085
6652
|
const o = JSON.parse(stdout);
|
|
@@ -6156,7 +6723,7 @@ var ClaudeAuthService = class {
|
|
|
6156
6723
|
reject(err instanceof Error ? err : new Error("failed to spawn claude"));
|
|
6157
6724
|
return;
|
|
6158
6725
|
}
|
|
6159
|
-
const id =
|
|
6726
|
+
const id = randomUUID5();
|
|
6160
6727
|
let settled = false;
|
|
6161
6728
|
let buf = "";
|
|
6162
6729
|
const onData = (d) => {
|
|
@@ -7518,7 +8085,7 @@ var CodexMetadataService = class {
|
|
|
7518
8085
|
|
|
7519
8086
|
// src/providers/claude-metadata-service.ts
|
|
7520
8087
|
import { execFile as nodeExecFile2, spawn as nodeSpawn3 } from "child_process";
|
|
7521
|
-
import { randomUUID as
|
|
8088
|
+
import { randomUUID as randomUUID6 } from "crypto";
|
|
7522
8089
|
import { StringDecoder } from "string_decoder";
|
|
7523
8090
|
var SAFE_VALUE = /^[A-Za-z0-9][A-Za-z0-9._:/\u005b\u005d-]*$/;
|
|
7524
8091
|
var MAX_MODELS = 64;
|
|
@@ -7726,7 +8293,7 @@ function createClaudeMetadataRunner(options) {
|
|
|
7726
8293
|
reject(metadataUnavailable2());
|
|
7727
8294
|
return;
|
|
7728
8295
|
}
|
|
7729
|
-
const requestId = `roamcode-models-${
|
|
8296
|
+
const requestId = `roamcode-models-${randomUUID6()}`;
|
|
7730
8297
|
const decoder = new StringDecoder("utf8");
|
|
7731
8298
|
let stdoutBuffer = "";
|
|
7732
8299
|
let outputBytes = 0;
|