@roamcode.ai/server 1.0.10 → 1.0.12
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 +660 -50
- package/dist/start.d.ts +28 -0
- package/dist/start.js +654 -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.12" ? "1.0.12".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,92 @@ 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
|
+
var FILE_HISTORY_BACKFILL_BUDGET_MS = 150;
|
|
4912
|
+
var FILE_HISTORY_AVAILABILITY_BUDGET_MS = 150;
|
|
4913
|
+
function completionWithin(task, timeoutMs) {
|
|
4914
|
+
return new Promise((resolve7) => {
|
|
4915
|
+
let settled = false;
|
|
4916
|
+
const finish = (completed) => {
|
|
4917
|
+
if (settled) return;
|
|
4918
|
+
settled = true;
|
|
4919
|
+
clearTimeout(timer);
|
|
4920
|
+
resolve7(completed);
|
|
4921
|
+
};
|
|
4922
|
+
const timer = setTimeout(() => finish(false), timeoutMs);
|
|
4923
|
+
void task.then(
|
|
4924
|
+
() => finish(true),
|
|
4925
|
+
() => finish(false)
|
|
4926
|
+
);
|
|
4927
|
+
});
|
|
4928
|
+
}
|
|
4929
|
+
function attachmentMedia(filename, declared = "application/octet-stream") {
|
|
4930
|
+
const ext = filename.includes(".") ? filename.slice(filename.lastIndexOf(".") + 1).toLowerCase() : "";
|
|
4931
|
+
if (declared.startsWith("image/") || IMAGE_FILE_EXTENSIONS.has(ext)) {
|
|
4932
|
+
const inferred = ext === "jpg" || ext === "jpeg" ? "image/jpeg" : ext === "svg" ? "image/svg+xml" : ext ? `image/${ext}` : declared;
|
|
4933
|
+
return { mimeType: declared.startsWith("image/") ? declared : inferred, kind: "image" };
|
|
4934
|
+
}
|
|
4935
|
+
if (declared === "application/pdf" || ext === "pdf") return { mimeType: "application/pdf", kind: "pdf" };
|
|
4936
|
+
if (declared.startsWith("text/") || TEXT_FILE_EXTENSIONS.has(ext)) {
|
|
4937
|
+
return { mimeType: "text/plain; charset=utf-8", kind: "text" };
|
|
4938
|
+
}
|
|
4939
|
+
return { mimeType: declared || "application/octet-stream", kind: "binary" };
|
|
4940
|
+
}
|
|
4941
|
+
function publicSessionFile(file, available = true) {
|
|
4942
|
+
return { ...file, isImage: file.kind === "image", available };
|
|
4943
|
+
}
|
|
4620
4944
|
function isDisallowedPushHost(hostname) {
|
|
4621
4945
|
const host = hostname.toLowerCase().replace(/^\[|\]$/g, "");
|
|
4622
4946
|
if (host === "localhost" || host.endsWith(".localhost")) return true;
|
|
@@ -4672,8 +4996,69 @@ function createServer(config, deps = {}) {
|
|
|
4672
4996
|
});
|
|
4673
4997
|
const fsService = new FsService({ root: config.fsRoot });
|
|
4674
4998
|
const terminalSharedRoot = terminalSharedBase({ dataDir: config.dataDir, fsRoot: config.fsRoot });
|
|
4999
|
+
const backfilledFileSessions = /* @__PURE__ */ new Set();
|
|
5000
|
+
const fileBackfillsInFlight = /* @__PURE__ */ new Map();
|
|
5001
|
+
const backfillManagedFiles = (sessionId) => {
|
|
5002
|
+
if (backfilledFileSessions.has(sessionId)) return Promise.resolve();
|
|
5003
|
+
const existing = fileBackfillsInFlight.get(sessionId);
|
|
5004
|
+
if (existing) return existing;
|
|
5005
|
+
const task = (async () => {
|
|
5006
|
+
const sessionDir = terminalSharedDir({ dataDir: config.dataDir, fsRoot: config.fsRoot, sessionId });
|
|
5007
|
+
const discovered = await fsService.discoverManagedFiles(sessionDir);
|
|
5008
|
+
const knownPaths = new Set(store.listFiles(sessionId, true).map((file) => file.path));
|
|
5009
|
+
for (const file of discovered) {
|
|
5010
|
+
if (knownPaths.has(file.path)) continue;
|
|
5011
|
+
const expiresAt = file.mtimeMs + TERMINAL_FILE_TTL_MS;
|
|
5012
|
+
if (expiresAt <= Date.now()) continue;
|
|
5013
|
+
const now = Date.now();
|
|
5014
|
+
const media = attachmentMedia(file.filename);
|
|
5015
|
+
store.putFile({
|
|
5016
|
+
id: randomUUID4(),
|
|
5017
|
+
sessionId,
|
|
5018
|
+
direction: "sent",
|
|
5019
|
+
storage: "managed",
|
|
5020
|
+
name: file.filename,
|
|
5021
|
+
path: file.path,
|
|
5022
|
+
mimeType: media.mimeType,
|
|
5023
|
+
size: file.size,
|
|
5024
|
+
kind: media.kind,
|
|
5025
|
+
createdAt: file.mtimeMs,
|
|
5026
|
+
updatedAt: now,
|
|
5027
|
+
expiresAt
|
|
5028
|
+
});
|
|
5029
|
+
knownPaths.add(file.path);
|
|
5030
|
+
}
|
|
5031
|
+
backfilledFileSessions.add(sessionId);
|
|
5032
|
+
})();
|
|
5033
|
+
fileBackfillsInFlight.set(sessionId, task);
|
|
5034
|
+
void task.catch(() => void 0).finally(() => fileBackfillsInFlight.delete(sessionId));
|
|
5035
|
+
return task;
|
|
5036
|
+
};
|
|
5037
|
+
const fileAvailableWithinBudget = async (file) => {
|
|
5038
|
+
if (file.expiresAt <= Date.now()) return false;
|
|
5039
|
+
return new Promise((resolve7) => {
|
|
5040
|
+
let settled = false;
|
|
5041
|
+
const finish = (available) => {
|
|
5042
|
+
if (settled) return;
|
|
5043
|
+
settled = true;
|
|
5044
|
+
clearTimeout(timer);
|
|
5045
|
+
resolve7(available);
|
|
5046
|
+
};
|
|
5047
|
+
const timer = setTimeout(() => finish(true), FILE_HISTORY_AVAILABILITY_BUDGET_MS);
|
|
5048
|
+
void fsService.describeFile(file.path).then(
|
|
5049
|
+
() => finish(true),
|
|
5050
|
+
() => finish(false)
|
|
5051
|
+
);
|
|
5052
|
+
});
|
|
5053
|
+
};
|
|
4675
5054
|
const sweepSharedFiles = () => {
|
|
4676
|
-
void
|
|
5055
|
+
void (async () => {
|
|
5056
|
+
const expired = typeof store.pruneFiles === "function" ? store.pruneFiles(Date.now()) : [];
|
|
5057
|
+
await Promise.all(
|
|
5058
|
+
expired.filter((file) => file.storage === "managed").map((file) => fsService.removeManagedPath(file.path).catch(() => void 0))
|
|
5059
|
+
);
|
|
5060
|
+
await fsService.pruneChildDirsOlderThan(terminalSharedRoot, TERMINAL_FILE_TTL_MS).catch(() => 0);
|
|
5061
|
+
})();
|
|
4677
5062
|
};
|
|
4678
5063
|
sweepSharedFiles();
|
|
4679
5064
|
const sharedSweepTimer = setInterval(sweepSharedFiles, TERMINAL_SWEEP_INTERVAL_MS);
|
|
@@ -4918,7 +5303,7 @@ function createServer(config, deps = {}) {
|
|
|
4918
5303
|
reply.code(400).send({ error: `cwd does not exist: ${body.cwd}` });
|
|
4919
5304
|
return;
|
|
4920
5305
|
}
|
|
4921
|
-
const id =
|
|
5306
|
+
const id = randomUUID4();
|
|
4922
5307
|
const rawOptions = body.options ?? (provider === "claude" ? {
|
|
4923
5308
|
...typeof body.model === "string" ? { model: body.model } : {},
|
|
4924
5309
|
...typeof body.effort === "string" ? { effort: body.effort } : {},
|
|
@@ -5142,8 +5527,10 @@ function createServer(config, deps = {}) {
|
|
|
5142
5527
|
}
|
|
5143
5528
|
const caption = typeof body.caption === "string" ? body.caption : void 0;
|
|
5144
5529
|
let described;
|
|
5530
|
+
let fileInfo;
|
|
5145
5531
|
try {
|
|
5146
5532
|
described = await fsService.describeForAttachment(body.path);
|
|
5533
|
+
fileInfo = await fsService.describeFile(body.path);
|
|
5147
5534
|
} catch (err) {
|
|
5148
5535
|
if (err instanceof FsError) {
|
|
5149
5536
|
reply.code(err.code === "forbidden" ? 403 : 404).send({ error: err.message });
|
|
@@ -5153,14 +5540,28 @@ function createServer(config, deps = {}) {
|
|
|
5153
5540
|
return;
|
|
5154
5541
|
}
|
|
5155
5542
|
const isImage = body.kind === "image" ? true : body.kind === "file" ? false : described.isImage;
|
|
5156
|
-
const id =
|
|
5157
|
-
|
|
5158
|
-
|
|
5543
|
+
const id = randomUUID4();
|
|
5544
|
+
const now = Date.now();
|
|
5545
|
+
const media = attachmentMedia(described.name);
|
|
5546
|
+
const stored = {
|
|
5159
5547
|
id,
|
|
5160
|
-
|
|
5548
|
+
sessionId,
|
|
5549
|
+
direction: "received",
|
|
5550
|
+
storage: "workspace",
|
|
5161
5551
|
name: described.name,
|
|
5162
|
-
|
|
5163
|
-
|
|
5552
|
+
path: body.path,
|
|
5553
|
+
mimeType: media.mimeType,
|
|
5554
|
+
size: fileInfo.size,
|
|
5555
|
+
kind: isImage ? "image" : media.kind,
|
|
5556
|
+
...caption ? { caption } : {},
|
|
5557
|
+
createdAt: now,
|
|
5558
|
+
updatedAt: now,
|
|
5559
|
+
expiresAt: now + TERMINAL_FILE_TTL_MS
|
|
5560
|
+
};
|
|
5561
|
+
store.putFile(stored);
|
|
5562
|
+
terminalManager.pushControl(sessionId, {
|
|
5563
|
+
t: "attach",
|
|
5564
|
+
...publicSessionFile(stored)
|
|
5164
5565
|
});
|
|
5165
5566
|
dispatchPush({ kind: "file", sessionId, detail: described.name });
|
|
5166
5567
|
reply.code(200).send({ ok: true, id });
|
|
@@ -5691,23 +6092,126 @@ function createServer(config, deps = {}) {
|
|
|
5691
6092
|
reply.code(400).send({ error: err.message });
|
|
5692
6093
|
}
|
|
5693
6094
|
});
|
|
6095
|
+
app.get("/sessions/:id/files", async (request, reply) => {
|
|
6096
|
+
if (!terminalManager.get(request.params.id)) {
|
|
6097
|
+
reply.code(404).send({ error: "terminal session not found" });
|
|
6098
|
+
return;
|
|
6099
|
+
}
|
|
6100
|
+
await completionWithin(backfillManagedFiles(request.params.id), FILE_HISTORY_BACKFILL_BUDGET_MS);
|
|
6101
|
+
const files = await Promise.all(
|
|
6102
|
+
store.listFiles(request.params.id).map(async (file) => {
|
|
6103
|
+
const available = await fileAvailableWithinBudget(file);
|
|
6104
|
+
return publicSessionFile(file, available);
|
|
6105
|
+
})
|
|
6106
|
+
);
|
|
6107
|
+
return {
|
|
6108
|
+
files,
|
|
6109
|
+
policy: {
|
|
6110
|
+
maxUploadBytes: config.maxUploadBytes,
|
|
6111
|
+
retentionMs: TERMINAL_FILE_TTL_MS,
|
|
6112
|
+
durable: store.mode === "sqlite"
|
|
6113
|
+
}
|
|
6114
|
+
};
|
|
6115
|
+
});
|
|
6116
|
+
app.get(
|
|
6117
|
+
"/sessions/:id/files/:fileId/content",
|
|
6118
|
+
async (request, reply) => {
|
|
6119
|
+
const file = store.getFile(request.params.id, request.params.fileId);
|
|
6120
|
+
if (!file || file.hiddenAt !== void 0) {
|
|
6121
|
+
reply.code(404).send({ error: "file not found" });
|
|
6122
|
+
return;
|
|
6123
|
+
}
|
|
6124
|
+
if (file.expiresAt <= Date.now()) {
|
|
6125
|
+
reply.code(410).send({ error: "file has expired" });
|
|
6126
|
+
return;
|
|
6127
|
+
}
|
|
6128
|
+
try {
|
|
6129
|
+
const info = await fsService.describeFile(file.path);
|
|
6130
|
+
const disposition = request.query.disposition === "inline" ? "inline" : "attachment";
|
|
6131
|
+
reply.header("accept-ranges", "bytes").header("content-type", file.mimeType).header("x-content-type-options", "nosniff").header(
|
|
6132
|
+
"content-security-policy",
|
|
6133
|
+
"sandbox; default-src 'none'; img-src 'self' data: blob:; style-src 'unsafe-inline'"
|
|
6134
|
+
).header("content-disposition", contentDisposition(info.filename, disposition));
|
|
6135
|
+
const range = request.headers.range;
|
|
6136
|
+
if (range) {
|
|
6137
|
+
const match = /^bytes=(\d+)-(\d*)$/.exec(range);
|
|
6138
|
+
if (!match) {
|
|
6139
|
+
reply.code(416).header("content-range", `bytes */${info.size}`).send();
|
|
6140
|
+
return;
|
|
6141
|
+
}
|
|
6142
|
+
const start = Number(match[1]);
|
|
6143
|
+
const end = match[2] ? Math.min(Number(match[2]), info.size - 1) : info.size - 1;
|
|
6144
|
+
if (!Number.isSafeInteger(start) || start < 0 || start > end || start >= info.size) {
|
|
6145
|
+
reply.code(416).header("content-range", `bytes */${info.size}`).send();
|
|
6146
|
+
return;
|
|
6147
|
+
}
|
|
6148
|
+
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 }));
|
|
6149
|
+
}
|
|
6150
|
+
return reply.header("content-length", String(info.size)).send(createReadStream(info.path));
|
|
6151
|
+
} catch (err) {
|
|
6152
|
+
const code = err instanceof FsError && err.code === "forbidden" ? 403 : 404;
|
|
6153
|
+
reply.code(code).send({ error: err.message });
|
|
6154
|
+
}
|
|
6155
|
+
}
|
|
6156
|
+
);
|
|
5694
6157
|
app.post("/sessions/:id/upload", async (request, reply) => {
|
|
5695
|
-
const
|
|
5696
|
-
if (!
|
|
6158
|
+
const sessionId = request.params.id;
|
|
6159
|
+
if (!terminalManager.get(sessionId)) {
|
|
5697
6160
|
reply.code(404).send({ error: "terminal session not found" });
|
|
5698
6161
|
return;
|
|
5699
6162
|
}
|
|
5700
|
-
let
|
|
6163
|
+
let data;
|
|
6164
|
+
try {
|
|
6165
|
+
data = await request.file();
|
|
6166
|
+
} catch (err) {
|
|
6167
|
+
reply.code(400).send({ error: err.message });
|
|
6168
|
+
return;
|
|
6169
|
+
}
|
|
6170
|
+
if (!data) {
|
|
6171
|
+
reply.code(400).send({ error: "no file field in the upload" });
|
|
6172
|
+
return;
|
|
6173
|
+
}
|
|
6174
|
+
const id = randomUUID4();
|
|
5701
6175
|
try {
|
|
5702
|
-
dir = await fsService.ensureDirWithinRoot(
|
|
5703
|
-
terminalSharedDir({ dataDir: config.dataDir, fsRoot: config.fsRoot, sessionId
|
|
6176
|
+
const dir = await fsService.ensureDirWithinRoot(
|
|
6177
|
+
`${terminalSharedDir({ dataDir: config.dataDir, fsRoot: config.fsRoot, sessionId })}/${id}`
|
|
5704
6178
|
);
|
|
6179
|
+
const written = await fsService.writeUploadedStream(dir, data.filename, data.file, () => !data.file.truncated);
|
|
6180
|
+
const now = Date.now();
|
|
6181
|
+
const media = attachmentMedia(data.filename, data.mimetype);
|
|
6182
|
+
const stored = {
|
|
6183
|
+
id,
|
|
6184
|
+
sessionId,
|
|
6185
|
+
direction: "sent",
|
|
6186
|
+
storage: "managed",
|
|
6187
|
+
name: data.filename,
|
|
6188
|
+
path: written.path,
|
|
6189
|
+
mimeType: media.mimeType,
|
|
6190
|
+
size: written.size,
|
|
6191
|
+
kind: media.kind,
|
|
6192
|
+
createdAt: now,
|
|
6193
|
+
updatedAt: now,
|
|
6194
|
+
expiresAt: now + TERMINAL_FILE_TTL_MS
|
|
6195
|
+
};
|
|
6196
|
+
store.putFile(stored);
|
|
6197
|
+
terminalManager.pushControl(sessionId, { t: "file", op: "added", file: publicSessionFile(stored) });
|
|
6198
|
+
reply.code(201).send({ path: stored.path, file: publicSessionFile(stored) });
|
|
5705
6199
|
} catch (err) {
|
|
5706
|
-
|
|
5707
|
-
|
|
6200
|
+
reply.code(data.file.truncated ? 413 : 400).send({
|
|
6201
|
+
error: data.file.truncated ? "file exceeds the upload size limit" : err.message
|
|
6202
|
+
});
|
|
6203
|
+
}
|
|
6204
|
+
});
|
|
6205
|
+
app.post("/sessions/:id/files/:fileId/derive", async (request, reply) => {
|
|
6206
|
+
const source = store.getFile(request.params.id, request.params.fileId);
|
|
6207
|
+
if (!source || source.hiddenAt !== void 0 || source.kind !== "image") {
|
|
6208
|
+
reply.code(404).send({ error: "source image not found" });
|
|
6209
|
+
return;
|
|
6210
|
+
}
|
|
6211
|
+
if (source.expiresAt <= Date.now()) {
|
|
6212
|
+
reply.code(410).send({ error: "source image has expired" });
|
|
5708
6213
|
return;
|
|
5709
6214
|
}
|
|
5710
|
-
await fsService.pruneOlderThan(dir, TERMINAL_FILE_TTL_MS).catch(() => 0);
|
|
5711
6215
|
let data;
|
|
5712
6216
|
try {
|
|
5713
6217
|
data = await request.file();
|
|
@@ -5715,28 +6219,134 @@ function createServer(config, deps = {}) {
|
|
|
5715
6219
|
reply.code(400).send({ error: err.message });
|
|
5716
6220
|
return;
|
|
5717
6221
|
}
|
|
5718
|
-
if (!data) {
|
|
5719
|
-
reply.code(400).send({ error: "
|
|
6222
|
+
if (!data || !data.mimetype.startsWith("image/")) {
|
|
6223
|
+
reply.code(400).send({ error: "an edited image is required" });
|
|
5720
6224
|
return;
|
|
5721
6225
|
}
|
|
5722
|
-
|
|
6226
|
+
const id = randomUUID4();
|
|
5723
6227
|
try {
|
|
5724
|
-
|
|
6228
|
+
const dir = await fsService.ensureDirWithinRoot(
|
|
6229
|
+
`${terminalSharedDir({ dataDir: config.dataDir, fsRoot: config.fsRoot, sessionId: source.sessionId })}/${id}`
|
|
6230
|
+
);
|
|
6231
|
+
const written = await fsService.writeUploadedStream(dir, data.filename, data.file, () => !data.file.truncated);
|
|
6232
|
+
const now = Date.now();
|
|
6233
|
+
const media = attachmentMedia(data.filename, data.mimetype);
|
|
6234
|
+
const stored = {
|
|
6235
|
+
id,
|
|
6236
|
+
sessionId: source.sessionId,
|
|
6237
|
+
direction: "sent",
|
|
6238
|
+
storage: "managed",
|
|
6239
|
+
name: data.filename,
|
|
6240
|
+
path: written.path,
|
|
6241
|
+
mimeType: media.mimeType,
|
|
6242
|
+
size: written.size,
|
|
6243
|
+
kind: "image",
|
|
6244
|
+
createdAt: now,
|
|
6245
|
+
updatedAt: now,
|
|
6246
|
+
expiresAt: now + TERMINAL_FILE_TTL_MS,
|
|
6247
|
+
derivedFromId: source.id
|
|
6248
|
+
};
|
|
6249
|
+
store.putFile(stored);
|
|
6250
|
+
terminalManager.pushControl(source.sessionId, { t: "file", op: "added", file: publicSessionFile(stored) });
|
|
6251
|
+
reply.code(201).send({ path: stored.path, file: publicSessionFile(stored) });
|
|
5725
6252
|
} catch (err) {
|
|
5726
|
-
reply.code(413).send({
|
|
6253
|
+
reply.code(data.file.truncated ? 413 : 400).send({
|
|
6254
|
+
error: data.file.truncated ? "file exceeds the upload size limit" : err.message
|
|
6255
|
+
});
|
|
6256
|
+
}
|
|
6257
|
+
});
|
|
6258
|
+
app.put("/sessions/:id/files/:fileId/content", async (request, reply) => {
|
|
6259
|
+
const file = store.getFile(request.params.id, request.params.fileId);
|
|
6260
|
+
if (!file || file.hiddenAt !== void 0) {
|
|
6261
|
+
reply.code(404).send({ error: "file not found" });
|
|
5727
6262
|
return;
|
|
5728
6263
|
}
|
|
5729
|
-
if (
|
|
5730
|
-
reply.code(
|
|
6264
|
+
if (file.expiresAt <= Date.now()) {
|
|
6265
|
+
reply.code(410).send({ error: "file has expired" });
|
|
6266
|
+
return;
|
|
6267
|
+
}
|
|
6268
|
+
if (file.direction !== "sent" || file.storage !== "managed" || file.kind !== "image") {
|
|
6269
|
+
reply.code(409).send({ error: "only managed sent images can be replaced" });
|
|
5731
6270
|
return;
|
|
5732
6271
|
}
|
|
6272
|
+
let data;
|
|
5733
6273
|
try {
|
|
5734
|
-
|
|
5735
|
-
reply.code(201).send({ path: written.path });
|
|
6274
|
+
data = await request.file();
|
|
5736
6275
|
} catch (err) {
|
|
5737
6276
|
reply.code(400).send({ error: err.message });
|
|
6277
|
+
return;
|
|
6278
|
+
}
|
|
6279
|
+
if (!data || !data.mimetype.startsWith("image/")) {
|
|
6280
|
+
reply.code(400).send({ error: "an image file is required" });
|
|
6281
|
+
return;
|
|
6282
|
+
}
|
|
6283
|
+
try {
|
|
6284
|
+
const written = await fsService.replaceFileStream(file.path, data.file, () => !data.file.truncated);
|
|
6285
|
+
const now = Date.now();
|
|
6286
|
+
const updated = {
|
|
6287
|
+
...file,
|
|
6288
|
+
mimeType: data.mimetype,
|
|
6289
|
+
size: written.size,
|
|
6290
|
+
updatedAt: now,
|
|
6291
|
+
expiresAt: now + TERMINAL_FILE_TTL_MS
|
|
6292
|
+
};
|
|
6293
|
+
store.putFile(updated);
|
|
6294
|
+
terminalManager.pushControl(file.sessionId, { t: "file", op: "updated", file: publicSessionFile(updated) });
|
|
6295
|
+
reply.send({ file: publicSessionFile(updated) });
|
|
6296
|
+
} catch (err) {
|
|
6297
|
+
reply.code(data.file.truncated ? 413 : 400).send({
|
|
6298
|
+
error: data.file.truncated ? "file exceeds the upload size limit" : err.message
|
|
6299
|
+
});
|
|
5738
6300
|
}
|
|
5739
6301
|
});
|
|
6302
|
+
app.patch(
|
|
6303
|
+
"/sessions/:id/files/:fileId",
|
|
6304
|
+
async (request, reply) => {
|
|
6305
|
+
const file = store.getFile(request.params.id, request.params.fileId);
|
|
6306
|
+
if (!file) {
|
|
6307
|
+
reply.code(404).send({ error: "file not found" });
|
|
6308
|
+
return;
|
|
6309
|
+
}
|
|
6310
|
+
store.setFileHidden(file.sessionId, file.id, request.body?.hidden === false ? void 0 : Date.now());
|
|
6311
|
+
terminalManager.pushControl(file.sessionId, {
|
|
6312
|
+
t: "file",
|
|
6313
|
+
op: request.body?.hidden === false ? "restored" : "hidden",
|
|
6314
|
+
id: file.id
|
|
6315
|
+
});
|
|
6316
|
+
reply.code(204).send();
|
|
6317
|
+
}
|
|
6318
|
+
);
|
|
6319
|
+
app.delete(
|
|
6320
|
+
"/sessions/:id/files/:fileId",
|
|
6321
|
+
async (request, reply) => {
|
|
6322
|
+
const file = store.getFile(request.params.id, request.params.fileId);
|
|
6323
|
+
if (!file) {
|
|
6324
|
+
reply.code(204).send();
|
|
6325
|
+
return;
|
|
6326
|
+
}
|
|
6327
|
+
if (request.query.content === "true") {
|
|
6328
|
+
if (file.direction !== "sent" || file.storage !== "managed") {
|
|
6329
|
+
reply.code(409).send({ error: "workspace files cannot be deleted by RoamCode" });
|
|
6330
|
+
return;
|
|
6331
|
+
}
|
|
6332
|
+
try {
|
|
6333
|
+
await fsService.removeManagedPath(file.path);
|
|
6334
|
+
} catch (err) {
|
|
6335
|
+
if (!(err instanceof FsError && err.code === "not-found")) {
|
|
6336
|
+
reply.code(err instanceof FsError && err.code === "forbidden" ? 403 : 500).send({
|
|
6337
|
+
error: "managed file could not be deleted"
|
|
6338
|
+
});
|
|
6339
|
+
return;
|
|
6340
|
+
}
|
|
6341
|
+
}
|
|
6342
|
+
store.deleteFile(file.sessionId, file.id);
|
|
6343
|
+
} else {
|
|
6344
|
+
store.setFileHidden(file.sessionId, file.id, Date.now());
|
|
6345
|
+
}
|
|
6346
|
+
terminalManager.pushControl(file.sessionId, { t: "file", op: "removed", id: file.id });
|
|
6347
|
+
reply.code(204).send();
|
|
6348
|
+
}
|
|
6349
|
+
);
|
|
5740
6350
|
if (deps.webDir) registerStatic(app, { webDir: deps.webDir });
|
|
5741
6351
|
app.addHook("onClose", async () => {
|
|
5742
6352
|
clearInterval(sharedSweepTimer);
|
|
@@ -5767,10 +6377,10 @@ function createServer(config, deps = {}) {
|
|
|
5767
6377
|
});
|
|
5768
6378
|
return { app, authGate, terminalManager, terminalAvailable };
|
|
5769
6379
|
}
|
|
5770
|
-
function contentDisposition(filename) {
|
|
6380
|
+
function contentDisposition(filename, disposition = "attachment") {
|
|
5771
6381
|
const ascii = filename.replace(/[\x00-\x1f\x7f"\\]/g, "_");
|
|
5772
6382
|
const encoded = encodeURIComponent(filename);
|
|
5773
|
-
return
|
|
6383
|
+
return `${disposition}; filename="${ascii}"; filename*=UTF-8''${encoded}`;
|
|
5774
6384
|
}
|
|
5775
6385
|
|
|
5776
6386
|
// src/start.ts
|
|
@@ -6079,7 +6689,7 @@ function createUsageService(opts) {
|
|
|
6079
6689
|
|
|
6080
6690
|
// src/claude-auth-service.ts
|
|
6081
6691
|
import { spawn as nodeSpawn2 } from "child_process";
|
|
6082
|
-
import { randomUUID as
|
|
6692
|
+
import { randomUUID as randomUUID5 } from "crypto";
|
|
6083
6693
|
function parseAuthStatus(stdout) {
|
|
6084
6694
|
try {
|
|
6085
6695
|
const o = JSON.parse(stdout);
|
|
@@ -6156,7 +6766,7 @@ var ClaudeAuthService = class {
|
|
|
6156
6766
|
reject(err instanceof Error ? err : new Error("failed to spawn claude"));
|
|
6157
6767
|
return;
|
|
6158
6768
|
}
|
|
6159
|
-
const id =
|
|
6769
|
+
const id = randomUUID5();
|
|
6160
6770
|
let settled = false;
|
|
6161
6771
|
let buf = "";
|
|
6162
6772
|
const onData = (d) => {
|
|
@@ -7518,7 +8128,7 @@ var CodexMetadataService = class {
|
|
|
7518
8128
|
|
|
7519
8129
|
// src/providers/claude-metadata-service.ts
|
|
7520
8130
|
import { execFile as nodeExecFile2, spawn as nodeSpawn3 } from "child_process";
|
|
7521
|
-
import { randomUUID as
|
|
8131
|
+
import { randomUUID as randomUUID6 } from "crypto";
|
|
7522
8132
|
import { StringDecoder } from "string_decoder";
|
|
7523
8133
|
var SAFE_VALUE = /^[A-Za-z0-9][A-Za-z0-9._:/\u005b\u005d-]*$/;
|
|
7524
8134
|
var MAX_MODELS = 64;
|
|
@@ -7726,7 +8336,7 @@ function createClaudeMetadataRunner(options) {
|
|
|
7726
8336
|
reject(metadataUnavailable2());
|
|
7727
8337
|
return;
|
|
7728
8338
|
}
|
|
7729
|
-
const requestId = `roamcode-models-${
|
|
8339
|
+
const requestId = `roamcode-models-${randomUUID6()}`;
|
|
7730
8340
|
const decoder = new StringDecoder("utf8");
|
|
7731
8341
|
let stdoutBuffer = "";
|
|
7732
8342
|
let outputBytes = 0;
|