agentgui 1.0.1063 → 1.0.1064
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/.gm/prd.yml +0 -3
- package/lib/http-handler.js +94 -6
- package/package.json +1 -1
- package/site/app/js/app.js +43 -3
- package/site/app/js/backend.js +3 -0
package/.gm/prd.yml
CHANGED
|
@@ -3106,9 +3106,6 @@
|
|
|
3106
3106
|
- id: docstudio-cue-session-group-eyebrow
|
|
3107
3107
|
subject: Verify ConversationList's session-group headers (Today/Yesterday/etc) use the --tr-label token + --fg-3 tone consistently, matching docstudio's uppercase letter-spaced low-opacity eyebrow label idiom
|
|
3108
3108
|
status: pending
|
|
3109
|
-
- id: gc-delete-undo-trash
|
|
3110
|
-
subject: No undo-after-delete (soft-delete/trash) anywhere in the confined delete surface
|
|
3111
|
-
status: pending
|
|
3112
3109
|
- id: gc-expanded-body-highlight
|
|
3113
3110
|
subject: Search-result highlighting inside expanded event bodies never highlights the matched query term
|
|
3114
3111
|
status: pending
|
package/lib/http-handler.js
CHANGED
|
@@ -146,6 +146,73 @@ function isAllowRoot(realPath, allowRoots) {
|
|
|
146
146
|
return allowRoots.some(r => (IS_WINDOWS ? r.toLowerCase() : r) === p);
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
+
// --- Soft-delete (trash) -----------------------------------------------
|
|
150
|
+
// /api/delete moves entries into <root>/.agentgui-trash/<trashId>__<name>
|
|
151
|
+
// instead of unlinking, giving a short undo window. In-memory index only
|
|
152
|
+
// (server restart forfeits the undo window - acceptable since the retention
|
|
153
|
+
// window itself is short and this is a convenience net on top of, not a
|
|
154
|
+
// replacement for, the pre-delete ConfirmDialog). Purged after
|
|
155
|
+
// TRASH_RETENTION_MS or when trashIndex grows past TRASH_MAX_ENTRIES
|
|
156
|
+
// (oldest-first), so a long-running server's trash dir can't grow unbounded.
|
|
157
|
+
const TRASH_DIR_NAME = '.agentgui-trash';
|
|
158
|
+
const TRASH_RETENTION_MS = parseInt(process.env.AGENTGUI_TRASH_RETENTION_MS || '', 10) || 10 * 60 * 1000;
|
|
159
|
+
const TRASH_MAX_ENTRIES = 200;
|
|
160
|
+
const trashIndex = new Map(); // trashId -> { trashPath, originalPath, root, deletedAt }
|
|
161
|
+
|
|
162
|
+
function purgeExpiredTrash() {
|
|
163
|
+
const now = Date.now();
|
|
164
|
+
for (const [id, info] of trashIndex) {
|
|
165
|
+
if (now - info.deletedAt > TRASH_RETENTION_MS) {
|
|
166
|
+
try { fs.rmSync(info.trashPath, { recursive: true, force: true }); } catch { /* already gone */ }
|
|
167
|
+
trashIndex.delete(id);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (trashIndex.size > TRASH_MAX_ENTRIES) {
|
|
171
|
+
const sorted = [...trashIndex.entries()].sort((a, b) => a[1].deletedAt - b[1].deletedAt);
|
|
172
|
+
for (const [id, info] of sorted.slice(0, trashIndex.size - TRASH_MAX_ENTRIES)) {
|
|
173
|
+
try { fs.rmSync(info.trashPath, { recursive: true, force: true }); } catch {}
|
|
174
|
+
trashIndex.delete(id);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Which allowed root a confined realPath lives under - the trash dir sits
|
|
180
|
+
// alongside it (still inside the SAME root, so confineToRoots covers the
|
|
181
|
+
// trash path too - no new unconfined surface).
|
|
182
|
+
function rootFor(realPath, allowRoots) {
|
|
183
|
+
const p = IS_WINDOWS ? realPath.toLowerCase() : realPath;
|
|
184
|
+
return allowRoots.find(r => { const rr = IS_WINDOWS ? r.toLowerCase() : r; return p === rr || p.startsWith(rr + path.sep); });
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function moveToTrash(realPath, allowRoots) {
|
|
188
|
+
purgeExpiredTrash();
|
|
189
|
+
const root = rootFor(realPath, allowRoots);
|
|
190
|
+
if (!root) { const e = new Error('not confined to an allowed root'); e.code = 'EACCES'; throw e; }
|
|
191
|
+
const trashDir = path.join(root, TRASH_DIR_NAME);
|
|
192
|
+
fs.mkdirSync(trashDir, { recursive: true });
|
|
193
|
+
const trashId = crypto.randomBytes(8).toString('hex');
|
|
194
|
+
const base = path.basename(realPath);
|
|
195
|
+
const trashPath = path.join(trashDir, trashId + '__' + base);
|
|
196
|
+
fs.renameSync(realPath, trashPath);
|
|
197
|
+
trashIndex.set(trashId, { trashPath, originalPath: realPath, root, deletedAt: Date.now() });
|
|
198
|
+
return { trashId };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function restoreFromTrash(trashId, allowRoots) {
|
|
202
|
+
const info = trashIndex.get(trashId);
|
|
203
|
+
if (!info) { const e = new Error('nothing to restore - the undo window has expired or this was already restored'); e.code = 'NOT_FOUND'; throw e; }
|
|
204
|
+
// Re-confine the ORIGINAL path at restore time (not trust the cached one
|
|
205
|
+
// blindly) - the allowlist itself doesn't change at runtime, but this keeps
|
|
206
|
+
// restore honoring the exact same confinement contract every other route does.
|
|
207
|
+
const conf = confineToRoots(info.originalPath, allowRoots);
|
|
208
|
+
if (!conf.ok && conf.reason !== 'not found') { const e = new Error('restore target is no longer inside an accessible folder'); e.code = 'CONFLICT'; throw e; }
|
|
209
|
+
if (fs.existsSync(info.originalPath)) { const e = new Error('a file already exists at the original location'); e.code = 'CONFLICT'; throw e; }
|
|
210
|
+
fs.mkdirSync(path.dirname(info.originalPath), { recursive: true });
|
|
211
|
+
fs.renameSync(info.trashPath, info.originalPath);
|
|
212
|
+
trashIndex.delete(trashId);
|
|
213
|
+
return { path: info.originalPath };
|
|
214
|
+
}
|
|
215
|
+
|
|
149
216
|
export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, serveFile, staticDir, messageQueues, getWss, activeExecutions, getACPStatus, discoveredAgents, PKG_VERSION, RATE_LIMIT_MAX, rateLimitMap, routes, PORT }) {
|
|
150
217
|
// Warn operators when CORS_ORIGIN=* is combined with no PASSWORD: any
|
|
151
218
|
// cross-origin page can make credentialless fetch() calls to all /api/*
|
|
@@ -690,13 +757,18 @@ export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, ser
|
|
|
690
757
|
if (isAllowRoot(conf.realPath, allowRoots)) { sendJSON(req, res, 403, { error: 'forbidden: cannot delete an allowed root' }); return; }
|
|
691
758
|
try {
|
|
692
759
|
const st = fs.lstatSync(conf.realPath);
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
760
|
+
// Soft-delete: move into a confined per-root .agentgui-trash/ instead
|
|
761
|
+
// of unlinking, so the only safety net isn't confirm-before (the
|
|
762
|
+
// pre-existing ConfirmDialog) but also undo-after, matching an
|
|
763
|
+
// fsbrowse-grade file manager. A non-empty directory without
|
|
764
|
+
// recursive=true still throws ENOTEMPTY BEFORE any move happens
|
|
765
|
+
// (checked via a dry probe) to keep that existing guard's semantics.
|
|
766
|
+
if (st.isDirectory() && body.recursive !== true) {
|
|
767
|
+
const dryEntries = fs.readdirSync(conf.realPath);
|
|
768
|
+
if (dryEntries.length) { sendJSON(req, res, 409, { error: 'directory is not empty' }); return; }
|
|
698
769
|
}
|
|
699
|
-
|
|
770
|
+
const trashInfo = moveToTrash(conf.realPath, allowRoots);
|
|
771
|
+
sendJSON(req, res, 200, { ok: true, trashId: trashInfo.trashId });
|
|
700
772
|
} catch (err) {
|
|
701
773
|
const code = err.code === 'ENOTEMPTY' ? 409 : (err.code === 'EACCES' || err.code === 'EPERM' ? 403 : (err.code === 'ENOENT' ? 404 : 400));
|
|
702
774
|
sendJSON(req, res, code, { error: err.code === 'ENOTEMPTY' ? 'directory is not empty' : err.message });
|
|
@@ -704,6 +776,22 @@ export function createHttpHandler({ BASE_URL, expressApp, queries, sendJSON, ser
|
|
|
704
776
|
return;
|
|
705
777
|
}
|
|
706
778
|
|
|
779
|
+
// POST /api/restore {trashId} -> {ok, path}. Undoes a /api/delete within
|
|
780
|
+
// its retention window (trashRetentionMs, default 10 minutes) by moving
|
|
781
|
+
// the entry back from .agentgui-trash/ to its original confined path.
|
|
782
|
+
if (routePath.split('?')[0] === '/api/restore' && req.method === 'POST') {
|
|
783
|
+
let body;
|
|
784
|
+
try { body = JSON.parse((await readBody(req, 64 * 1024)).toString('utf8') || '{}'); }
|
|
785
|
+
catch (e) { sendJSON(req, res, e.code === 'TOO_LARGE' ? 413 : 400, { error: 'bad request body' }); return; }
|
|
786
|
+
try {
|
|
787
|
+
const restored = restoreFromTrash(String(body.trashId || ''), fsAllowRoots());
|
|
788
|
+
sendJSON(req, res, 200, { ok: true, path: restored.path });
|
|
789
|
+
} catch (err) {
|
|
790
|
+
sendJSON(req, res, err.code === 'NOT_FOUND' ? 404 : (err.code === 'CONFLICT' ? 409 : 400), { error: err.message });
|
|
791
|
+
}
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
|
|
707
795
|
// POST /api/mkdir {dir, name} -> {ok, path}. dir must exist inside roots.
|
|
708
796
|
if (routePath.split('?')[0] === '/api/mkdir' && req.method === 'POST') {
|
|
709
797
|
let body;
|
package/package.json
CHANGED
package/site/app/js/app.js
CHANGED
|
@@ -1180,10 +1180,13 @@ async function runFileMutation(fn, doneMsg, patch) {
|
|
|
1180
1180
|
if (!d || d.busy) return;
|
|
1181
1181
|
d.busy = true; d.error = null; render();
|
|
1182
1182
|
try {
|
|
1183
|
-
await fn();
|
|
1183
|
+
const result = await fn();
|
|
1184
1184
|
state.files.dialog = null;
|
|
1185
1185
|
restoreFileDialogFocus(d._trigger);
|
|
1186
1186
|
announce(doneMsg);
|
|
1187
|
+
// A soft-delete's trashId (if this mutation was a delete) rides the
|
|
1188
|
+
// return value straight to the undo-toast caller.
|
|
1189
|
+
if (result && result.trashId) offerUndoDelete(result.trashId, doneMsg);
|
|
1187
1190
|
if (patch) {
|
|
1188
1191
|
// Patch the visible list immediately, matching the bulk-delete/move
|
|
1189
1192
|
// pattern, instead of stalling the dialog on a second full round-trip.
|
|
@@ -1200,6 +1203,36 @@ async function runFileMutation(fn, doneMsg, patch) {
|
|
|
1200
1203
|
d.busy = false; d.error = fileMutationCopy(e); render();
|
|
1201
1204
|
}
|
|
1202
1205
|
}
|
|
1206
|
+
// Delete is a soft-delete server-side (moved to a confined .agentgui-trash/,
|
|
1207
|
+
// see lib/http-handler.js) - offer a real undo action within the retention
|
|
1208
|
+
// window instead of the ConfirmDialog's pre-delete confirm being the ONLY
|
|
1209
|
+
// safety net. One active undo toast at a time (the most recent delete wins;
|
|
1210
|
+
// an in-flight bulk-delete calls this per-entry, each replacing the last -
|
|
1211
|
+
// acceptable since restoring the single most recent one is still strictly
|
|
1212
|
+
// better than no undo at all, and stacking N toasts for a bulk op would be
|
|
1213
|
+
// its own UX problem).
|
|
1214
|
+
const UNDO_DELETE_WINDOW_MS = 10000;
|
|
1215
|
+
let _undoDeleteTimer = null;
|
|
1216
|
+
function offerUndoDelete(trashId, doneMsg) {
|
|
1217
|
+
clearTimeout(_undoDeleteTimer);
|
|
1218
|
+
state.files.undoDelete = { trashId, doneMsg };
|
|
1219
|
+
render();
|
|
1220
|
+
_undoDeleteTimer = setTimeout(() => { state.files.undoDelete = null; render(); }, UNDO_DELETE_WINDOW_MS);
|
|
1221
|
+
}
|
|
1222
|
+
async function undoDelete() {
|
|
1223
|
+
const u = state.files.undoDelete;
|
|
1224
|
+
if (!u) return;
|
|
1225
|
+
clearTimeout(_undoDeleteTimer);
|
|
1226
|
+
state.files.undoDelete = null;
|
|
1227
|
+
try {
|
|
1228
|
+
await B.restoreEntry(state.backend, u.trashId);
|
|
1229
|
+
announce('restored');
|
|
1230
|
+
await loadDir(state.files.path, { fromHash: true });
|
|
1231
|
+
} catch (e) {
|
|
1232
|
+
announce('could not restore: ' + fileMutationCopy(e));
|
|
1233
|
+
render();
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1203
1236
|
// Upload a FileList into the current directory; per-file rows feed the kit
|
|
1204
1237
|
// UploadProgress (done/error per file - fetch has no chunk progress).
|
|
1205
1238
|
async function uploadFiles(fileList) {
|
|
@@ -1334,9 +1367,12 @@ function fileDialog() {
|
|
|
1334
1367
|
const isDir = d.file.type === 'dir';
|
|
1335
1368
|
return ConfirmDialog({
|
|
1336
1369
|
title: 'Delete ' + d.file.name,
|
|
1370
|
+
// Delete is soft (moved to trash, undoable for a short window right
|
|
1371
|
+
// after) - the copy no longer overclaims permanence the way an actual
|
|
1372
|
+
// unlink would warrant.
|
|
1337
1373
|
message: isDir
|
|
1338
|
-
? 'Delete this folder and everything inside it?
|
|
1339
|
-
: 'Delete this file?
|
|
1374
|
+
? 'Delete this folder and everything inside it? You can undo this for a few seconds after.'
|
|
1375
|
+
: 'Delete this file? You can undo this for a few seconds after.',
|
|
1340
1376
|
error: d.error || null, busy: !!d.busy,
|
|
1341
1377
|
confirmLabel: d.busy ? 'deleting…' : 'delete', cancelLabel: 'cancel', destructive: true,
|
|
1342
1378
|
onCancel: closeFileDialog,
|
|
@@ -1659,6 +1695,10 @@ function filesMain() {
|
|
|
1659
1695
|
: body;
|
|
1660
1696
|
return [
|
|
1661
1697
|
offlineBanner(),
|
|
1698
|
+
f.undoDelete ? Alert({ key: 'undodel', kind: 'info', title: 'Deleted',
|
|
1699
|
+
children: [
|
|
1700
|
+
h('span', { key: 'udtxt' }, (f.undoDelete.doneMsg || 'Deleted') + ' - undo within a few seconds. '),
|
|
1701
|
+
Btn({ key: 'udbtn', onClick: () => undoDelete(), children: 'undo' })] }) : null,
|
|
1662
1702
|
PageHeader({ compact: true, dense: true, title: 'Files', lede: 'Browse and manage files in the allowed folders.' }),
|
|
1663
1703
|
// One vertical beat (.ds-files-stack gap) for the whole command stack -
|
|
1664
1704
|
// the bands used to butt edge-to-edge while the header gap was 24px.
|
package/site/app/js/backend.js
CHANGED
|
@@ -167,7 +167,10 @@ export async function statPath(base, p) {
|
|
|
167
167
|
}
|
|
168
168
|
|
|
169
169
|
export function renameEntry(base, filePath, newName) { return mutateJSON(base, '/api/rename', { path: filePath, newName }); }
|
|
170
|
+
// deleteEntry's server response now includes trashId - the delete is a soft
|
|
171
|
+
// move-to-trash, not a permanent unlink, so the caller can offer an undo.
|
|
170
172
|
export function deleteEntry(base, filePath, recursive) { return mutateJSON(base, '/api/delete', { path: filePath, recursive: !!recursive }); }
|
|
173
|
+
export function restoreEntry(base, trashId) { return mutateJSON(base, '/api/restore', { trashId }); }
|
|
171
174
|
export function makeDir(base, dirPath, name) { return mutateJSON(base, '/api/mkdir', { dir: dirPath, name }); }
|
|
172
175
|
export function moveEntry(base, filePath, destDir, overwrite) { return mutateJSON(base, '/api/move', { path: filePath, destDir, overwrite: !!overwrite }); }
|
|
173
176
|
|