getbotons 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (191) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +505 -2
  3. package/installer/admin.mjs +6 -0
  4. package/installer/botons.mjs +430 -0
  5. package/installer/brain-clis.mjs +28 -0
  6. package/installer/linger.mjs +39 -0
  7. package/installer/output.mjs +27 -0
  8. package/installer/revision.mjs +30 -0
  9. package/installer/shell-path.mjs +66 -0
  10. package/installer/source-lock.json +2891 -0
  11. package/package.json +26 -4
  12. package/payload/LICENSE +21 -0
  13. package/payload/SECURITY.md +33 -0
  14. package/payload/installer/admin.mjs +6 -0
  15. package/payload/installer/botons.mjs +430 -0
  16. package/payload/installer/brain-clis.mjs +28 -0
  17. package/payload/installer/linger.mjs +39 -0
  18. package/payload/installer/output.mjs +27 -0
  19. package/payload/installer/revision.mjs +30 -0
  20. package/payload/installer/shell-path.mjs +66 -0
  21. package/payload/installer/source-lock.json +2891 -0
  22. package/payload/npm-shrinkwrap.json +51 -0
  23. package/payload/package.json +13 -0
  24. package/payload/scripts/backup.ts +47 -0
  25. package/payload/scripts/botons-backup.service +15 -0
  26. package/payload/scripts/botons-backup.timer +11 -0
  27. package/payload/scripts/botons.service +36 -0
  28. package/payload/scripts/check-browser.ts +90 -0
  29. package/payload/scripts/doctor.ts +790 -0
  30. package/payload/scripts/install-backup-timer.ts +52 -0
  31. package/payload/scripts/install-service.ts +206 -0
  32. package/payload/scripts/login.ts +17 -0
  33. package/payload/scripts/migrate-names.ts +47 -0
  34. package/payload/scripts/pair.ts +163 -0
  35. package/payload/scripts/restore.ts +108 -0
  36. package/payload/server/bin/botons +4 -0
  37. package/payload/server/bin/legacy/minion +4 -0
  38. package/payload/server/bin/legacy/tots +4 -0
  39. package/payload/server/src/acting.ts +16 -0
  40. package/payload/server/src/api-keys.ts +544 -0
  41. package/payload/server/src/api.ts +1484 -0
  42. package/payload/server/src/app.ts +546 -0
  43. package/payload/server/src/approvals.ts +873 -0
  44. package/payload/server/src/ask-rules.ts +181 -0
  45. package/payload/server/src/audit.ts +158 -0
  46. package/payload/server/src/auth.ts +497 -0
  47. package/payload/server/src/backup.ts +382 -0
  48. package/payload/server/src/brain-login.ts +75 -0
  49. package/payload/server/src/browser-cert.ts +67 -0
  50. package/payload/server/src/browser-handoff.ts +119 -0
  51. package/payload/server/src/browser-handover.ts +175 -0
  52. package/payload/server/src/browser-policy.ts +210 -0
  53. package/payload/server/src/browser-proxy.ts +580 -0
  54. package/payload/server/src/browser-request.ts +335 -0
  55. package/payload/server/src/browser-screenshot.ts +56 -0
  56. package/payload/server/src/browser-send.ts +155 -0
  57. package/payload/server/src/browser.ts +2164 -0
  58. package/payload/server/src/callback.ts +218 -0
  59. package/payload/server/src/capabilities.ts +90 -0
  60. package/payload/server/src/cards.ts +243 -0
  61. package/payload/server/src/cli/approve-mcp.ts +119 -0
  62. package/payload/server/src/cli/botons.ts +629 -0
  63. package/payload/server/src/cli/browser-mcp.ts +168 -0
  64. package/payload/server/src/cli/workspace-file.ts +78 -0
  65. package/payload/server/src/cli-homes.ts +38 -0
  66. package/payload/server/src/cli-install.ts +104 -0
  67. package/payload/server/src/cli-updates.ts +112 -0
  68. package/payload/server/src/codex-config-repair.ts +80 -0
  69. package/payload/server/src/config.ts +473 -0
  70. package/payload/server/src/connector-access.ts +158 -0
  71. package/payload/server/src/connector-launch.ts +203 -0
  72. package/payload/server/src/connectors.ts +947 -0
  73. package/payload/server/src/disk-usage.ts +62 -0
  74. package/payload/server/src/engine.ts +2328 -0
  75. package/payload/server/src/env-init.ts +5 -0
  76. package/payload/server/src/events.ts +46 -0
  77. package/payload/server/src/files.ts +528 -0
  78. package/payload/server/src/github-access.ts +52 -0
  79. package/payload/server/src/health.ts +117 -0
  80. package/payload/server/src/held-dir.ts +96 -0
  81. package/payload/server/src/hooks.ts +141 -0
  82. package/payload/server/src/http.ts +207 -0
  83. package/payload/server/src/index.ts +139 -0
  84. package/payload/server/src/install-type.ts +11 -0
  85. package/payload/server/src/instructions.ts +204 -0
  86. package/payload/server/src/isolation.ts +234 -0
  87. package/payload/server/src/legacy-upgrade.ts +668 -0
  88. package/payload/server/src/limits.ts +44 -0
  89. package/payload/server/src/lock.ts +75 -0
  90. package/payload/server/src/log.ts +137 -0
  91. package/payload/server/src/managed-clis.ts +65 -0
  92. package/payload/server/src/mcp-client.ts +229 -0
  93. package/payload/server/src/notify.ts +193 -0
  94. package/payload/server/src/oauth-connections.ts +650 -0
  95. package/payload/server/src/oauth.ts +311 -0
  96. package/payload/server/src/outbound.ts +142 -0
  97. package/payload/server/src/portable.ts +1669 -0
  98. package/payload/server/src/prompt.ts +676 -0
  99. package/payload/server/src/providers/api-brain.ts +612 -0
  100. package/payload/server/src/providers/api-history.ts +131 -0
  101. package/payload/server/src/providers/api-tools.ts +481 -0
  102. package/payload/server/src/providers/api-wire.ts +690 -0
  103. package/payload/server/src/providers/claude-cli.ts +620 -0
  104. package/payload/server/src/providers/cli-check.ts +265 -0
  105. package/payload/server/src/providers/cli-turn.ts +20 -0
  106. package/payload/server/src/providers/codex-cli.ts +552 -0
  107. package/payload/server/src/providers/codex-workspace.ts +61 -0
  108. package/payload/server/src/providers/failures.ts +143 -0
  109. package/payload/server/src/providers/index.ts +131 -0
  110. package/payload/server/src/providers/mcp-stdio.ts +147 -0
  111. package/payload/server/src/providers/mock.ts +94 -0
  112. package/payload/server/src/providers/process.ts +268 -0
  113. package/payload/server/src/providers/readiness.ts +43 -0
  114. package/payload/server/src/providers/sse.ts +73 -0
  115. package/payload/server/src/providers/types.ts +173 -0
  116. package/payload/server/src/rates.ts +128 -0
  117. package/payload/server/src/reactions.ts +266 -0
  118. package/payload/server/src/recent-logs.ts +489 -0
  119. package/payload/server/src/rehearsal.ts +593 -0
  120. package/payload/server/src/rehearsals.ts +303 -0
  121. package/payload/server/src/replay.ts +165 -0
  122. package/payload/server/src/sandbox.ts +767 -0
  123. package/payload/server/src/schedule.ts +214 -0
  124. package/payload/server/src/scheduler.ts +24 -0
  125. package/payload/server/src/schema.ts +882 -0
  126. package/payload/server/src/secrets.ts +46 -0
  127. package/payload/server/src/setup.ts +356 -0
  128. package/payload/server/src/skill-packages.ts +363 -0
  129. package/payload/server/src/skills.ts +527 -0
  130. package/payload/server/src/spend.ts +255 -0
  131. package/payload/server/src/ssrf.ts +126 -0
  132. package/payload/server/src/starter-gate.ts +168 -0
  133. package/payload/server/src/starters.ts +610 -0
  134. package/payload/server/src/storage.ts +32 -0
  135. package/payload/server/src/store.ts +3087 -0
  136. package/payload/server/src/tailnet.ts +132 -0
  137. package/payload/server/src/templates.ts +454 -0
  138. package/payload/server/src/trash.ts +772 -0
  139. package/payload/server/src/util.ts +97 -0
  140. package/payload/server/src/webhooks.ts +216 -0
  141. package/payload/server/src/work-feed.ts +473 -0
  142. package/payload/shared/app-version.d.mts +1 -0
  143. package/payload/shared/app-version.mjs +3 -0
  144. package/payload/shared/boton.ts +323 -0
  145. package/payload/shared/brand.ts +107 -0
  146. package/payload/shared/cards.ts +427 -0
  147. package/payload/shared/effort.ts +91 -0
  148. package/payload/shared/files.ts +104 -0
  149. package/payload/shared/handoff.ts +97 -0
  150. package/payload/shared/install-commands.ts +7 -0
  151. package/payload/shared/legacy-names.d.mts +35 -0
  152. package/payload/shared/legacy-names.mjs +244 -0
  153. package/payload/shared/live-view.ts +94 -0
  154. package/payload/shared/look-critter.ts +189 -0
  155. package/payload/shared/look-tater.ts +127 -0
  156. package/payload/shared/look.ts +200 -0
  157. package/payload/shared/reactions.ts +43 -0
  158. package/payload/shared/seeded.ts +34 -0
  159. package/payload/shared/services.ts +90 -0
  160. package/payload/shared/types.ts +3016 -0
  161. package/payload/shared/util.ts +68 -0
  162. package/payload/shared/versions.ts +44 -0
  163. package/payload/shared/wake.ts +164 -0
  164. package/payload/web/dist/apple-touch-icon.png +0 -0
  165. package/payload/web/dist/assets/Icons-nytVAa9g.js +1 -0
  166. package/payload/web/dist/assets/NewBotDialog-DXr2XhDP.js +1 -0
  167. package/payload/web/dist/assets/PairScreen-CMcJO7cQ.js +1 -0
  168. package/payload/web/dist/assets/Panel-Bvqz3I3m.js +4 -0
  169. package/payload/web/dist/assets/ReactionPicker-B0SKsjiy.js +1 -0
  170. package/payload/web/dist/assets/SettingRows-CvLomtuo.js +4 -0
  171. package/payload/web/dist/assets/SettingsDialog-CQ41LV-P.js +8 -0
  172. package/payload/web/dist/assets/Setup-C-3P7Mtn.js +1 -0
  173. package/payload/web/dist/assets/Skills-DXMH69kH.js +1 -0
  174. package/payload/web/dist/assets/StarterSetup-Ce2V1_Cs.js +5 -0
  175. package/payload/web/dist/assets/Steps-CKtB3GAM.js +1 -0
  176. package/payload/web/dist/assets/emoji-catalog-DfpPz8qw.js +540 -0
  177. package/payload/web/dist/assets/index-BEU7FDxV.css +1 -0
  178. package/payload/web/dist/assets/index-Dhwn-rf0.js +3 -0
  179. package/payload/web/dist/assets/react-C1iE8mnY.js +9 -0
  180. package/payload/web/dist/assets/reactions-rw0YeCmA.js +1 -0
  181. package/payload/web/dist/assets/rolldown-runtime-XKyaHtl9.js +1 -0
  182. package/payload/web/dist/assets/util-Cx5feSJu.js +1 -0
  183. package/payload/web/dist/assets/vendor-B3qdcjGo.js +14 -0
  184. package/payload/web/dist/favicon.svg +1 -0
  185. package/payload/web/dist/icon-192.png +0 -0
  186. package/payload/web/dist/icon-512.png +0 -0
  187. package/payload/web/dist/icon.svg +1 -0
  188. package/payload/web/dist/index.html +67 -0
  189. package/payload/web/dist/manifest.webmanifest +12 -0
  190. package/shared/legacy-names.d.mts +35 -0
  191. package/shared/legacy-names.mjs +244 -0
@@ -0,0 +1,772 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { closeSync, constants, fstatSync, lstatSync, openSync, type Stats } from 'node:fs';
3
+ import { type FileHandle, link, lstat, mkdir, open, readdir, readFile, readlink, realpath, rename, rm, rmdir, unlink, writeFile } from 'node:fs/promises';
4
+ import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
5
+ import { INBOX_PINS_MAX, TRASH_KEEP_DAYS } from '../../shared/files.ts';
6
+ import type { Bot, BotStorage, RestoreResult, StorageUsage, TrashItem, TrashOrigin, TrashReason, TrashResult } from '../../shared/types.ts';
7
+ import { type Audit, type AuditActor, SYSTEM_ACTOR } from './audit.ts';
8
+ import { folderBytes } from './disk-usage.ts';
9
+ import { log } from './log.ts';
10
+ import { HttpError, type Store } from './store.ts';
11
+ import { inside } from './util.ts';
12
+
13
+ /*
14
+ * The server's trash, BOTONS_HOME/trash: what the owner deletes in Files (a Boton's workspace or the shared folder), and
15
+ * what the inbox cleanup and Clear inbox move there. Each item is a folder of its own, `<id>/meta.json` (where it
16
+ * was, whose, when) and `<id>/item` (the file or folder itself, renamed there, never copied), kept TRASH_KEEP_DAYS
17
+ * and then deleted for good.
18
+ *
19
+ * A Boton can write its workspace and the shared folder, and so race the server: it could swap a folder on the way
20
+ * for a link out at any moment. So every change is made through a folder held open (`Dir`): the folder is opened,
21
+ * where it really is is read from the open handle and must be inside, and names in it are reached through the handle
22
+ * (/proc/self/fd/<n>/<name>), so a later swap of any folder on the way changes nothing. The entry itself is never
23
+ * followed (a symlink goes to the trash as the link), and what was moved must be the very file that was checked; if a
24
+ * Boton swapped it meanwhile, what was moved stays in the trash (described as it is) and the delete is refused: putting
25
+ * it back could replace what the Boton put at its name. A restore makes a missing folder only inside a folder held open
26
+ * and checked, and opens and checks each one before going on. Where folders can't be reached by handle (no
27
+ * /proc/self/fd) nothing is moved to or from the trash at all: a path checked and then used is a race a Boton can win.
28
+ *
29
+ * A restore never replaces anything, however a Boton races it. A file is put back by a hard link, which fails if the
30
+ * name is taken; where a link can't be made it stays in the trash. A folder first claims its name with an empty folder
31
+ * of its own (mkdir fails if the name is taken), then is renamed onto it: a rename replaces only an empty folder and
32
+ * fails on anything else, so the most a Boton that swaps the claim can lose is an empty folder.
33
+ *
34
+ * Only a rename moves anything: across two disks (a workspace or shared folder mounted apart from BOTONS_HOME) the
35
+ * delete is refused and nothing changes, since a copy and a remove can't be made both safe and all-or-nothing.
36
+ *
37
+ * An item whose meta.json can't be read or isn't valid (its deletion time not an exact time), or whose deletion time is
38
+ * in the future, is never deleted by the daily cleanup, and is logged: only a folder with nothing in it but (at most)
39
+ * its meta.json, left by a server that stopped mid-move, is.
40
+ */
41
+
42
+ const META = 'meta.json';
43
+ const ITEM = 'item';
44
+ const ID = /^[0-9a-f]{16}$/;
45
+ const DAY_MS = 86_400_000;
46
+ const INBOX = 'inbox';
47
+ /** Each item deleted at once: one request, one audit event, one confirmation. */
48
+ const DELETE_MAX = 500;
49
+ const LAST_CLEANUP = 'storage.lastCleanupAt';
50
+ const SHARED_TARGET = { kind: 'shared', id: 'shared', label: 'Shared folder' };
51
+ export const CROSS_DISK = "It is on another disk than the server's trash (trash/ in BOTONS_HOME), so it can't be moved there; nothing was changed. Delete it with a Boton's own tools, or keep the folder on the same disk as BOTONS_HOME.";
52
+ export const NO_HANDLES = "Botons can't move files to or from the trash safely on this system: it moves them only through folders it holds open (/proc/self/fd, on Linux). Nothing was changed; delete it with a Boton's own tools.";
53
+ export const NO_LINK = "This file can't be put back without risking a file that took its name meanwhile (the disk doesn't take hard links), so it was left in the trash. Download it from there, or delete it forever.";
54
+ const TAKEN = new Set(['EEXIST', 'ENOTEMPTY', 'ENOTDIR', 'EISDIR']);
55
+ const NO_LINKS = new Set(['EPERM', 'ENOTSUP', 'EOPNOTSUPP', 'ENOSYS', 'EMLINK']);
56
+
57
+ let handles: boolean | undefined;
58
+ /** Whether a folder held open can be reached by its handle here: /proc/self/fd/<n> must be that very folder. */
59
+ function canHold(): boolean {
60
+ if (handles !== undefined) return handles;
61
+ let fd: number | undefined;
62
+ try {
63
+ fd = openSync('/', constants.O_RDONLY | constants.O_DIRECTORY);
64
+ const a = fstatSync(fd);
65
+ const b = lstatSync(`/proc/self/fd/${fd}/.`);
66
+ handles = a.dev === b.dev && a.ino === b.ino;
67
+ } catch {
68
+ handles = false;
69
+ } finally {
70
+ if (fd !== undefined) closeSync(fd);
71
+ }
72
+ return handles;
73
+ }
74
+
75
+ interface Meta {
76
+ id: string;
77
+ origin?: TrashOrigin;
78
+ botId: string;
79
+ botName: string;
80
+ path: string;
81
+ name: string;
82
+ isDir: boolean;
83
+ size: number;
84
+ deletedAt: string;
85
+ reason: TrashReason;
86
+ }
87
+
88
+ /** Where a delete or restore happens: a Boton's workspace, or the shared folder. */
89
+ type Space = { origin: 'workspace'; bot: Bot; dir: string } | { origin: 'shared'; dir: string };
90
+
91
+ /** An entry checked for moving: `name` in the folder `folder`, resolved inside `root`; the entry itself not followed. */
92
+ interface Entry {
93
+ space: Space;
94
+ root: string;
95
+ folder: string;
96
+ name: string;
97
+ rel: string;
98
+ stat: Stats;
99
+ }
100
+
101
+ /** Test hooks: a Boton's moves interleaved at the points a race would hit. */
102
+ export interface TrashHooks {
103
+ beforeOpen?: (folder: string) => void | Promise<void>;
104
+ beforeRename?: (from: string) => void | Promise<void>;
105
+ beforeMkdir?: (parent: string, name: string) => void | Promise<void>;
106
+ /** Just before a restore claims `target` (a link for a file, an empty folder for a folder). */
107
+ beforeClaim?: (target: string, isDir: boolean) => void | Promise<void>;
108
+ /** A folder's restore: after its name is claimed, just before it is renamed onto the claim. */
109
+ beforeMoveIn?: (target: string) => void | Promise<void>;
110
+ /** In place of the hard link a file is restored by. */
111
+ link?: (from: string, to: string) => Promise<void>;
112
+ /** As if folders can't be reached by handle here. */
113
+ noHandles?: boolean;
114
+ }
115
+
116
+ const errCode = (err: unknown) => (err as NodeJS.ErrnoException)?.code;
117
+ const exists = (p: string) => lstat(p).then(
118
+ () => true,
119
+ () => false,
120
+ );
121
+ const sameFile = (a: Stats, b: Stats) => a.dev === b.dev && a.ino === b.ino;
122
+ const toRel = (root: string, abs: string) => relative(root, abs).split(sep).join('/');
123
+ const crossDisk = (err: unknown) => (errCode(err) === 'EXDEV' ? new HttpError(409, CROSS_DISK) : err);
124
+
125
+ /** A folder held open (only where handles can be, `canHold()`). `real` is where it was when opened; `where()` is where it is now. */
126
+ class Dir {
127
+ readonly fh: FileHandle;
128
+ readonly real: string;
129
+ constructor(fh: FileHandle, real: string) {
130
+ this.fh = fh;
131
+ this.real = real;
132
+ }
133
+ /** A name in this very folder, wherever it has moved meanwhile. */
134
+ at(name: string): string {
135
+ return `/proc/self/fd/${this.fh.fd}/${name}`;
136
+ }
137
+ where(): Promise<string | null> {
138
+ return readlink(`/proc/self/fd/${this.fh.fd}`).catch(() => null);
139
+ }
140
+ close(): Promise<void> {
141
+ return this.fh.close().catch(() => {});
142
+ }
143
+ }
144
+
145
+ /** Opens a folder (following links on the way) and reads where it really is; null if it isn't a folder. */
146
+ async function openDir(path: string): Promise<Dir | null> {
147
+ let fh: FileHandle;
148
+ try {
149
+ fh = await open(path, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NONBLOCK);
150
+ } catch {
151
+ return null;
152
+ }
153
+ const dir = new Dir(fh, '');
154
+ const real = await dir.where();
155
+ if (!real) {
156
+ await dir.close();
157
+ return null;
158
+ }
159
+ return new Dir(fh, real);
160
+ }
161
+
162
+ /** Opens `path` and checks it is inside `root` (or is it); null, and closed, if not. */
163
+ async function openInside(root: string, path: string): Promise<Dir | null> {
164
+ const dir = await openDir(path);
165
+ if (dir && (dir.real === root || inside(root, dir.real))) return dir;
166
+ await dir?.close();
167
+ return null;
168
+ }
169
+
170
+ export interface TrashOptions {
171
+ home: string;
172
+ store: Store;
173
+ audit: Audit;
174
+ /** The shared folder: the owner deletes from it too. */
175
+ sharedDir: string;
176
+ /** Folders no delete may take, nor any folder holding one: the shared folder, the workspaces folder, BOTONS_HOME. */
177
+ protectedDirs: string[];
178
+ now?: () => number;
179
+ hooks?: TrashHooks;
180
+ }
181
+
182
+ export class Trash {
183
+ readonly dir: string;
184
+ #store: Store;
185
+ #audit: Audit;
186
+ #shared: string;
187
+ #protected: string[];
188
+ #now: () => number;
189
+ #hooks: TrashHooks;
190
+ /** One change at a time, so two restores (or a restore and a purge) of one item never race. */
191
+ #chain: Promise<unknown> = Promise.resolve();
192
+ #housekeeping = false;
193
+
194
+ constructor(opts: TrashOptions) {
195
+ this.dir = join(opts.home, 'trash');
196
+ this.#store = opts.store;
197
+ this.#audit = opts.audit;
198
+ this.#shared = opts.sharedDir;
199
+ this.#protected = opts.protectedDirs.map((d) => resolve(d));
200
+ this.#now = opts.now ?? Date.now;
201
+ this.#hooks = opts.hooks ?? {};
202
+ }
203
+
204
+ /** Moving anything needs folders held open; refused, before anything is touched, where they can't be. */
205
+ #mustHold(): void {
206
+ if (!this.#canHold()) throw new HttpError(501, NO_HANDLES);
207
+ }
208
+
209
+ #canHold(): boolean {
210
+ return !this.#hooks.noHandles && canHold();
211
+ }
212
+
213
+ #serial<T>(fn: () => Promise<T>): Promise<T> {
214
+ const run = this.#chain.then(fn, fn);
215
+ this.#chain = run.catch(() => {});
216
+ return run;
217
+ }
218
+
219
+ async #trashRoot(): Promise<string> {
220
+ await mkdir(this.dir, { recursive: true, mode: 0o700 });
221
+ return realpath(this.dir);
222
+ }
223
+
224
+ #space(origin: TrashOrigin, botId?: string): Space {
225
+ if (origin === 'shared') return { origin, dir: this.#shared };
226
+ const bot = this.#store.getBot(botId ?? '');
227
+ return { origin, bot, dir: bot.workspace };
228
+ }
229
+
230
+ // ------------------------------------------------------------------ paths
231
+
232
+ /**
233
+ * The entry a client's path names, for moving: its folder resolved (symlinks and all) and inside, the entry itself
234
+ * not followed. Never the space's own folder, a workspace's inbox folder, or anything holding a protected folder.
235
+ */
236
+ async #entry(space: Space, input: unknown): Promise<Entry> {
237
+ const label = space.origin === 'shared' ? 'the shared folder' : 'the workspace';
238
+ if (typeof input !== 'string' || !input) throw new HttpError(400, `Each path must be a path in ${label}`);
239
+ if (input.includes('\0')) throw new HttpError(400, 'path is not valid');
240
+ await mkdir(space.dir, { recursive: true });
241
+ const root = await realpath(space.dir);
242
+ const ws = resolve(space.dir);
243
+ const requested = isAbsolute(input) ? resolve(input) : resolve(root, input);
244
+ const base = inside(root, requested) ? root : inside(ws, requested) ? ws : null;
245
+ if (base === null) throw new HttpError(403, `That path is outside ${label}`);
246
+ if (requested === base) throw new HttpError(403, space.origin === 'shared' ? "The shared folder itself can't be deleted" : "The workspace itself can't be deleted");
247
+ let folder: string;
248
+ try {
249
+ folder = await realpath(dirname(requested));
250
+ } catch {
251
+ throw new HttpError(404, `No such file or folder in ${label}: ${input}`);
252
+ }
253
+ if (folder !== root && !inside(root, folder)) throw new HttpError(403, `That path is outside ${label}`);
254
+ const name = requested.slice(requested.lastIndexOf(sep) + 1);
255
+ const abs = join(folder, name);
256
+ let st: Stats;
257
+ try {
258
+ st = await lstat(abs);
259
+ } catch {
260
+ throw new HttpError(404, `No such file or folder in ${label}: ${input}`);
261
+ }
262
+ const rel = toRel(root, abs);
263
+ if (!rel || rel === '.' || rel.startsWith('../')) throw new HttpError(403, `That path is outside ${label}`);
264
+ if (space.origin === 'workspace' && rel === INBOX) throw new HttpError(403, "The inbox folder itself can't be deleted; use Clear inbox to empty it");
265
+ if (st.isDirectory()) {
266
+ for (const p of [...this.#protected, this.dir]) {
267
+ if (inside(abs, p) || inside(abs, await realpath(p).catch(() => p))) throw new HttpError(403, `${rel} holds a folder Botons keeps, so it can't be deleted`);
268
+ }
269
+ }
270
+ return { space, root, folder, name, rel, stat: st };
271
+ }
272
+
273
+ // ------------------------------------------------------------------ moving
274
+
275
+ async #trashOne(entry: Entry, reason: TrashReason): Promise<TrashItem> {
276
+ const trash = await this.#trashRoot();
277
+ await this.#hooks.beforeOpen?.(entry.folder);
278
+ // The folder it is in, held open: a Boton could have swapped it for a link since it was checked.
279
+ const dir = await openInside(entry.root, entry.folder);
280
+ if (!dir) throw new HttpError(409, 'That folder moved while it was being deleted; try again');
281
+ const id = randomBytes(8).toString('hex');
282
+ const home = join(trash, id);
283
+ try {
284
+ const from = dir.at(entry.name);
285
+ const now = await lstat(from).catch(() => null);
286
+ if (!now || !sameFile(now, entry.stat)) throw new HttpError(409, 'That file changed while it was being deleted; try again');
287
+ const size = now.isDirectory() ? (await folderBytes(from)).bytes : now.size;
288
+ const bot = entry.space.origin === 'workspace' ? entry.space.bot : null;
289
+ const meta: Meta = {
290
+ id,
291
+ origin: entry.space.origin,
292
+ botId: bot?.id ?? '',
293
+ botName: bot?.name ?? '',
294
+ path: toRel(entry.root, join(dir.real, entry.name)),
295
+ name: entry.name,
296
+ isDir: now.isDirectory(),
297
+ size,
298
+ deletedAt: new Date(this.#now()).toISOString(),
299
+ reason,
300
+ };
301
+ await mkdir(home, { mode: 0o700 });
302
+ try {
303
+ await writeFile(join(home, META), JSON.stringify(meta), { flag: 'wx', mode: 0o600 });
304
+ await this.#hooks.beforeRename?.(from);
305
+ const where = await dir.where();
306
+ if (!where || !inside(entry.root, where)) throw new HttpError(409, 'That folder moved while it was being deleted; try again');
307
+ await rename(from, join(home, ITEM)).catch((err) => {
308
+ throw crossDisk(err);
309
+ });
310
+ const arrived = await lstat(join(home, ITEM));
311
+ if (!sameFile(arrived, entry.stat)) {
312
+ // Never renamed back (that could replace what a Boton put at the name since): kept in the trash as what it is.
313
+ const kept: Meta = { ...meta, isDir: arrived.isDirectory(), size: arrived.isDirectory() ? (await folderBytes(join(home, ITEM))).bytes : arrived.size };
314
+ await writeFile(join(home, META), JSON.stringify(kept), { mode: 0o600 });
315
+ log.warn('a file changed while it was moved to the trash; what was moved is kept in the trash', { id, path: meta.path });
316
+ throw new HttpError(409, 'That file changed while it was being deleted; what was moved is in the trash. Check the folder and try again');
317
+ }
318
+ } catch (err) {
319
+ if (!(await exists(join(home, ITEM)))) await rm(home, { recursive: true, force: true }).catch(() => {});
320
+ throw err;
321
+ }
322
+ if (bot && meta.path.startsWith(`${INBOX}/`) && !meta.path.slice(INBOX.length + 1).includes('/')) this.#store.setInboxPin(bot.id, meta.name, false);
323
+ return this.#item(meta);
324
+ } finally {
325
+ await dir.close();
326
+ }
327
+ }
328
+
329
+ #item(m: Meta): TrashItem {
330
+ const origin = m.origin ?? 'workspace';
331
+ return {
332
+ ...m,
333
+ origin,
334
+ purgeAt: new Date(Date.parse(m.deletedAt) + TRASH_KEEP_DAYS * DAY_MS).toISOString(),
335
+ botGone: origin === 'workspace' && !this.#store.hasBot(m.botId),
336
+ };
337
+ }
338
+
339
+ // ------------------------------------------------------------------ the owner's actions
340
+
341
+ /** A Boton's workspace files: every path is checked before anything moves; then each goes to the trash. */
342
+ delete(botId: string, paths: unknown, actor: AuditActor): Promise<TrashResult> {
343
+ return this.#serial(() => this.#delete(this.#space('workspace', botId), paths, actor));
344
+ }
345
+
346
+ /** The shared folder's files, the same way. */
347
+ deleteShared(paths: unknown, actor: AuditActor): Promise<TrashResult> {
348
+ return this.#serial(() => this.#delete(this.#space('shared'), paths, actor));
349
+ }
350
+
351
+ async #delete(space: Space, paths: unknown, actor: AuditActor): Promise<TrashResult> {
352
+ this.#mustHold();
353
+ if (!Array.isArray(paths) || !paths.length) throw new HttpError(400, 'paths must be a list of paths');
354
+ if (paths.length > DELETE_MAX) throw new HttpError(400, `At most ${DELETE_MAX} at once`);
355
+ const entries: Entry[] = [];
356
+ const absOf = (e: Entry) => join(e.folder, e.name);
357
+ for (const p of paths) {
358
+ const e = await this.#entry(space, p);
359
+ // A folder and something in it, or one path twice: the folder (or the first) is enough.
360
+ if (entries.some((x) => inside(absOf(x), absOf(e)))) continue;
361
+ for (let i = entries.length - 1; i >= 0; i--) if (inside(absOf(e), absOf(entries[i]))) entries.splice(i, 1);
362
+ entries.push(e);
363
+ }
364
+ const trashed: TrashItem[] = [];
365
+ try {
366
+ for (const e of entries) trashed.push(await this.#trashOne(e, 'deleted'));
367
+ } finally {
368
+ if (trashed.length) this.#record(actor, 'files.trashed', space, trashed);
369
+ }
370
+ return { trashed };
371
+ }
372
+
373
+ /** Everything directly in the inbox but kept (pinned) items and hidden ones (an upload in progress). */
374
+ clearInbox(botId: string, actor: AuditActor): Promise<TrashResult> {
375
+ return this.#serial(async () => {
376
+ const bot = this.#store.getBot(botId);
377
+ this.#mustHold();
378
+ const { trashed, kept } = await this.#sweepInbox(bot, 'inbox_cleared', () => true);
379
+ this.#audit.record(actor, { action: 'inbox.cleared', target: { kind: 'boton', id: bot.id, label: bot.name }, details: { items: trashed.length, bytes: sum(trashed), kept } });
380
+ return { trashed, kept };
381
+ });
382
+ }
383
+
384
+ async #sweepInbox(bot: Bot, reason: TrashReason, take: (st: Stats) => boolean): Promise<{ trashed: TrashItem[]; kept: number }> {
385
+ const root = await realpath(bot.workspace).catch(() => null);
386
+ if (!root) return { trashed: [], kept: 0 };
387
+ const inbox = join(root, INBOX);
388
+ const st = await lstat(inbox).catch(() => null);
389
+ // Only a real folder: an inbox a Boton made a link is not swept at all.
390
+ if (!st?.isDirectory()) return { trashed: [], kept: 0 };
391
+ const pins = new Set(this.#store.inboxPins(bot.id));
392
+ const space = this.#space('workspace', bot.id);
393
+ const trashed: TrashItem[] = [];
394
+ let kept = 0;
395
+ for (const name of await readdir(inbox).catch(() => [] as string[])) {
396
+ if (name.startsWith('.')) continue;
397
+ if (pins.has(name)) {
398
+ kept++;
399
+ continue;
400
+ }
401
+ try {
402
+ const e = await this.#entry(space, `${INBOX}/${name}`);
403
+ if (!take(e.stat)) continue;
404
+ trashed.push(await this.#trashOne(e, reason));
405
+ } catch (err) {
406
+ log.warn('could not move an inbox file to the trash', { botId: bot.id, name, err: (err as Error).message });
407
+ }
408
+ }
409
+ return { trashed, kept };
410
+ }
411
+
412
+ /** A Boton's items (from its workspace), the shared folder's, or every item. */
413
+ list(filter: { botId?: string; origin?: TrashOrigin } = {}): Promise<TrashItem[]> {
414
+ return this.#readAll().then((all) =>
415
+ all
416
+ .map((m) => this.#item(m))
417
+ .filter((i) => (!filter.origin || i.origin === filter.origin) && (!filter.botId || (i.origin === 'workspace' && i.botId === filter.botId)))
418
+ .sort((a, b) => b.deletedAt.localeCompare(a.deletedAt)),
419
+ );
420
+ }
421
+
422
+ async #readAll(): Promise<Meta[]> {
423
+ let ids: string[];
424
+ try {
425
+ ids = await readdir(this.dir);
426
+ } catch {
427
+ return [];
428
+ }
429
+ const out: Meta[] = [];
430
+ for (const id of ids) {
431
+ const m = await this.#meta(id);
432
+ if (m) out.push(m);
433
+ }
434
+ return out;
435
+ }
436
+
437
+ async #meta(id: string): Promise<Meta | null> {
438
+ if (!ID.test(id)) return null;
439
+ try {
440
+ const m = JSON.parse(await readFile(join(this.dir, id, META), 'utf8')) as Meta;
441
+ if (m.id !== id || typeof m.path !== 'string' || typeof m.botId !== 'string') return null;
442
+ if (m.origin !== undefined && m.origin !== 'workspace' && m.origin !== 'shared') return null;
443
+ if (typeof m.name !== 'string' || typeof m.size !== 'number' || deletedTime(m.deletedAt) === null) return null;
444
+ if (!(await exists(join(this.dir, id, ITEM)))) return null;
445
+ return m;
446
+ } catch {
447
+ return null;
448
+ }
449
+ }
450
+
451
+ async #mustMeta(id: string): Promise<Meta> {
452
+ const m = await this.#meta(id);
453
+ if (!m) throw new HttpError(404, 'Nothing in the trash with that id');
454
+ return m;
455
+ }
456
+
457
+ /** Back where it was, or beside it with -2, -3… if that name is taken now; its folders made again if they are gone. */
458
+ restore(id: string, actor: AuditActor): Promise<RestoreResult> {
459
+ return this.#serial(async () => {
460
+ const m = await this.#mustMeta(id);
461
+ const origin = m.origin ?? 'workspace';
462
+ if (origin === 'workspace' && !this.#store.hasBot(m.botId)) throw new HttpError(409, `${m.botName} was deleted, so this can only be deleted forever`);
463
+ const space = this.#space(origin, m.botId);
464
+ this.#mustHold();
465
+ const item = join(this.dir, id, ITEM);
466
+ const st = await lstat(item);
467
+ await mkdir(space.dir, { recursive: true });
468
+ const root = await realpath(space.dir);
469
+ const top = await openInside(root, root);
470
+ if (!top) throw new HttpError(409, 'That folder moved while the file was being restored; try again');
471
+ let into = top;
472
+ try {
473
+ const reached = await this.#restoreFolder(root, top, m.path.includes('/') ? m.path.slice(0, m.path.lastIndexOf('/')) : '');
474
+ into = reached.dir;
475
+ const path = await this.#place(item, into, m.name, st, root);
476
+ await rm(join(this.dir, id), { recursive: true, force: true });
477
+ const target = space.origin === 'workspace' ? { kind: 'boton', id: space.bot.id, label: space.bot.name } : SHARED_TARGET;
478
+ const out: RestoreResult = { origin, path, renamed: path !== m.path, folderGone: !reached.complete };
479
+ this.#audit.record(actor, { action: 'files.restored', target, details: { path, from: m.path, renamed: out.renamed, folderGone: out.folderGone } });
480
+ return out;
481
+ } finally {
482
+ if (into !== top) await into.close();
483
+ await top.close();
484
+ }
485
+ });
486
+ }
487
+
488
+ /**
489
+ * The folder to restore into, held open, made again if it is gone. Where it can't be (a file is in the way, or it
490
+ * leads outside), the nearest folder on the way that can, and `complete` false. One folder at a time: each is made
491
+ * only inside the last one, held open and checked, and is itself opened and checked before the next, so nothing is
492
+ * ever made outside however folders are swapped.
493
+ */
494
+ async #restoreFolder(root: string, top: Dir, rel: string): Promise<{ dir: Dir; complete: boolean }> {
495
+ let at = top;
496
+ for (const seg of rel ? rel.split('/') : []) {
497
+ if (!seg || seg === '.' || seg === '..') return { dir: at, complete: false };
498
+ await this.#hooks.beforeMkdir?.(at.real, seg);
499
+ try {
500
+ await mkdir(at.at(seg));
501
+ } catch (err) {
502
+ if (errCode(err) !== 'EEXIST') return { dir: at, complete: false };
503
+ }
504
+ const next = await openInside(root, at.at(seg));
505
+ if (!next) return { dir: at, complete: false };
506
+ if (at !== top) await at.close();
507
+ at = next;
508
+ }
509
+ return { dir: at, complete: true };
510
+ }
511
+
512
+ /**
513
+ * Into `into` under `name`, or the first of `name-2.ext`, `-3`… nothing is at, never replacing anything: each name is
514
+ * claimed by an operation that fails if it is taken (a hard link for a file; for a folder an empty folder of its
515
+ * own, which it is then renamed onto), so a name a Boton takes meanwhile is simply passed over. Returns where it is
516
+ * now, relative to `root`, read from the folder held open after it landed (a Boton may have moved that folder).
517
+ */
518
+ async #place(item: string, into: Dir, name: string, st: Stats, root: string): Promise<string> {
519
+ const within = (w: string | null): w is string => !!w && (w === root || inside(root, w));
520
+ if (!within(await into.where())) throw new HttpError(409, 'That folder moved while the file was being restored; try again');
521
+ const dot = name.lastIndexOf('.');
522
+ const [stem, ext] = dot > 0 ? [name.slice(0, dot), name.slice(dot)] : [name, ''];
523
+ for (let n = 1; n < 1000; n++) {
524
+ const candidate = n === 1 ? name : `${stem}-${n}${ext}`;
525
+ const target = into.at(candidate);
526
+ await this.#hooks.beforeClaim?.(target, st.isDirectory());
527
+ if (!(st.isDirectory() ? await this.#claimFolder(item, target) : await this.#claimFile(item, target))) continue;
528
+ const landed = await lstat(target).catch(() => null);
529
+ if (!landed || !sameFile(landed, st)) {
530
+ log.error('a restored file was not where it was put', { name: candidate });
531
+ throw new HttpError(409, 'That file changed while it was being restored; try again');
532
+ }
533
+ const where = await into.where();
534
+ if (within(where)) return toRel(root, join(where, candidate));
535
+ log.warn('a restored file’s folder was moved out of reach as it landed', { name: candidate });
536
+ throw new HttpError(409, `It was restored as ${candidate}, but a Boton moved its folder meanwhile, so where it is now is not known`);
537
+ }
538
+ throw new HttpError(409, 'Too many files with that name there');
539
+ }
540
+
541
+ /** A hard link at `target` (false if the name is taken), then the trash's own name for it removed. */
542
+ async #claimFile(item: string, target: string): Promise<boolean> {
543
+ try {
544
+ await (this.#hooks.link ?? link)(item, target);
545
+ } catch (err) {
546
+ const code = errCode(err);
547
+ if (code === 'EEXIST') return false;
548
+ if (code === 'EXDEV') throw new HttpError(409, CROSS_DISK);
549
+ // No atomic way to put it back without risking what may take the name: it stays where it is.
550
+ if (NO_LINKS.has(code ?? '')) throw new HttpError(409, NO_LINK);
551
+ throw err;
552
+ }
553
+ await unlink(item);
554
+ return true;
555
+ }
556
+
557
+ /** An empty folder made at `target` (false if the name is taken), then the folder renamed onto it. */
558
+ async #claimFolder(item: string, target: string): Promise<boolean> {
559
+ try {
560
+ await mkdir(target, { mode: 0o700 });
561
+ } catch (err) {
562
+ if (errCode(err) === 'EEXIST') return false;
563
+ throw err;
564
+ }
565
+ try {
566
+ await this.#hooks.beforeMoveIn?.(target);
567
+ // Replaces only the empty folder just made; fails if anything is in it now, or it is no longer a folder.
568
+ await rename(item, target);
569
+ return true;
570
+ } catch (err) {
571
+ // Only an empty folder is removed: a Boton's file or full folder in its place stays.
572
+ await rmdir(target).catch(() => {});
573
+ const code = errCode(err);
574
+ if (TAKEN.has(code ?? '')) return false;
575
+ throw crossDisk(err);
576
+ }
577
+ }
578
+
579
+ /** Deleted for good, now. */
580
+ purge(id: string, actor: AuditActor): Promise<void> {
581
+ return this.#serial(async () => {
582
+ const m = await this.#mustMeta(id);
583
+ await rm(join(this.dir, id), { recursive: true });
584
+ this.#audit.record(actor, { action: 'files.purged', target: targetOf(m), details: { items: 1, bytes: m.size, paths: [m.path] } });
585
+ });
586
+ }
587
+
588
+ /** Every item of one Boton (from its workspace), or of the shared folder, deleted for good. */
589
+ empty(scope: { botId: string } | { origin: 'shared' }, actor: AuditActor): Promise<{ purged: number }> {
590
+ return this.#serial(async () => {
591
+ const all = (await this.#readAll()).filter((m) => ('botId' in scope ? (m.origin ?? 'workspace') === 'workspace' && m.botId === scope.botId : m.origin === 'shared'));
592
+ const gone: Meta[] = [];
593
+ try {
594
+ for (const m of all) {
595
+ await rm(join(this.dir, m.id), { recursive: true });
596
+ gone.push(m);
597
+ }
598
+ } finally {
599
+ // Only what was deleted is recorded: a failure part way leaves the rest, and says so.
600
+ if (gone.length) this.#audit.record(actor, { action: 'files.purged', target: targetOf(gone[0]), details: { items: gone.length, bytes: gone.reduce((n, m) => n + m.size, 0), paths: gone.map((m) => m.path) } });
601
+ }
602
+ return { purged: gone.length };
603
+ });
604
+ }
605
+
606
+ // ------------------------------------------------------------------ daily
607
+
608
+ /**
609
+ * Once a day (the scheduler asks every tick; a restart doesn't run it again early): items past TRASH_KEEP_DAYS are
610
+ * deleted for good, and inbox files untouched for the owner's cleanup days go to the trash, kept ones left.
611
+ * Only files directly in an inbox: never a folder, a link, anything else in a workspace, or the shared folder.
612
+ */
613
+ async housekeep(opts: { force?: boolean } = {}): Promise<{ purged: number; cleaned: number } | null> {
614
+ if (this.#housekeeping) return null;
615
+ const now = this.#now();
616
+ const last = Number(this.#store.storageState(LAST_CLEANUP) ?? 0);
617
+ if (!opts.force && now - last < DAY_MS) return null;
618
+ this.#housekeeping = true;
619
+ try {
620
+ this.#store.setStorageState(LAST_CLEANUP, String(now));
621
+ return await this.#serial(async () => {
622
+ const purged = await this.#purgeExpired(now);
623
+ let cleaned = 0;
624
+ const days = this.#store.getSettings().inboxCleanupDays;
625
+ if (days !== null && !this.#canHold()) log.warn('the inbox cleanup was skipped: files are moved to the trash only through folders held open (/proc/self/fd, on Linux)');
626
+ else if (days !== null) {
627
+ const cutoff = now - days * DAY_MS;
628
+ for (const bot of this.#store.listBots()) {
629
+ const { trashed } = await this.#sweepInbox(bot, 'inbox_cleanup', (st) => st.isFile() && st.mtimeMs < cutoff);
630
+ cleaned += trashed.length;
631
+ if (trashed.length) this.#record(SYSTEM_ACTOR, 'inbox.cleaned_up', bot, trashed, { days });
632
+ }
633
+ }
634
+ return { purged, cleaned };
635
+ });
636
+ } catch (err) {
637
+ log.error('the daily storage cleanup failed', { err });
638
+ return null;
639
+ } finally {
640
+ this.#housekeeping = false;
641
+ }
642
+ }
643
+
644
+ async #purgeExpired(now: number): Promise<number> {
645
+ let ids: string[];
646
+ try {
647
+ ids = await readdir(this.dir);
648
+ } catch {
649
+ return 0;
650
+ }
651
+ const expired: Meta[] = [];
652
+ for (const id of ids) {
653
+ if (!ID.test(id)) continue;
654
+ const m = await this.#meta(id);
655
+ if (!m) {
656
+ await this.#cleanStaging(id, now);
657
+ continue;
658
+ }
659
+ // #meta takes only an exact time; one after now (a clock set back, or a meta.json written by hand) is kept.
660
+ const at = deletedTime(m.deletedAt) ?? now;
661
+ if (at > now) log.warn('a trash item whose deletion time is in the future was kept as it is', { id });
662
+ if (at + TRASH_KEEP_DAYS * DAY_MS > now) continue;
663
+ try {
664
+ await rm(join(this.dir, id), { recursive: true });
665
+ expired.push(m);
666
+ } catch (err) {
667
+ log.warn('could not delete a trash item', { id, err: (err as Error).message });
668
+ }
669
+ }
670
+ const groups = new Map<string, Meta[]>();
671
+ for (const m of expired) {
672
+ const key = m.origin === 'shared' ? 'shared' : `boton:${m.botId}`;
673
+ groups.set(key, [...(groups.get(key) ?? []), m]);
674
+ }
675
+ for (const ms of groups.values()) {
676
+ this.#audit.record(SYSTEM_ACTOR, { action: 'files.purged', target: targetOf(ms[0]), details: { items: ms.length, bytes: ms.reduce((n, m) => n + m.size, 0), paths: ms.map((m) => m.path), afterDays: TRASH_KEEP_DAYS } });
677
+ }
678
+ return expired.length;
679
+ }
680
+
681
+ /**
682
+ * A trash folder without readable metadata. Only one that is demonstrably empty but for (at most) its meta.json,
683
+ * left by a server that stopped mid-move and an hour old, is removed; anything holding an item, or anything else, or
684
+ * that can't be read, is left as it is (and logged), since it may be the only copy of what was deleted.
685
+ */
686
+ async #cleanStaging(id: string, now: number): Promise<void> {
687
+ const at = join(this.dir, id);
688
+ const st = await lstat(at).catch(() => null);
689
+ if (!st) return;
690
+ const names = st.isDirectory() ? await readdir(at).catch(() => null) : null;
691
+ if (names && names.every((n) => n === META)) {
692
+ if (now - st.mtimeMs < 3_600_000) return;
693
+ // Never its item: a meta.json and nothing else (a file), then the folder only if it is empty by now.
694
+ try {
695
+ if (names.length && (await lstat(join(at, META))).isFile()) await unlink(join(at, META));
696
+ await rmdir(at);
697
+ } catch (err) {
698
+ log.warn('could not remove an unfinished trash item', { id, err: (err as Error).message });
699
+ }
700
+ return;
701
+ }
702
+ log.warn('a trash item whose meta.json is unreadable or not valid was kept as it is', { id });
703
+ }
704
+
705
+ #record(actor: AuditActor, action: 'files.trashed' | 'inbox.cleaned_up', space: Space | Bot, items: TrashItem[], extra: Record<string, number> = {}) {
706
+ const target = 'origin' in space ? (space.origin === 'shared' ? SHARED_TARGET : { kind: 'boton', id: space.bot.id, label: space.bot.name }) : { kind: 'boton', id: space.id, label: space.name };
707
+ this.#audit.record(actor, { action, target, details: { items: items.length, bytes: sum(items), paths: items.map((i) => i.path), ...extra } });
708
+ }
709
+
710
+ // ------------------------------------------------------------------ pins and sizes
711
+
712
+ async setPinned(botId: string, path: unknown, pinned: unknown): Promise<{ path: string; pinned: boolean }> {
713
+ const bot = this.#store.getBot(botId);
714
+ if (typeof pinned !== 'boolean') throw new HttpError(400, 'pinned must be true or false');
715
+ const e = await this.#entry(this.#space('workspace', bot.id), path);
716
+ const name = e.rel.slice(INBOX.length + 1);
717
+ if (!e.rel.startsWith(`${INBOX}/`) || name.includes('/')) throw new HttpError(400, 'Only files and folders directly in the inbox can be kept');
718
+ // At the cap, pins of names no longer in the inbox make room first; past it a new one is refused.
719
+ if (pinned && this.#store.inboxPins(bot.id).length >= INBOX_PINS_MAX) {
720
+ const inbox = await readdir(join(e.root, INBOX)).catch(() => null);
721
+ if (inbox) this.#store.pruneInboxPins(bot.id, new Set(inbox));
722
+ }
723
+ this.#store.setInboxPin(bot.id, name, pinned);
724
+ return { path: e.rel, pinned };
725
+ }
726
+
727
+ async botStorage(bot: Bot, items?: Meta[]): Promise<BotStorage & { partial: boolean }> {
728
+ const mine = (items ?? (await this.#readAll())).filter((m) => (m.origin ?? 'workspace') === 'workspace' && m.botId === bot.id);
729
+ const [ws, inbox] = await Promise.all([folderBytes(bot.workspace), lstat(join(bot.workspace, INBOX)).then((s) => (s.isDirectory() ? folderBytes(join(bot.workspace, INBOX)) : { bytes: 0, partial: false }), () => ({ bytes: 0, partial: false }))]);
730
+ return {
731
+ botId: bot.id,
732
+ name: bot.name,
733
+ workspaceBytes: ws.bytes,
734
+ inboxBytes: inbox.bytes,
735
+ trashBytes: mine.reduce((n, m) => n + m.size, 0),
736
+ trashItems: mine.length,
737
+ partial: ws.partial || inbox.partial,
738
+ };
739
+ }
740
+
741
+ /** The shared folder's size, and its items in the trash. */
742
+ async sharedStorage(items?: Meta[]): Promise<{ sharedBytes: number; sharedTrashBytes: number; sharedTrashItems: number; partial: boolean }> {
743
+ const mine = (items ?? (await this.#readAll())).filter((m) => m.origin === 'shared');
744
+ const shared = await folderBytes(this.#shared);
745
+ return { sharedBytes: shared.bytes, sharedTrashBytes: mine.reduce((n, m) => n + m.size, 0), sharedTrashItems: mine.length, partial: shared.partial };
746
+ }
747
+
748
+ async usage(): Promise<StorageUsage> {
749
+ const items = await this.#readAll();
750
+ const bots = await Promise.all(this.#store.listBots().map((b) => this.botStorage(b, items)));
751
+ const { partial: sharedPartial, ...shared } = await this.sharedStorage(items);
752
+ const settings = this.#store.getSettings();
753
+ return {
754
+ bots: bots.map(({ partial: _, ...b }) => b),
755
+ ...shared,
756
+ trashBytes: items.reduce((n, m) => n + m.size, 0),
757
+ partial: sharedPartial || bots.some((b) => b.partial),
758
+ inboxCleanupDays: settings.inboxCleanupDays,
759
+ trashKeepDays: TRASH_KEEP_DAYS,
760
+ };
761
+ }
762
+ }
763
+
764
+ /** When an item was deleted, from its meta.json: only an exact ISO time (as written), else null. */
765
+ function deletedTime(v: unknown): number | null {
766
+ if (typeof v !== 'string') return null;
767
+ const t = Date.parse(v);
768
+ return Number.isFinite(t) && new Date(t).toISOString() === v ? t : null;
769
+ }
770
+
771
+ const targetOf = (m: Meta) => (m.origin === 'shared' ? SHARED_TARGET : { kind: 'boton', id: m.botId, label: m.botName });
772
+ const sum = (items: TrashItem[]) => items.reduce((n, i) => n + i.size, 0);