@konglx/rotom 2.21.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 (189) hide show
  1. package/README.md +417 -0
  2. package/bin/mesh-master.sh +439 -0
  3. package/bin/rotom +29 -0
  4. package/bin/rotom-link.sh +136 -0
  5. package/bin/rotom-send-with-status +57 -0
  6. package/bin/rotom-up.sh +428 -0
  7. package/dist/cli/ask.js +62 -0
  8. package/dist/cli/common.js +321 -0
  9. package/dist/cli/config.js +65 -0
  10. package/dist/cli/directory.js +17 -0
  11. package/dist/cli/executor.js +58 -0
  12. package/dist/cli/fed.js +91 -0
  13. package/dist/cli/group.js +273 -0
  14. package/dist/cli/identity.js +62 -0
  15. package/dist/cli/init.js +268 -0
  16. package/dist/cli/issue.js +202 -0
  17. package/dist/cli/join.js +170 -0
  18. package/dist/cli/link.js +47 -0
  19. package/dist/cli/master.js +51 -0
  20. package/dist/cli/memory.js +307 -0
  21. package/dist/cli/note.js +68 -0
  22. package/dist/cli/repo.js +77 -0
  23. package/dist/cli/rotom.js +277 -0
  24. package/dist/cli/routes.js +118 -0
  25. package/dist/cli/run.js +45 -0
  26. package/dist/cli/schedule.js +237 -0
  27. package/dist/cli/skill.js +173 -0
  28. package/dist/cli/team.js +106 -0
  29. package/dist/executor/claude-code-hook.cjs +80 -0
  30. package/dist/executor/cli-executor.js +8 -0
  31. package/dist/executor/executors/claude-code.js +780 -0
  32. package/dist/executor/executors/codex.js +719 -0
  33. package/dist/executor/executors/hermes-cli.js +855 -0
  34. package/dist/executor/executors/openclaw.js +467 -0
  35. package/dist/executor/executors/pi.js +514 -0
  36. package/dist/executor/index.js +269 -0
  37. package/dist/executor/jsonrpc-transport.js +125 -0
  38. package/dist/executor/process-runner.js +101 -0
  39. package/dist/executor/reasoning-status.js +83 -0
  40. package/dist/executor/repo-cache.js +502 -0
  41. package/dist/executor/session-store.js +188 -0
  42. package/dist/executor/worker-chat.js +257 -0
  43. package/dist/executor/worker-connection.js +89 -0
  44. package/dist/executor/worker-issue.js +264 -0
  45. package/dist/executor/worker.js +877 -0
  46. package/dist/link/pending-requests.js +72 -0
  47. package/dist/link/server.js +233 -0
  48. package/dist/link/visibility-store.js +58 -0
  49. package/dist/master/api/agents.js +333 -0
  50. package/dist/master/api/artifacts.js +271 -0
  51. package/dist/master/api/domains.js +64 -0
  52. package/dist/master/api/groups.js +635 -0
  53. package/dist/master/api/guidance-templates.js +147 -0
  54. package/dist/master/api/index.js +89 -0
  55. package/dist/master/api/issues-patrol.js +172 -0
  56. package/dist/master/api/issues.js +663 -0
  57. package/dist/master/api/links-patrol.js +168 -0
  58. package/dist/master/api/links.js +114 -0
  59. package/dist/master/api/memory.js +259 -0
  60. package/dist/master/api/messages.js +157 -0
  61. package/dist/master/api/notes.js +77 -0
  62. package/dist/master/api/schedule-patterns.js +133 -0
  63. package/dist/master/api/schedules.js +272 -0
  64. package/dist/master/api/sessions.js +158 -0
  65. package/dist/master/api/share.js +269 -0
  66. package/dist/master/api/skills.js +190 -0
  67. package/dist/master/api/teams.js +122 -0
  68. package/dist/master/api/uploads.js +245 -0
  69. package/dist/master/auth.js +134 -0
  70. package/dist/master/dashboard/animations/calico-dozing.apng +0 -0
  71. package/dist/master/dashboard/animations/calico-error.apng +0 -0
  72. package/dist/master/dashboard/animations/calico-happy.apng +0 -0
  73. package/dist/master/dashboard/animations/calico-notification.apng +0 -0
  74. package/dist/master/dashboard/animations/calico-sleeping.apng +0 -0
  75. package/dist/master/dashboard/animations/calico-thinking.apng +0 -0
  76. package/dist/master/dashboard/animations/calico-waking.apng +0 -0
  77. package/dist/master/dashboard/assets/ApprovalCard-C38VV6ko.css +1 -0
  78. package/dist/master/dashboard/assets/ApprovalCard-CHPh2dmE.js +17 -0
  79. package/dist/master/dashboard/assets/ArtifactPanel-P_2gAP7v.js +1 -0
  80. package/dist/master/dashboard/assets/ArtifactPanel-aGHySny5.css +1 -0
  81. package/dist/master/dashboard/assets/css.worker-DaIe3gwK.js +84 -0
  82. package/dist/master/dashboard/assets/editor.worker-BCzxt1at.js +12 -0
  83. package/dist/master/dashboard/assets/html.worker-CKrFyw_2.js +461 -0
  84. package/dist/master/dashboard/assets/index-CChrTn81.css +32 -0
  85. package/dist/master/dashboard/assets/index-Dhu4SN1z.js +181 -0
  86. package/dist/master/dashboard/assets/json.worker-B7c_PmGb.js +49 -0
  87. package/dist/master/dashboard/assets/markdown-CeN5IgdF.js +29 -0
  88. package/dist/master/dashboard/assets/monaco-core-DyX1CsEw.css +1 -0
  89. package/dist/master/dashboard/assets/monaco-core-oQiQUisy.js +833 -0
  90. package/dist/master/dashboard/assets/monaco-setup-CiOPQdmo.js +1 -0
  91. package/dist/master/dashboard/assets/react-vendor-C8IxlyCR.js +67 -0
  92. package/dist/master/dashboard/assets/ts.worker-BhkL8olL.js +51334 -0
  93. package/dist/master/dashboard/assets/useMonaco-ILb4vyPh.js +12 -0
  94. package/dist/master/dashboard/assets/vite-preload-CxJPbCTl.js +1 -0
  95. package/dist/master/dashboard/debug-auth.html +197 -0
  96. package/dist/master/dashboard/favicon.ico +0 -0
  97. package/dist/master/dashboard/index.html +20 -0
  98. package/dist/master/dashboard/rotom-avatar.png +0 -0
  99. package/dist/master/db/agent-sessions.js +60 -0
  100. package/dist/master/db/agent-visibility.js +64 -0
  101. package/dist/master/db/agents.js +119 -0
  102. package/dist/master/db/ask-bridges.js +157 -0
  103. package/dist/master/db/build-update.js +59 -0
  104. package/dist/master/db/core.js +82 -0
  105. package/dist/master/db/domains.js +80 -0
  106. package/dist/master/db/groups.js +316 -0
  107. package/dist/master/db/guidance-templates.js +58 -0
  108. package/dist/master/db/index.js +12 -0
  109. package/dist/master/db/internal.js +45 -0
  110. package/dist/master/db/issues-patrol.js +81 -0
  111. package/dist/master/db/issues.js +373 -0
  112. package/dist/master/db/links.js +221 -0
  113. package/dist/master/db/master-node.js +43 -0
  114. package/dist/master/db/memory.js +272 -0
  115. package/dist/master/db/messages.js +210 -0
  116. package/dist/master/db/notes.js +55 -0
  117. package/dist/master/db/schedule-patterns.js +56 -0
  118. package/dist/master/db/schedules.js +135 -0
  119. package/dist/master/db/skills.js +144 -0
  120. package/dist/master/db/team.js +88 -0
  121. package/dist/master/db/types.js +10 -0
  122. package/dist/master/db.js +12 -0
  123. package/dist/master/embedded.js +133 -0
  124. package/dist/master/federation/client.js +283 -0
  125. package/dist/master/federation/identity.js +133 -0
  126. package/dist/master/federation/manager.js +267 -0
  127. package/dist/master/federation/publisher.js +87 -0
  128. package/dist/master/federation/self-publisher.js +69 -0
  129. package/dist/master/federation/server.js +487 -0
  130. package/dist/master/group-paths.js +208 -0
  131. package/dist/master/offline-queue.js +38 -0
  132. package/dist/master/opc-bootstrap.js +245 -0
  133. package/dist/master/patrol-terminal.js +275 -0
  134. package/dist/master/repo-scan.js +188 -0
  135. package/dist/master/router.js +214 -0
  136. package/dist/master/scheduler-handlers.js +510 -0
  137. package/dist/master/scheduler.js +201 -0
  138. package/dist/master/server.js +203 -0
  139. package/dist/master/services/link-collector.js +82 -0
  140. package/dist/master/services/link-patrol-bootstrap.js +50 -0
  141. package/dist/master/services/memory-extract-prompt.js +34 -0
  142. package/dist/master/services/patrol-bootstrap.js +63 -0
  143. package/dist/master/share-tokens.js +56 -0
  144. package/dist/master/terminal-hub.js +300 -0
  145. package/dist/master/uploads.js +108 -0
  146. package/dist/master/util/fs.js +100 -0
  147. package/dist/master/util/paths.js +50 -0
  148. package/dist/master/util/persona.js +10 -0
  149. package/dist/master/ws-hub/connection.js +928 -0
  150. package/dist/master/ws-hub/conversation.js +290 -0
  151. package/dist/master/ws-hub/directory.js +70 -0
  152. package/dist/master/ws-hub/dispatch-enrich.js +34 -0
  153. package/dist/master/ws-hub/hub.js +136 -0
  154. package/dist/master/ws-hub/index.js +9 -0
  155. package/dist/master/ws-hub/internal.js +35 -0
  156. package/dist/master/ws-hub/routing.js +295 -0
  157. package/dist/master/ws-hub/sessions.js +130 -0
  158. package/dist/master/ws-hub.js +11 -0
  159. package/dist/shared/agent-profile.js +44 -0
  160. package/dist/shared/constants.js +55 -0
  161. package/dist/shared/dedup.js +33 -0
  162. package/dist/shared/group-context.js +62 -0
  163. package/dist/shared/json-codec.js +33 -0
  164. package/dist/shared/logger.js +136 -0
  165. package/dist/shared/mention.js +22 -0
  166. package/dist/shared/network.js +40 -0
  167. package/dist/shared/parse.js +18 -0
  168. package/dist/shared/prompt-composer.js +171 -0
  169. package/dist/shared/protocol/client-messages.js +8 -0
  170. package/dist/shared/protocol/enums.js +6 -0
  171. package/dist/shared/protocol/federation.js +62 -0
  172. package/dist/shared/protocol/guards.js +87 -0
  173. package/dist/shared/protocol/server-messages.js +8 -0
  174. package/dist/shared/protocol/types.js +8 -0
  175. package/dist/shared/protocol.js +19 -0
  176. package/dist/shared/readonly-allowlist.js +122 -0
  177. package/dist/shared/rotom-cli-prompt.js +23 -0
  178. package/dist/shared/skill-context.js +19 -0
  179. package/dist/shared/skill-md.js +43 -0
  180. package/dist/shared/slash-commands.js +50 -0
  181. package/dist/shared/time.js +80 -0
  182. package/dist/shared/title.js +46 -0
  183. package/dist/shared/url-extractor.js +99 -0
  184. package/migrations/001-schema.sql +942 -0
  185. package/package.json +68 -0
  186. package/scripts/fix-node-pty-perms.mjs +46 -0
  187. package/skill/rotom-a2a-communicate/SKILL.md +257 -0
  188. package/skill/rotom-bus-host/SKILL.md +78 -0
  189. package/skill/rotom-bus-host/scripts/poll-replies.sh +148 -0
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Digital Employee Mesh — Share Token Store
3
+ *
4
+ * Ephemeral, in-memory only. Powers the dashboard's "Share" / visitor mode:
5
+ * any Dashboard user can mint a `share_<hex>` token bound to a single group,
6
+ * copy the resulting URL, and grant a third-party read-only access to the
7
+ * group's messages / issues / artifacts / notes — without exposing the agent
8
+ * token and without persisting anything.
9
+ *
10
+ * Lifetime: process memory only. All tokens vanish on Master restart.
11
+ * Tokens can also be revoked explicitly via `revoke()`. No expiry by default
12
+ * (per requirement: "in-memory only, no persistence"); if a TTL is desired
13
+ * later, add it here without changing the public interface.
14
+ */
15
+ import { randomBytes } from "node:crypto";
16
+ export class ShareTokenStore {
17
+ tokens = new Map();
18
+ /** Mint a new share token bound to `groupId`, created by `createdBy`. */
19
+ create(groupId, createdBy) {
20
+ const token = `share_${randomBytes(16).toString("hex")}`;
21
+ const record = {
22
+ token,
23
+ groupId,
24
+ createdBy,
25
+ createdAt: Date.now(),
26
+ };
27
+ this.tokens.set(token, record);
28
+ return record;
29
+ }
30
+ /** Look up a token. Returns undefined if unknown / revoked. */
31
+ resolve(token) {
32
+ return this.tokens.get(token);
33
+ }
34
+ /** Revoke a token. Returns true if a token was removed. */
35
+ revoke(token) {
36
+ return this.tokens.delete(token);
37
+ }
38
+ /** List all tokens a given creator has minted (for UI / cleanup). */
39
+ listByCreator(createdBy) {
40
+ const out = [];
41
+ for (const r of this.tokens.values()) {
42
+ if (r.createdBy === createdBy)
43
+ out.push(r);
44
+ }
45
+ return out;
46
+ }
47
+ /** List all tokens currently bound to a group. */
48
+ listByGroup(groupId) {
49
+ const out = [];
50
+ for (const r of this.tokens.values()) {
51
+ if (r.groupId === groupId)
52
+ out.push(r);
53
+ }
54
+ return out;
55
+ }
56
+ }
@@ -0,0 +1,300 @@
1
+ /**
2
+ * Web terminal hub — bridges browser xterm.js to a node-pty shell rooted in
3
+ * the group's working directory.
4
+ *
5
+ * Wire protocol (JSON over WS):
6
+ * client → server {type:"input", data:string}
7
+ * {type:"resize", cols:number, rows:number}
8
+ * server → client {type:"output", data:string}
9
+ * {type:"exit", code:number|null, signal:number|null}
10
+ * {type:"error", message:string}
11
+ *
12
+ * Mounted on /api/terminal via httpServer 'upgrade' so it shares the master's
13
+ * single port without touching the agent-protocol WSHub.
14
+ *
15
+ * node-pty is loaded lazily. If it isn't installed (optionalDependency), the
16
+ * hub starts in a disabled state and rejects upgrades with 503 — keeping the
17
+ * rest of the master functional.
18
+ */
19
+ import { URL } from "node:url";
20
+ import fs from "node:fs";
21
+ import os from "node:os";
22
+ import path from "node:path";
23
+ import { WebSocketServer, WebSocket } from "ws";
24
+ import { resolveGroupArtifactRoot } from "./group-paths.js";
25
+ import { decodeJson } from "../shared/json-codec.js";
26
+ const TERMINAL_PATH = "/api/terminal";
27
+ const DEFAULT_COLS = 80;
28
+ const DEFAULT_ROWS = 24;
29
+ async function loadPty(logger) {
30
+ try {
31
+ // Built as a runtime expression so tsc doesn't try to resolve the
32
+ // optional `node-pty` package at build time. The module ships with
33
+ // its own bundled .d.ts which we don't depend on (PtyModule above
34
+ // captures the surface we actually use).
35
+ const moduleName = "node-pty";
36
+ const mod = (await import(moduleName));
37
+ return mod.default ?? mod;
38
+ }
39
+ catch (err) {
40
+ logger.warn("[terminal] node-pty unavailable; web terminal disabled. " +
41
+ "Run `pnpm install` (or `npm i node-pty`) to enable.", err instanceof Error ? err.message : err);
42
+ return null;
43
+ }
44
+ }
45
+ export class TerminalHub {
46
+ httpServer;
47
+ db;
48
+ logger;
49
+ wss = null;
50
+ pty = null;
51
+ upgradeHandler = null;
52
+ delegatedUpgradeListeners = [];
53
+ sessions = new Set();
54
+ constructor(httpServer, db, logger) {
55
+ this.httpServer = httpServer;
56
+ this.db = db;
57
+ this.logger = logger;
58
+ }
59
+ async start() {
60
+ this.pty = await loadPty(this.logger);
61
+ // WSHub constructs `WebSocketServer({ server, path: "/ws" })`, which adds
62
+ // its own upgrade listener that aborts handshakes for any path other
63
+ // than /ws. If we just `httpServer.on('upgrade', …)` alongside it, both
64
+ // listeners fire — and WSHub's `abortHandshake` destroys our socket
65
+ // milliseconds after we accept it. To avoid touching WSHub, we capture
66
+ // the existing upgrade listeners, take over as the sole listener, and
67
+ // delegate non-terminal paths back to them.
68
+ this.delegatedUpgradeListeners = this.httpServer
69
+ .listeners("upgrade")
70
+ .slice();
71
+ this.httpServer.removeAllListeners("upgrade");
72
+ if (!this.pty) {
73
+ this.upgradeHandler = (req, socket, head) => {
74
+ if (this.matchPath(req)) {
75
+ this.rejectUpgrade(socket, 503, "node-pty not installed");
76
+ return;
77
+ }
78
+ this.delegateUpgrade(req, socket, head);
79
+ };
80
+ this.httpServer.on("upgrade", this.upgradeHandler);
81
+ return;
82
+ }
83
+ this.wss = new WebSocketServer({ noServer: true });
84
+ this.upgradeHandler = (req, socket, head) => {
85
+ if (!this.matchPath(req)) {
86
+ this.delegateUpgrade(req, socket, head);
87
+ return;
88
+ }
89
+ const parsed = this.parseTarget(req);
90
+ if (!parsed) {
91
+ this.rejectUpgrade(socket, 400, "missing groupId or cwd");
92
+ return;
93
+ }
94
+ // Standalone (cwd) mode skips the group lookup entirely; groupId mode
95
+ // still resolves through the db so it picks up the group's working_dir
96
+ // override (or the default ~/.rotom/artifacts/<groupId>).
97
+ const cwd = parsed.kind === "cwd"
98
+ ? parsed.cwd
99
+ : resolveGroupArtifactRoot(this.db, parsed.groupId);
100
+ const label = parsed.kind === "cwd" ? "standalone" : parsed.groupId;
101
+ this.wss.handleUpgrade(req, socket, head, (ws) => {
102
+ this.handleConnection(ws, label, cwd);
103
+ });
104
+ };
105
+ this.httpServer.on("upgrade", this.upgradeHandler);
106
+ this.logger.info(`[terminal] hub ready at ws path ${TERMINAL_PATH}`);
107
+ }
108
+ stop() {
109
+ if (this.upgradeHandler) {
110
+ this.httpServer.off("upgrade", this.upgradeHandler);
111
+ this.upgradeHandler = null;
112
+ // Restore the listeners we hijacked so other subsystems keep working
113
+ // if the process keeps running after a TerminalHub-only stop.
114
+ for (const fn of this.delegatedUpgradeListeners) {
115
+ this.httpServer.on("upgrade", fn);
116
+ }
117
+ this.delegatedUpgradeListeners = [];
118
+ }
119
+ if (this.wss) {
120
+ this.wss.close();
121
+ this.wss = null;
122
+ }
123
+ }
124
+ delegateUpgrade(req, socket, head) {
125
+ if (this.delegatedUpgradeListeners.length === 0) {
126
+ // No other handler registered → match Node's default and 400 the
127
+ // unsolicited upgrade rather than leaving the socket open.
128
+ this.rejectUpgrade(socket, 400, "no upgrade handler");
129
+ return;
130
+ }
131
+ for (const fn of this.delegatedUpgradeListeners) {
132
+ try {
133
+ fn.call(this.httpServer, req, socket, head);
134
+ }
135
+ catch (err) {
136
+ this.logger.warn("[terminal] delegated upgrade listener threw:", err);
137
+ }
138
+ }
139
+ }
140
+ matchPath(req) {
141
+ if (!req.url)
142
+ return false;
143
+ // url is /api/terminal?groupId=...; strip query
144
+ const idx = req.url.indexOf("?");
145
+ const pathname = idx >= 0 ? req.url.slice(0, idx) : req.url;
146
+ return pathname === TERMINAL_PATH;
147
+ }
148
+ parseTarget(req) {
149
+ try {
150
+ const url = new URL(req.url || "", "http://localhost");
151
+ const cwdParam = url.searchParams.get("cwd");
152
+ if (cwdParam) {
153
+ // Accept any absolute path the master process can actually open.
154
+ // The shell already has full local-user privileges, so this isn't
155
+ // a privilege boundary — we just reject obviously-malformed input
156
+ // so we don't hand node-pty something unusable.
157
+ const trimmed = cwdParam.trim();
158
+ if (!trimmed || trimmed.length > 1024)
159
+ return null;
160
+ if (!path.isAbsolute(trimmed))
161
+ return null;
162
+ return { kind: "cwd", cwd: trimmed };
163
+ }
164
+ const id = url.searchParams.get("groupId");
165
+ if (id && /^[A-Za-z0-9_-]+$/.test(id)) {
166
+ return { kind: "group", groupId: id };
167
+ }
168
+ return null;
169
+ }
170
+ catch {
171
+ return null;
172
+ }
173
+ }
174
+ rejectUpgrade(socket, status, reason) {
175
+ const text = `HTTP/1.1 ${status} ${reason}\r\nConnection: close\r\n\r\n`;
176
+ try {
177
+ socket.write(text);
178
+ }
179
+ catch {
180
+ /* ignore */
181
+ }
182
+ socket.destroy();
183
+ }
184
+ handleConnection(ws, sessionLabel, cwd) {
185
+ if (!this.pty) {
186
+ ws.close(1011, "pty unavailable");
187
+ return;
188
+ }
189
+ const sessionId = `${sessionLabel}:${Date.now()}:${Math.random().toString(36).slice(2, 8)}`;
190
+ this.sessions.add(sessionId);
191
+ // posix_spawnp from node-pty fails with a opaque "posix_spawnp failed"
192
+ // when cwd doesn't exist. The group's working_dir or the default
193
+ // ~/.rotom/artifacts/<groupId> may have never been created. Make sure
194
+ // we hand the pty a real, traversable directory.
195
+ let spawnCwd = cwd;
196
+ try {
197
+ fs.mkdirSync(spawnCwd, { recursive: true });
198
+ }
199
+ catch (err) {
200
+ this.logger.warn(`[terminal] cannot create cwd ${spawnCwd}, falling back to $HOME:`, err);
201
+ spawnCwd = os.homedir();
202
+ }
203
+ if (!fs.existsSync(spawnCwd))
204
+ spawnCwd = os.homedir();
205
+ const shell = process.env.SHELL || "/bin/bash";
206
+ let term;
207
+ try {
208
+ term = this.pty.spawn(shell, ["-l"], {
209
+ name: "xterm-color",
210
+ cols: DEFAULT_COLS,
211
+ rows: DEFAULT_ROWS,
212
+ cwd: spawnCwd,
213
+ env: { ...process.env, TERM: "xterm-256color" },
214
+ });
215
+ }
216
+ catch (err) {
217
+ const msg = err instanceof Error ? err.message : String(err);
218
+ this.logger.error(`[terminal] spawn failed for session=${sessionLabel} shell=${shell} cwd=${spawnCwd}: ${msg}`);
219
+ try {
220
+ ws.send(JSON.stringify({
221
+ type: "error",
222
+ message: `spawn failed (shell=${shell}, cwd=${spawnCwd}): ${msg}`,
223
+ }));
224
+ }
225
+ catch { /* ignore */ }
226
+ ws.close(1011, "spawn failed");
227
+ this.sessions.delete(sessionId);
228
+ return;
229
+ }
230
+ this.logger.info(`[terminal] session ${sessionId} pid=${term.pid} cwd=${spawnCwd}`);
231
+ const dataSub = term.onData((data) => {
232
+ if (ws.readyState !== WebSocket.OPEN)
233
+ return;
234
+ try {
235
+ ws.send(JSON.stringify({ type: "output", data }));
236
+ }
237
+ catch (err) {
238
+ this.logger.warn(`[terminal] send failed for ${sessionId}:`, err);
239
+ }
240
+ });
241
+ const exitSub = term.onExit(({ exitCode, signal }) => {
242
+ try {
243
+ if (ws.readyState === WebSocket.OPEN) {
244
+ ws.send(JSON.stringify({ type: "exit", code: exitCode, signal: signal ?? null }));
245
+ }
246
+ }
247
+ catch { /* ignore */ }
248
+ try {
249
+ ws.close(1000, "pty exit");
250
+ }
251
+ catch { /* ignore */ }
252
+ });
253
+ ws.on("message", (raw) => {
254
+ const m = decodeJson(raw);
255
+ if (!m || typeof m !== "object")
256
+ return;
257
+ if (m.type === "input" && typeof m.data === "string") {
258
+ try {
259
+ term.write(m.data);
260
+ }
261
+ catch (err) {
262
+ this.logger.warn(`[terminal] write failed for ${sessionId}:`, err);
263
+ }
264
+ }
265
+ else if (m.type === "resize" && typeof m.cols === "number" && typeof m.rows === "number") {
266
+ const cols = Math.max(1, Math.min(500, Math.floor(m.cols)));
267
+ const rows = Math.max(1, Math.min(200, Math.floor(m.rows)));
268
+ try {
269
+ term.resize(cols, rows);
270
+ }
271
+ catch (err) {
272
+ this.logger.warn(`[terminal] resize failed for ${sessionId}:`, err);
273
+ }
274
+ }
275
+ });
276
+ const cleanup = () => {
277
+ if (!this.sessions.has(sessionId))
278
+ return;
279
+ this.sessions.delete(sessionId);
280
+ try {
281
+ dataSub.dispose();
282
+ }
283
+ catch { /* ignore */ }
284
+ try {
285
+ exitSub.dispose();
286
+ }
287
+ catch { /* ignore */ }
288
+ try {
289
+ term.kill();
290
+ }
291
+ catch { /* ignore */ }
292
+ this.logger.info(`[terminal] session ${sessionId} closed`);
293
+ };
294
+ ws.on("close", cleanup);
295
+ ws.on("error", (err) => {
296
+ this.logger.warn(`[terminal] ws error for ${sessionId}:`, err);
297
+ cleanup();
298
+ });
299
+ }
300
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Image upload storage helpers.
3
+ *
4
+ * Uploads land under `~/.rotom/uploads/<YYYY-MM>/<groupId>/<file>` so that:
5
+ * • per-month top-level buckets make "delete everything older than X" a
6
+ * single `rm -rf` on a date-prefixed directory
7
+ * • per-group subdirectory keeps a single upload dir from filling up and
8
+ * gives the URL a natural groupId segment for auth/scoping
9
+ * • filename embeds `YYYYMMDD-HHmmss` so files remain sortable/cleanable
10
+ * even if someone flattens the month buckets later
11
+ *
12
+ * URL → disk mapping is `/api/uploads/<groupId>/<filename>` (no DB lookup).
13
+ */
14
+ import fs from "node:fs";
15
+ import os from "node:os";
16
+ import path from "node:path";
17
+ import { randomBytes } from "node:crypto";
18
+ import { toBeijingCompact, toBeijingYearMonth } from "../shared/time.js";
19
+ export const UPLOADS_ROOT = path.join(os.homedir(), ".rotom", "uploads");
20
+ /** Hard ceiling on a single upload's decoded byte length (matches express.json
21
+ * limit in server.ts — keep them in sync). */
22
+ export const MAX_UPLOAD_BYTES = 15 * 1024 * 1024;
23
+ const MIME_TO_EXT = {
24
+ "image/png": "png",
25
+ "image/jpeg": "jpg",
26
+ "image/gif": "gif",
27
+ "image/webp": "webp",
28
+ };
29
+ const EXT_TO_MIME = {
30
+ png: "image/png",
31
+ jpg: "image/jpeg",
32
+ jpeg: "image/jpeg",
33
+ gif: "image/gif",
34
+ webp: "image/webp",
35
+ };
36
+ export function extFromMime(mimeType) {
37
+ return MIME_TO_EXT[mimeType.toLowerCase()] ?? null;
38
+ }
39
+ export function mimeFromExt(ext) {
40
+ return EXT_TO_MIME[ext.toLowerCase().replace(/^\./, "")] ?? null;
41
+ }
42
+ export function isAllowedMime(mimeType) {
43
+ return mimeType.toLowerCase() in MIME_TO_EXT;
44
+ }
45
+ /** Validate an inbound upload. Returns a normalised mimeType + ext on success. */
46
+ export function validateUpload(fileName, mimeType, sizeBytes) {
47
+ if (typeof fileName !== "string" || !fileName.trim()) {
48
+ return { ok: false, error: "fileName is required" };
49
+ }
50
+ if (typeof mimeType !== "string" || !isAllowedMime(mimeType)) {
51
+ return {
52
+ ok: false,
53
+ error: `mimeType not supported (allowed: ${Object.keys(MIME_TO_EXT).join(", ")})`,
54
+ };
55
+ }
56
+ if (!Number.isFinite(sizeBytes) || sizeBytes <= 0) {
57
+ return { ok: false, error: "size must be > 0" };
58
+ }
59
+ if (sizeBytes > MAX_UPLOAD_BYTES) {
60
+ return {
61
+ ok: false,
62
+ error: `file too large: ${sizeBytes} bytes > ${MAX_UPLOAD_BYTES} bytes`,
63
+ };
64
+ }
65
+ const ext = extFromMime(mimeType);
66
+ return { ok: true, mimeType: mimeType.toLowerCase(), ext };
67
+ }
68
+ /** `<YYYYMMDD-HHmmss>-<rand6hex>.<ext>` — sortable + collision-resistant. */
69
+ export function generateUploadFileName(ext) {
70
+ const stamp = toBeijingCompact();
71
+ const rand = randomBytes(3).toString("hex");
72
+ return `${stamp}-${rand}.${ext}`;
73
+ }
74
+ /**
75
+ * Resolve the on-disk directory for a (groupId, now) tuple, creating it.
76
+ * Returns `{ dir, monthDir }` so callers can build the absolute path.
77
+ */
78
+ export function resolveUploadDir(groupId) {
79
+ const monthDir = toBeijingYearMonth();
80
+ const dir = path.join(UPLOADS_ROOT, monthDir, groupId);
81
+ fs.mkdirSync(dir, { recursive: true });
82
+ return { dir, monthDir };
83
+ }
84
+ /**
85
+ * Resolve and verify an absolute path for an inbound (groupId, fileName)
86
+ * GET request. Returns null when the path would escape the group's upload
87
+ * directory or when fileName is malformed.
88
+ */
89
+ export function safeResolveUploadPath(groupId, fileName) {
90
+ if (!fileName || fileName.includes("/") || fileName.includes("\\"))
91
+ return null;
92
+ if (fileName.startsWith("."))
93
+ return null;
94
+ // Walk every <YYYY-MM> bucket — uploads historically lived under any month.
95
+ // Cheap operation: typically <100 month buckets.
96
+ if (!fs.existsSync(UPLOADS_ROOT))
97
+ return null;
98
+ for (const entry of fs.readdirSync(UPLOADS_ROOT)) {
99
+ const candidate = path.join(UPLOADS_ROOT, entry, groupId, fileName);
100
+ const resolved = path.resolve(candidate);
101
+ const groupRoot = path.resolve(path.join(UPLOADS_ROOT, entry, groupId));
102
+ if (!resolved.startsWith(groupRoot + path.sep))
103
+ continue;
104
+ if (fs.existsSync(resolved))
105
+ return resolved;
106
+ }
107
+ return null;
108
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * File-tree walking + content-read helpers shared by artifact endpoints.
3
+ *
4
+ * Previously duplicated between `api/artifacts.ts` (statSync-based, follows
5
+ * symlinks — needed so extraRepo mountPath symlinks walk as directories) and
6
+ * `api/share.ts` (older Dirent-based copy that missed symlinked dirs). The
7
+ * shared `walkDir` keeps the statSync behaviour; the share endpoint now
8
+ * benefits from the same symlink handling.
9
+ */
10
+ import fs from "node:fs";
11
+ import path from "node:path";
12
+ import { toBeijing } from "../../shared/time.js";
13
+ /** Files we serve back as base64 (everything else is read as utf-8 text). */
14
+ export const BINARY_EXTS = new Set([
15
+ ".png", ".jpg", ".jpeg", ".gif", ".ico",
16
+ ".woff", ".woff2", ".ttf", ".eot",
17
+ ".pdf", ".zip",
18
+ ]);
19
+ /** Single-file content cap. Larger files are returned as a placeholder string. */
20
+ export const MAX_CONTENT_SIZE = 500 * 1024;
21
+ /**
22
+ * Recursive directory walk. Skips dotfiles and `node_modules`. Uses
23
+ * `fs.statSync` (follows symlinks) so symlinked directories are walked into
24
+ * rather than reported as files. Sorts directories-first, then by name.
25
+ */
26
+ export function walkDir(dir, base) {
27
+ const entries = [];
28
+ let items;
29
+ try {
30
+ items = fs.readdirSync(dir, { withFileTypes: true });
31
+ }
32
+ catch {
33
+ return entries;
34
+ }
35
+ for (const item of items) {
36
+ if (item.name.startsWith("."))
37
+ continue;
38
+ if (item.name === "node_modules")
39
+ continue;
40
+ const fullPath = path.join(dir, item.name);
41
+ const relPath = path.relative(base, fullPath);
42
+ let stat;
43
+ try {
44
+ stat = fs.statSync(fullPath);
45
+ }
46
+ catch {
47
+ continue;
48
+ }
49
+ if (stat.isDirectory()) {
50
+ entries.push({
51
+ name: item.name,
52
+ path: relPath,
53
+ absPath: fullPath,
54
+ size: 0,
55
+ modifiedTime: toBeijing(stat.mtime),
56
+ type: "directory",
57
+ children: walkDir(fullPath, base),
58
+ });
59
+ }
60
+ else if (stat.isFile()) {
61
+ entries.push({
62
+ name: item.name,
63
+ path: relPath,
64
+ absPath: fullPath,
65
+ size: stat.size,
66
+ modifiedTime: toBeijing(stat.mtime),
67
+ type: "file",
68
+ });
69
+ }
70
+ }
71
+ entries.sort((a, b) => {
72
+ if (a.type !== b.type)
73
+ return a.type === "directory" ? -1 : 1;
74
+ return a.name.localeCompare(b.name);
75
+ });
76
+ return entries;
77
+ }
78
+ /**
79
+ * Resolve `relPath` against `baseDir`, enforce containment, and read the
80
+ * file as either utf-8 text or base64 (per `BINARY_EXTS`). Files larger than
81
+ * `MAX_CONTENT_SIZE` are reported as `too-large` so the caller can return the
82
+ * placeholder text the dashboard expects.
83
+ */
84
+ export function readFileSafely(baseDir, relPath) {
85
+ const resolved = path.resolve(baseDir, relPath);
86
+ if (!resolved.startsWith(path.resolve(baseDir)))
87
+ return { kind: "outside-base" };
88
+ if (!fs.existsSync(resolved))
89
+ return { kind: "missing" };
90
+ const stat = fs.statSync(resolved);
91
+ if (stat.size > MAX_CONTENT_SIZE)
92
+ return { kind: "too-large", size: stat.size };
93
+ const ext = path.extname(resolved).toLowerCase();
94
+ if (BINARY_EXTS.has(ext)) {
95
+ const buf = fs.readFileSync(resolved);
96
+ return { kind: "ok", content: buf.toString("base64"), type: "binary", size: stat.size };
97
+ }
98
+ const content = fs.readFileSync(resolved, "utf-8");
99
+ return { kind: "ok", content, type: "text", size: stat.size };
100
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Filesystem-path validation helpers shared across master/api handlers.
3
+ *
4
+ * `validateWorkingDir` was previously duplicated byte-for-byte in
5
+ * `api/groups.ts` and `api/issues.ts`; both handlers need to coerce a
6
+ * user-supplied working_dir string into a resolved, accessible directory.
7
+ */
8
+ import fs from "node:fs";
9
+ import os from "node:os";
10
+ import path from "node:path";
11
+ /**
12
+ * Coerce a user-supplied working_dir string into a resolved, accessible
13
+ * directory path. Expands `~` / `~/...`, requires an absolute path, and
14
+ * verifies the path is an existing directory with R+X permissions.
15
+ */
16
+ export function validateWorkingDir(input) {
17
+ if (typeof input !== "string")
18
+ return { ok: false, error: "working_dir must be a string" };
19
+ const raw = input.trim();
20
+ if (!raw)
21
+ return { ok: false, error: "working_dir is empty" };
22
+ let expanded = raw;
23
+ if (raw === "~")
24
+ expanded = os.homedir();
25
+ else if (raw.startsWith("~/"))
26
+ expanded = path.join(os.homedir(), raw.slice(2));
27
+ if (!path.isAbsolute(expanded)) {
28
+ return { ok: false, error: `working_dir must be an absolute path (got: ${raw})` };
29
+ }
30
+ const resolved = path.resolve(expanded);
31
+ let stat;
32
+ try {
33
+ stat = fs.statSync(resolved);
34
+ }
35
+ catch (err) {
36
+ if (err?.code === "ENOENT")
37
+ return { ok: false, error: `工作目录不存在: ${resolved}` };
38
+ return { ok: false, error: `工作目录无法访问: ${resolved} (${err?.code ?? err?.message ?? "unknown"})` };
39
+ }
40
+ if (!stat.isDirectory()) {
41
+ return { ok: false, error: `工作目录不是一个目录: ${resolved}` };
42
+ }
43
+ try {
44
+ fs.accessSync(resolved, fs.constants.R_OK | fs.constants.X_OK);
45
+ }
46
+ catch {
47
+ return { ok: false, error: `工作目录无读取/进入权限: ${resolved}` };
48
+ }
49
+ return { ok: true, path: resolved };
50
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * 群里所有定时任务的人设名。
3
+ *
4
+ * 不是 LLM —— 只是一个跑腿的 scheduler:到点检查某 agent 有没有回复,
5
+ * 有就汇报、没就升级。所有用户可见处(系统消息、Dashboard 列表、#reply 胶囊)
6
+ * 统一挂这个名字,避免出现 ask-bridge:<uuid> 这种机器脸。
7
+ *
8
+ * 名字取自鲁滨逊的仆人"星期五":忠实跑腿、传话、汇报。
9
+ */
10
+ export const TIMER_PERSONA_NAME = "星期五";