@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.
- package/README.md +417 -0
- package/bin/mesh-master.sh +439 -0
- package/bin/rotom +29 -0
- package/bin/rotom-link.sh +136 -0
- package/bin/rotom-send-with-status +57 -0
- package/bin/rotom-up.sh +428 -0
- package/dist/cli/ask.js +62 -0
- package/dist/cli/common.js +321 -0
- package/dist/cli/config.js +65 -0
- package/dist/cli/directory.js +17 -0
- package/dist/cli/executor.js +58 -0
- package/dist/cli/fed.js +91 -0
- package/dist/cli/group.js +273 -0
- package/dist/cli/identity.js +62 -0
- package/dist/cli/init.js +268 -0
- package/dist/cli/issue.js +202 -0
- package/dist/cli/join.js +170 -0
- package/dist/cli/link.js +47 -0
- package/dist/cli/master.js +51 -0
- package/dist/cli/memory.js +307 -0
- package/dist/cli/note.js +68 -0
- package/dist/cli/repo.js +77 -0
- package/dist/cli/rotom.js +277 -0
- package/dist/cli/routes.js +118 -0
- package/dist/cli/run.js +45 -0
- package/dist/cli/schedule.js +237 -0
- package/dist/cli/skill.js +173 -0
- package/dist/cli/team.js +106 -0
- package/dist/executor/claude-code-hook.cjs +80 -0
- package/dist/executor/cli-executor.js +8 -0
- package/dist/executor/executors/claude-code.js +780 -0
- package/dist/executor/executors/codex.js +719 -0
- package/dist/executor/executors/hermes-cli.js +855 -0
- package/dist/executor/executors/openclaw.js +467 -0
- package/dist/executor/executors/pi.js +514 -0
- package/dist/executor/index.js +269 -0
- package/dist/executor/jsonrpc-transport.js +125 -0
- package/dist/executor/process-runner.js +101 -0
- package/dist/executor/reasoning-status.js +83 -0
- package/dist/executor/repo-cache.js +502 -0
- package/dist/executor/session-store.js +188 -0
- package/dist/executor/worker-chat.js +257 -0
- package/dist/executor/worker-connection.js +89 -0
- package/dist/executor/worker-issue.js +264 -0
- package/dist/executor/worker.js +877 -0
- package/dist/link/pending-requests.js +72 -0
- package/dist/link/server.js +233 -0
- package/dist/link/visibility-store.js +58 -0
- package/dist/master/api/agents.js +333 -0
- package/dist/master/api/artifacts.js +271 -0
- package/dist/master/api/domains.js +64 -0
- package/dist/master/api/groups.js +635 -0
- package/dist/master/api/guidance-templates.js +147 -0
- package/dist/master/api/index.js +89 -0
- package/dist/master/api/issues-patrol.js +172 -0
- package/dist/master/api/issues.js +663 -0
- package/dist/master/api/links-patrol.js +168 -0
- package/dist/master/api/links.js +114 -0
- package/dist/master/api/memory.js +259 -0
- package/dist/master/api/messages.js +157 -0
- package/dist/master/api/notes.js +77 -0
- package/dist/master/api/schedule-patterns.js +133 -0
- package/dist/master/api/schedules.js +272 -0
- package/dist/master/api/sessions.js +158 -0
- package/dist/master/api/share.js +269 -0
- package/dist/master/api/skills.js +190 -0
- package/dist/master/api/teams.js +122 -0
- package/dist/master/api/uploads.js +245 -0
- package/dist/master/auth.js +134 -0
- package/dist/master/dashboard/animations/calico-dozing.apng +0 -0
- package/dist/master/dashboard/animations/calico-error.apng +0 -0
- package/dist/master/dashboard/animations/calico-happy.apng +0 -0
- package/dist/master/dashboard/animations/calico-notification.apng +0 -0
- package/dist/master/dashboard/animations/calico-sleeping.apng +0 -0
- package/dist/master/dashboard/animations/calico-thinking.apng +0 -0
- package/dist/master/dashboard/animations/calico-waking.apng +0 -0
- package/dist/master/dashboard/assets/ApprovalCard-C38VV6ko.css +1 -0
- package/dist/master/dashboard/assets/ApprovalCard-CHPh2dmE.js +17 -0
- package/dist/master/dashboard/assets/ArtifactPanel-P_2gAP7v.js +1 -0
- package/dist/master/dashboard/assets/ArtifactPanel-aGHySny5.css +1 -0
- package/dist/master/dashboard/assets/css.worker-DaIe3gwK.js +84 -0
- package/dist/master/dashboard/assets/editor.worker-BCzxt1at.js +12 -0
- package/dist/master/dashboard/assets/html.worker-CKrFyw_2.js +461 -0
- package/dist/master/dashboard/assets/index-CChrTn81.css +32 -0
- package/dist/master/dashboard/assets/index-Dhu4SN1z.js +181 -0
- package/dist/master/dashboard/assets/json.worker-B7c_PmGb.js +49 -0
- package/dist/master/dashboard/assets/markdown-CeN5IgdF.js +29 -0
- package/dist/master/dashboard/assets/monaco-core-DyX1CsEw.css +1 -0
- package/dist/master/dashboard/assets/monaco-core-oQiQUisy.js +833 -0
- package/dist/master/dashboard/assets/monaco-setup-CiOPQdmo.js +1 -0
- package/dist/master/dashboard/assets/react-vendor-C8IxlyCR.js +67 -0
- package/dist/master/dashboard/assets/ts.worker-BhkL8olL.js +51334 -0
- package/dist/master/dashboard/assets/useMonaco-ILb4vyPh.js +12 -0
- package/dist/master/dashboard/assets/vite-preload-CxJPbCTl.js +1 -0
- package/dist/master/dashboard/debug-auth.html +197 -0
- package/dist/master/dashboard/favicon.ico +0 -0
- package/dist/master/dashboard/index.html +20 -0
- package/dist/master/dashboard/rotom-avatar.png +0 -0
- package/dist/master/db/agent-sessions.js +60 -0
- package/dist/master/db/agent-visibility.js +64 -0
- package/dist/master/db/agents.js +119 -0
- package/dist/master/db/ask-bridges.js +157 -0
- package/dist/master/db/build-update.js +59 -0
- package/dist/master/db/core.js +82 -0
- package/dist/master/db/domains.js +80 -0
- package/dist/master/db/groups.js +316 -0
- package/dist/master/db/guidance-templates.js +58 -0
- package/dist/master/db/index.js +12 -0
- package/dist/master/db/internal.js +45 -0
- package/dist/master/db/issues-patrol.js +81 -0
- package/dist/master/db/issues.js +373 -0
- package/dist/master/db/links.js +221 -0
- package/dist/master/db/master-node.js +43 -0
- package/dist/master/db/memory.js +272 -0
- package/dist/master/db/messages.js +210 -0
- package/dist/master/db/notes.js +55 -0
- package/dist/master/db/schedule-patterns.js +56 -0
- package/dist/master/db/schedules.js +135 -0
- package/dist/master/db/skills.js +144 -0
- package/dist/master/db/team.js +88 -0
- package/dist/master/db/types.js +10 -0
- package/dist/master/db.js +12 -0
- package/dist/master/embedded.js +133 -0
- package/dist/master/federation/client.js +283 -0
- package/dist/master/federation/identity.js +133 -0
- package/dist/master/federation/manager.js +267 -0
- package/dist/master/federation/publisher.js +87 -0
- package/dist/master/federation/self-publisher.js +69 -0
- package/dist/master/federation/server.js +487 -0
- package/dist/master/group-paths.js +208 -0
- package/dist/master/offline-queue.js +38 -0
- package/dist/master/opc-bootstrap.js +245 -0
- package/dist/master/patrol-terminal.js +275 -0
- package/dist/master/repo-scan.js +188 -0
- package/dist/master/router.js +214 -0
- package/dist/master/scheduler-handlers.js +510 -0
- package/dist/master/scheduler.js +201 -0
- package/dist/master/server.js +203 -0
- package/dist/master/services/link-collector.js +82 -0
- package/dist/master/services/link-patrol-bootstrap.js +50 -0
- package/dist/master/services/memory-extract-prompt.js +34 -0
- package/dist/master/services/patrol-bootstrap.js +63 -0
- package/dist/master/share-tokens.js +56 -0
- package/dist/master/terminal-hub.js +300 -0
- package/dist/master/uploads.js +108 -0
- package/dist/master/util/fs.js +100 -0
- package/dist/master/util/paths.js +50 -0
- package/dist/master/util/persona.js +10 -0
- package/dist/master/ws-hub/connection.js +928 -0
- package/dist/master/ws-hub/conversation.js +290 -0
- package/dist/master/ws-hub/directory.js +70 -0
- package/dist/master/ws-hub/dispatch-enrich.js +34 -0
- package/dist/master/ws-hub/hub.js +136 -0
- package/dist/master/ws-hub/index.js +9 -0
- package/dist/master/ws-hub/internal.js +35 -0
- package/dist/master/ws-hub/routing.js +295 -0
- package/dist/master/ws-hub/sessions.js +130 -0
- package/dist/master/ws-hub.js +11 -0
- package/dist/shared/agent-profile.js +44 -0
- package/dist/shared/constants.js +55 -0
- package/dist/shared/dedup.js +33 -0
- package/dist/shared/group-context.js +62 -0
- package/dist/shared/json-codec.js +33 -0
- package/dist/shared/logger.js +136 -0
- package/dist/shared/mention.js +22 -0
- package/dist/shared/network.js +40 -0
- package/dist/shared/parse.js +18 -0
- package/dist/shared/prompt-composer.js +171 -0
- package/dist/shared/protocol/client-messages.js +8 -0
- package/dist/shared/protocol/enums.js +6 -0
- package/dist/shared/protocol/federation.js +62 -0
- package/dist/shared/protocol/guards.js +87 -0
- package/dist/shared/protocol/server-messages.js +8 -0
- package/dist/shared/protocol/types.js +8 -0
- package/dist/shared/protocol.js +19 -0
- package/dist/shared/readonly-allowlist.js +122 -0
- package/dist/shared/rotom-cli-prompt.js +23 -0
- package/dist/shared/skill-context.js +19 -0
- package/dist/shared/skill-md.js +43 -0
- package/dist/shared/slash-commands.js +50 -0
- package/dist/shared/time.js +80 -0
- package/dist/shared/title.js +46 -0
- package/dist/shared/url-extractor.js +99 -0
- package/migrations/001-schema.sql +942 -0
- package/package.json +68 -0
- package/scripts/fix-node-pty-perms.mjs +46 -0
- package/skill/rotom-a2a-communicate/SKILL.md +257 -0
- package/skill/rotom-bus-host/SKILL.md +78 -0
- 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
|
+
}
|