@lyric_dev/data-loom 0.4.0 → 0.5.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 +45 -1
- package/dist/autostart.js +141 -0
- package/dist/claudeDesktop.js +82 -0
- package/dist/index.js +88 -7
- package/dist/lifecycle.js +101 -0
- package/dist/mcpServer.js +23 -10
- package/dist/paths.js +39 -0
- package/dist/server.js +118 -32
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -38,6 +38,31 @@ With no argument it uses the current directory. Then open <http://127.0.0.1:4317
|
|
|
38
38
|
|
|
39
39
|
> Published to npm automatically by CI on version tags (`vX.Y.Z`) via GitHub Actions.
|
|
40
40
|
|
|
41
|
+
## Run it always-on (background + autostart)
|
|
42
|
+
|
|
43
|
+
The command above runs in the foreground and stops when you close the terminal. Since the daemon also hosts the MCP endpoint, you usually want it always running. Manage a detached background daemon with:
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
data-loom start [C:\path\to\project] # launch detached; returns immediately
|
|
47
|
+
data-loom status # is it running? where are the logs?
|
|
48
|
+
data-loom stop # stop the background daemon
|
|
49
|
+
data-loom restart [path] # stop then start
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
There is at most one instance: `start` while one is already running is a no-op that just reports it. Background output goes to a log file (path shown by `status`) since there's no attached console — on Windows, `%LOCALAPPDATA%\data-loom\daemon.log` (macOS `~/Library/Application Support/data-loom/`, Linux `${XDG_STATE_HOME:-~/.local/state}/data-loom/`).
|
|
53
|
+
|
|
54
|
+
To have it launch automatically when you log in:
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
data-loom autostart enable # register a per-user login item AND start it now
|
|
58
|
+
data-loom autostart status # is autostart registered?
|
|
59
|
+
data-loom autostart disable # remove the login item (does not stop a running daemon)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
`enable` also starts the daemon immediately; pass `--no-start` to only register for next login. The login item is per-user and needs no admin rights — a Startup-folder shortcut on Windows, a LaunchAgent on macOS, an XDG autostart entry on Linux.
|
|
63
|
+
|
|
64
|
+
Everything is reversible: `data-loom stop`, `data-loom autostart disable`, and `data-loom disconnect claude-desktop` (below) undo each side effect, and each is idempotent.
|
|
65
|
+
|
|
41
66
|
## Run from source (development)
|
|
42
67
|
|
|
43
68
|
Requires Node.js ≥ 20.
|
|
@@ -66,7 +91,15 @@ The running daemon also **hosts an MCP server** over HTTP, so your own Claude se
|
|
|
66
91
|
claude mcp add --transport http --scope user data-loom http://127.0.0.1:4317/mcp
|
|
67
92
|
```
|
|
68
93
|
|
|
69
|
-
The MCP server lives in the daemon, so **DataLoom must be running** for the tools to be reachable (start it with `npx @lyric_dev/data-loom "C:\path\to\your\project"`). It binds to loopback only.
|
|
94
|
+
The MCP server lives in the daemon, so **DataLoom must be running** for the tools to be reachable (start it with `data-loom start` or `npx @lyric_dev/data-loom "C:\path\to\your\project"`). It binds to loopback only.
|
|
95
|
+
|
|
96
|
+
**Claude Desktop** reaches the same daemon — register it with:
|
|
97
|
+
|
|
98
|
+
```
|
|
99
|
+
data-loom connect claude-desktop
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
This adds a `data-loom` entry to Claude Desktop's `claude_desktop_config.json` (additively — your other servers are untouched), pointing at the same loopback endpoint via Claude Desktop's native remote-MCP support. Restart Claude Desktop to pick it up. On older Claude Desktop versions without native remote support, pass `--bridge` to register a stdio↔HTTP bridge (`npx mcp-remote`) instead. Remove it any time with `data-loom disconnect claude-desktop`. As with Claude Code, the daemon must be running for the tools to appear.
|
|
70
103
|
|
|
71
104
|
2. In any project, ask Claude something like *"review DataLoom's open proposals and set the dependencies."* On connect, the server asks Claude to surface any proposal that hasn't been reviewed for dependencies yet, propose the edges, and **confirm with you before writing**. It exposes these tools:
|
|
72
105
|
- `list_projects` — the selectable OpenSpec workspaces plus the current selection (read-only), to discover/confirm a `project` path.
|
|
@@ -81,6 +114,17 @@ The running daemon also **hosts an MCP server** over HTTP, so your own Claude se
|
|
|
81
114
|
|
|
82
115
|
> **Upgrading from an earlier version?** The MCP server used to be a per-project stdio registration (`claude mcp add data-loom -- npx … mcp "<path>"`). That mode is gone. Remove any old per-project `data-loom` registrations and add the single user-scope HTTP one above.
|
|
83
116
|
|
|
117
|
+
## Security
|
|
118
|
+
|
|
119
|
+
DataLoom runs entirely on `127.0.0.1` and is built for a single local user.
|
|
120
|
+
|
|
121
|
+
- **Loopback only** — the daemon (dashboard, WebSocket, and MCP endpoint) binds to the loopback interface and is never exposed to the network.
|
|
122
|
+
- **Host + Origin validated** — every HTTP request and WebSocket upgrade is checked: requests with a non-loopback `Host` (DNS-rebinding) or a non-loopback `Origin` (cross-site requests from a web page) are rejected. Native MCP clients (which send no `Origin`) are allowed. This is what keeps a random website you visit from driving the daemon.
|
|
123
|
+
- **No secrets** — the MCP tools carry only proposal text and change names; the topology view redacts server commands/args and shows scheme+host only. No API keys or config are read or returned, and the daemon holds no credential.
|
|
124
|
+
- **Bounded** — request bodies are capped (4 MB) and MCP sessions are capped and idle-evicted, so a local client can't exhaust memory.
|
|
125
|
+
|
|
126
|
+
The MCP tools can read and write any OpenSpec workspace path you point them at (this is intentional, so the tool can stay LLM-provider-independent). That means: treat the daemon as you would any local dev server — fine for your own machine, not something to run on a shared/multi-user host.
|
|
127
|
+
|
|
84
128
|
## Design principles
|
|
85
129
|
|
|
86
130
|
- **Derived, not stored** — phase/order is a pure function of the OpenSpec files, recomputed on every change. Nothing about ordering is persisted.
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// Per-user login autostart: register the background daemon to launch when the
|
|
2
|
+
// user logs in, using the native, no-elevation mechanism for each OS —
|
|
3
|
+
// Windows a shortcut in the per-user Startup folder
|
|
4
|
+
// macOS a LaunchAgent plist with RunAtLoad
|
|
5
|
+
// Linux an XDG autostart .desktop entry
|
|
6
|
+
// Every registration launches `<node> <script> start`, so a login-triggered
|
|
7
|
+
// launch shares the exact same detached/single-instance/logging path as a
|
|
8
|
+
// manual `data-loom start`.
|
|
9
|
+
import { spawn } from "node:child_process";
|
|
10
|
+
import { writeFile, rm, access, mkdir } from "node:fs/promises";
|
|
11
|
+
import { homedir } from "node:os";
|
|
12
|
+
import { dirname, join } from "node:path";
|
|
13
|
+
const NODE = process.execPath;
|
|
14
|
+
const SCRIPT = process.argv[1];
|
|
15
|
+
const LABEL = "dev.lyric.data-loom";
|
|
16
|
+
function exists(p) {
|
|
17
|
+
return access(p).then(() => true, () => false);
|
|
18
|
+
}
|
|
19
|
+
// ---- Windows: Startup-folder shortcut -------------------------------------
|
|
20
|
+
function winStartupLnk() {
|
|
21
|
+
const appData = process.env.APPDATA ?? join(homedir(), "AppData", "Roaming");
|
|
22
|
+
return join(appData, "Microsoft", "Windows", "Start Menu", "Programs", "Startup", "data-loom.lnk");
|
|
23
|
+
}
|
|
24
|
+
function runPowerShell(script) {
|
|
25
|
+
return new Promise((resolve, reject) => {
|
|
26
|
+
const ps = spawn("powershell.exe", ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script], { windowsHide: true, stdio: "ignore" });
|
|
27
|
+
ps.on("error", reject);
|
|
28
|
+
ps.on("exit", (code) => (code === 0 ? resolve() : reject(new Error(`powershell exited ${code}`))));
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
async function winEnable() {
|
|
32
|
+
const lnk = winStartupLnk();
|
|
33
|
+
await mkdir(dirname(lnk), { recursive: true });
|
|
34
|
+
// Create the .lnk via WScript.Shell; WindowStyle 7 = minimized so the brief
|
|
35
|
+
// launcher console doesn't grab focus (the daemon itself is detached).
|
|
36
|
+
const q = (s) => `'${s.replace(/'/g, "''")}'`;
|
|
37
|
+
await runPowerShell([
|
|
38
|
+
"$s = (New-Object -ComObject WScript.Shell).CreateShortcut(" + q(lnk) + ")",
|
|
39
|
+
"$s.TargetPath = " + q(NODE),
|
|
40
|
+
'$s.Arguments = ' + q(`"${SCRIPT}" start`),
|
|
41
|
+
"$s.WindowStyle = 7",
|
|
42
|
+
"$s.Description = 'DataLoom background daemon'",
|
|
43
|
+
"$s.Save()",
|
|
44
|
+
].join("; "));
|
|
45
|
+
}
|
|
46
|
+
async function winDisable() {
|
|
47
|
+
await rm(winStartupLnk(), { force: true }).catch(() => { });
|
|
48
|
+
}
|
|
49
|
+
// ---- macOS: LaunchAgent ----------------------------------------------------
|
|
50
|
+
function macPlistPath() {
|
|
51
|
+
return join(homedir(), "Library", "LaunchAgents", `${LABEL}.plist`);
|
|
52
|
+
}
|
|
53
|
+
async function macEnable() {
|
|
54
|
+
const path = macPlistPath();
|
|
55
|
+
await mkdir(dirname(path), { recursive: true });
|
|
56
|
+
const plist = `<?xml version="1.0" encoding="UTF-8"?>
|
|
57
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
58
|
+
<plist version="1.0">
|
|
59
|
+
<dict>
|
|
60
|
+
<key>Label</key><string>${LABEL}</string>
|
|
61
|
+
<key>ProgramArguments</key>
|
|
62
|
+
<array>
|
|
63
|
+
<string>${NODE}</string>
|
|
64
|
+
<string>${SCRIPT}</string>
|
|
65
|
+
<string>start</string>
|
|
66
|
+
</array>
|
|
67
|
+
<key>RunAtLoad</key><true/>
|
|
68
|
+
</dict>
|
|
69
|
+
</plist>
|
|
70
|
+
`;
|
|
71
|
+
await writeFile(path, plist, "utf8");
|
|
72
|
+
await new Promise((resolve) => {
|
|
73
|
+
const p = spawn("launchctl", ["load", "-w", path], { stdio: "ignore" });
|
|
74
|
+
p.on("error", () => resolve()); // best-effort; the plist is written regardless
|
|
75
|
+
p.on("exit", () => resolve());
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
async function macDisable() {
|
|
79
|
+
const path = macPlistPath();
|
|
80
|
+
if (await exists(path)) {
|
|
81
|
+
await new Promise((resolve) => {
|
|
82
|
+
const p = spawn("launchctl", ["unload", "-w", path], { stdio: "ignore" });
|
|
83
|
+
p.on("error", () => resolve());
|
|
84
|
+
p.on("exit", () => resolve());
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
await rm(path, { force: true }).catch(() => { });
|
|
88
|
+
}
|
|
89
|
+
// ---- Linux: XDG autostart --------------------------------------------------
|
|
90
|
+
function linuxDesktopPath() {
|
|
91
|
+
const cfg = process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config");
|
|
92
|
+
return join(cfg, "autostart", "data-loom.desktop");
|
|
93
|
+
}
|
|
94
|
+
async function linuxEnable() {
|
|
95
|
+
await mkdir(dirname(linuxDesktopPath()), { recursive: true });
|
|
96
|
+
const entry = `[Desktop Entry]
|
|
97
|
+
Type=Application
|
|
98
|
+
Name=DataLoom
|
|
99
|
+
Comment=DataLoom background daemon
|
|
100
|
+
Exec="${NODE}" "${SCRIPT}" start
|
|
101
|
+
X-GNOME-Autostart-enabled=true
|
|
102
|
+
`;
|
|
103
|
+
await writeFile(linuxDesktopPath(), entry, "utf8");
|
|
104
|
+
}
|
|
105
|
+
async function linuxDisable() {
|
|
106
|
+
await rm(linuxDesktopPath(), { force: true }).catch(() => { });
|
|
107
|
+
}
|
|
108
|
+
// ---- Dispatch --------------------------------------------------------------
|
|
109
|
+
function unsupported() {
|
|
110
|
+
throw new Error(`[data-loom] autostart is not supported on platform "${process.platform}"`);
|
|
111
|
+
}
|
|
112
|
+
/** Register the daemon to launch at login (idempotent — overwrites any prior). */
|
|
113
|
+
export async function enable() {
|
|
114
|
+
if (process.platform === "win32")
|
|
115
|
+
return winEnable();
|
|
116
|
+
if (process.platform === "darwin")
|
|
117
|
+
return macEnable();
|
|
118
|
+
if (process.platform === "linux")
|
|
119
|
+
return linuxEnable();
|
|
120
|
+
unsupported();
|
|
121
|
+
}
|
|
122
|
+
/** Remove the login registration (idempotent — succeeds when not enabled). */
|
|
123
|
+
export async function disable() {
|
|
124
|
+
if (process.platform === "win32")
|
|
125
|
+
return winDisable();
|
|
126
|
+
if (process.platform === "darwin")
|
|
127
|
+
return macDisable();
|
|
128
|
+
if (process.platform === "linux")
|
|
129
|
+
return linuxDisable();
|
|
130
|
+
unsupported();
|
|
131
|
+
}
|
|
132
|
+
/** True iff a login registration currently exists for this user. */
|
|
133
|
+
export async function isEnabled() {
|
|
134
|
+
if (process.platform === "win32")
|
|
135
|
+
return exists(winStartupLnk());
|
|
136
|
+
if (process.platform === "darwin")
|
|
137
|
+
return exists(macPlistPath());
|
|
138
|
+
if (process.platform === "linux")
|
|
139
|
+
return exists(linuxDesktopPath());
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// Claude Desktop integration: register DataLoom's loopback MCP endpoint into
|
|
2
|
+
// Claude Desktop's claude_desktop_config.json so the same running daemon that
|
|
3
|
+
// serves Claude Code also serves Claude Desktop.
|
|
4
|
+
//
|
|
5
|
+
// By default we write the native remote/HTTP form (a URL entry) — no extra
|
|
6
|
+
// process, one daemon as the single source of truth. Claude Desktop versions
|
|
7
|
+
// without native remote support use the stdio bridge (`npx mcp-remote <url>`),
|
|
8
|
+
// which the caller forces with `--bridge`. Either way the daemon needs no change:
|
|
9
|
+
// it already permits native (no-Origin) MCP clients on loopback.
|
|
10
|
+
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
11
|
+
import { homedir } from "node:os";
|
|
12
|
+
import { dirname, join } from "node:path";
|
|
13
|
+
import { mcpUrl } from "./paths.js";
|
|
14
|
+
const KEY = "data-loom";
|
|
15
|
+
/** Per-OS location of Claude Desktop's config file. */
|
|
16
|
+
export function configPath() {
|
|
17
|
+
if (process.platform === "win32") {
|
|
18
|
+
const appData = process.env.APPDATA ?? join(homedir(), "AppData", "Roaming");
|
|
19
|
+
return join(appData, "Claude", "claude_desktop_config.json");
|
|
20
|
+
}
|
|
21
|
+
if (process.platform === "darwin") {
|
|
22
|
+
return join(homedir(), "Library", "Application Support", "Claude", "claude_desktop_config.json");
|
|
23
|
+
}
|
|
24
|
+
const cfg = process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config");
|
|
25
|
+
return join(cfg, "Claude", "claude_desktop_config.json");
|
|
26
|
+
}
|
|
27
|
+
/** Read + parse the config, or return {} when absent. Throws on malformed JSON. */
|
|
28
|
+
async function readConfig(path) {
|
|
29
|
+
let raw;
|
|
30
|
+
try {
|
|
31
|
+
raw = await readFile(path, "utf8");
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return {};
|
|
35
|
+
}
|
|
36
|
+
if (!raw.trim())
|
|
37
|
+
return {};
|
|
38
|
+
try {
|
|
39
|
+
const parsed = JSON.parse(raw);
|
|
40
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
41
|
+
throw new Error("not a JSON object");
|
|
42
|
+
}
|
|
43
|
+
return parsed;
|
|
44
|
+
}
|
|
45
|
+
catch (e) {
|
|
46
|
+
throw new Error(`[data-loom] ${path} is not valid JSON (${e instanceof Error ? e.message : "parse error"}); ` +
|
|
47
|
+
"fix or remove it, then retry — DataLoom will not overwrite it.");
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
async function writeConfig(path, cfg) {
|
|
51
|
+
await mkdir(dirname(path), { recursive: true });
|
|
52
|
+
await writeFile(path, JSON.stringify(cfg, null, 2) + "\n", "utf8");
|
|
53
|
+
}
|
|
54
|
+
/** The MCP entry we register — native URL form by default, stdio bridge on demand. */
|
|
55
|
+
function entry(bridge) {
|
|
56
|
+
const url = mcpUrl();
|
|
57
|
+
return bridge ? { command: "npx", args: ["-y", "mcp-remote", url] } : { type: "http", url };
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Add/update DataLoom's entry under mcpServers, preserving everything else.
|
|
61
|
+
* Returns the path written so the caller can report it.
|
|
62
|
+
*/
|
|
63
|
+
export async function connect(opts = {}) {
|
|
64
|
+
const path = configPath();
|
|
65
|
+
const cfg = await readConfig(path);
|
|
66
|
+
cfg.mcpServers = { ...(cfg.mcpServers ?? {}), [KEY]: entry(opts.bridge ?? false) };
|
|
67
|
+
await writeConfig(path, cfg);
|
|
68
|
+
return path;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Remove only DataLoom's entry. Idempotent — returns false when nothing was
|
|
72
|
+
* registered (so the caller can say "nothing to remove").
|
|
73
|
+
*/
|
|
74
|
+
export async function disconnect() {
|
|
75
|
+
const path = configPath();
|
|
76
|
+
const cfg = await readConfig(path);
|
|
77
|
+
if (!cfg.mcpServers || !(KEY in cfg.mcpServers))
|
|
78
|
+
return { path, removed: false };
|
|
79
|
+
delete cfg.mcpServers[KEY];
|
|
80
|
+
await writeConfig(path, cfg);
|
|
81
|
+
return { path, removed: true };
|
|
82
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -12,8 +12,12 @@ import { discover } from "./mcp/discovery.js";
|
|
|
12
12
|
import { checkServer } from "./mcp/availability.js";
|
|
13
13
|
import { discoverProjects, isViewableProject } from "./projects.js";
|
|
14
14
|
import { resolvePublicDir } from "./assets.js";
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
import { HOST, PORT } from "./paths.js";
|
|
16
|
+
import * as lifecycle from "./lifecycle.js";
|
|
17
|
+
import * as autostart from "./autostart.js";
|
|
18
|
+
import * as claudeDesktop from "./claudeDesktop.js";
|
|
19
|
+
const host = HOST;
|
|
20
|
+
const port = PORT;
|
|
17
21
|
// Initial project: CLI argument, then DATA_LOOM_ROOT, then cwd.
|
|
18
22
|
const initialProject = resolve(process.argv[2] ?? process.env.DATA_LOOM_ROOT ?? process.cwd());
|
|
19
23
|
let session;
|
|
@@ -124,7 +128,9 @@ function getProjects() {
|
|
|
124
128
|
return discoverProjects(session?.project ?? "");
|
|
125
129
|
}
|
|
126
130
|
function openBrowser(url) {
|
|
127
|
-
|
|
131
|
+
// Detached background runs have no attached console and no user waiting at the
|
|
132
|
+
// terminal, so never pop a browser for them (nor when explicitly suppressed).
|
|
133
|
+
if (process.env.DATA_LOOM_NO_OPEN || process.env.DATA_LOOM_DETACHED)
|
|
128
134
|
return;
|
|
129
135
|
try {
|
|
130
136
|
if (process.platform === "win32") {
|
|
@@ -141,7 +147,82 @@ function openBrowser(url) {
|
|
|
141
147
|
/* non-fatal — the URL is already logged */
|
|
142
148
|
}
|
|
143
149
|
}
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
150
|
+
// ---- CLI verb dispatch -----------------------------------------------------
|
|
151
|
+
// A leading reserved verb selects a lifecycle/autostart/integration command;
|
|
152
|
+
// anything else falls through to the foreground daemon (with argv[2] as the
|
|
153
|
+
// optional project path), preserving the original invocation unchanged.
|
|
154
|
+
const VERBS = new Set(["start", "stop", "restart", "status", "autostart", "connect", "disconnect"]);
|
|
155
|
+
async function runAutostart(rest) {
|
|
156
|
+
const sub = rest[0];
|
|
157
|
+
if (sub === "enable") {
|
|
158
|
+
await autostart.enable();
|
|
159
|
+
console.log("[data-loom] autostart enabled (launches on login)");
|
|
160
|
+
// Enabling also brings the daemon up now, so it's running this session too —
|
|
161
|
+
// opt out with --no-start.
|
|
162
|
+
if (!rest.includes("--no-start"))
|
|
163
|
+
await lifecycle.start();
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
if (sub === "disable") {
|
|
167
|
+
await autostart.disable();
|
|
168
|
+
console.log("[data-loom] autostart disabled");
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
if (sub === "status") {
|
|
172
|
+
console.log((await autostart.isEnabled())
|
|
173
|
+
? "[data-loom] autostart is enabled"
|
|
174
|
+
: "[data-loom] autostart is not enabled");
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
throw new Error("usage: data-loom autostart <enable|disable|status> [--no-start]");
|
|
178
|
+
}
|
|
179
|
+
async function runConnect(rest) {
|
|
180
|
+
if (rest[0] !== "claude-desktop")
|
|
181
|
+
throw new Error("usage: data-loom connect claude-desktop [--bridge]");
|
|
182
|
+
const bridge = rest.includes("--bridge");
|
|
183
|
+
const path = await claudeDesktop.connect({ bridge });
|
|
184
|
+
console.log(`[data-loom] registered DataLoom in Claude Desktop (${bridge ? "stdio bridge" : "native HTTP"}) — ${path}`);
|
|
185
|
+
console.log("[data-loom] restart Claude Desktop to pick it up; DataLoom must be running to serve the tools.");
|
|
186
|
+
}
|
|
187
|
+
async function runDisconnect(rest) {
|
|
188
|
+
if (rest[0] !== "claude-desktop")
|
|
189
|
+
throw new Error("usage: data-loom disconnect claude-desktop");
|
|
190
|
+
const { path, removed } = await claudeDesktop.disconnect();
|
|
191
|
+
console.log(removed
|
|
192
|
+
? `[data-loom] removed DataLoom from Claude Desktop — ${path}`
|
|
193
|
+
: "[data-loom] DataLoom was not registered in Claude Desktop — nothing to remove");
|
|
194
|
+
}
|
|
195
|
+
async function runCli(argv) {
|
|
196
|
+
const [verb, ...rest] = argv;
|
|
197
|
+
switch (verb) {
|
|
198
|
+
case "start":
|
|
199
|
+
return lifecycle.start(rest[0]);
|
|
200
|
+
case "stop":
|
|
201
|
+
return lifecycle.stop();
|
|
202
|
+
case "restart":
|
|
203
|
+
return lifecycle.restart(rest[0]);
|
|
204
|
+
case "status":
|
|
205
|
+
return lifecycle.status();
|
|
206
|
+
case "autostart":
|
|
207
|
+
return runAutostart(rest);
|
|
208
|
+
case "connect":
|
|
209
|
+
return runConnect(rest);
|
|
210
|
+
case "disconnect":
|
|
211
|
+
return runDisconnect(rest);
|
|
212
|
+
default:
|
|
213
|
+
throw new Error(`unknown command: ${verb}`);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
const cliArgs = process.argv.slice(2);
|
|
217
|
+
if (cliArgs[0] && VERBS.has(cliArgs[0])) {
|
|
218
|
+
runCli(cliArgs).catch((err) => {
|
|
219
|
+
console.error(err instanceof Error ? err.message : err);
|
|
220
|
+
process.exit(1);
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
else {
|
|
224
|
+
main().catch((err) => {
|
|
225
|
+
console.error(err);
|
|
226
|
+
process.exit(1);
|
|
227
|
+
});
|
|
228
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// Background lifecycle for the daemon: start it detached, stop it, restart it,
|
|
2
|
+
// and report status. The loopback port is the authoritative "is it running?"
|
|
3
|
+
// signal — a stale PID file never blocks a start, because the source of truth is
|
|
4
|
+
// whether something actually answers on the port.
|
|
5
|
+
import { spawn } from "node:child_process";
|
|
6
|
+
import { openSync } from "node:fs";
|
|
7
|
+
import { readFile, writeFile, rm } from "node:fs/promises";
|
|
8
|
+
import { get } from "node:http";
|
|
9
|
+
import { HOST, PORT, baseUrl, logFile, pidFile, ensureStateDir } from "./paths.js";
|
|
10
|
+
/** Probe the loopback endpoint; true iff a daemon answers within the timeout. */
|
|
11
|
+
export function isRunning() {
|
|
12
|
+
return new Promise((resolve) => {
|
|
13
|
+
const req = get({ host: HOST, port: PORT, path: "/api/model", timeout: 1000 }, (res) => {
|
|
14
|
+
res.resume(); // drain
|
|
15
|
+
resolve(true);
|
|
16
|
+
});
|
|
17
|
+
req.on("error", () => resolve(false));
|
|
18
|
+
req.on("timeout", () => {
|
|
19
|
+
req.destroy();
|
|
20
|
+
resolve(false);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
async function readPid() {
|
|
25
|
+
try {
|
|
26
|
+
const n = Number((await readFile(pidFile(), "utf8")).trim());
|
|
27
|
+
return Number.isInteger(n) && n > 0 ? n : undefined;
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
const delay = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
34
|
+
/**
|
|
35
|
+
* Start the daemon detached from this terminal. If one already answers on the
|
|
36
|
+
* port, this is a reported no-op (single-instance guard). The child re-runs this
|
|
37
|
+
* same entry point in the foreground with DATA_LOOM_DETACHED set so it skips the
|
|
38
|
+
* browser and logs to the state-dir log file.
|
|
39
|
+
*/
|
|
40
|
+
export async function start(project) {
|
|
41
|
+
if (await isRunning()) {
|
|
42
|
+
console.log(`[data-loom] already running — ${baseUrl()}`);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
await ensureStateDir();
|
|
46
|
+
const out = openSync(logFile(), "a");
|
|
47
|
+
const args = [process.argv[1], ...(project ? [project] : [])];
|
|
48
|
+
const child = spawn(process.execPath, args, {
|
|
49
|
+
detached: true,
|
|
50
|
+
windowsHide: true,
|
|
51
|
+
stdio: ["ignore", out, out],
|
|
52
|
+
env: { ...process.env, DATA_LOOM_DETACHED: "1" },
|
|
53
|
+
});
|
|
54
|
+
child.unref();
|
|
55
|
+
if (child.pid)
|
|
56
|
+
await writeFile(pidFile(), String(child.pid), "utf8");
|
|
57
|
+
console.log(`[data-loom] started in background (pid ${child.pid ?? "?"}) — ${baseUrl()}`);
|
|
58
|
+
console.log(`[data-loom] logs: ${logFile()}`);
|
|
59
|
+
}
|
|
60
|
+
/** Stop the background daemon; a no-op (with notice) when nothing is running. */
|
|
61
|
+
export async function stop() {
|
|
62
|
+
const pid = await readPid();
|
|
63
|
+
const running = await isRunning();
|
|
64
|
+
if (!running && pid === undefined) {
|
|
65
|
+
console.log("[data-loom] not running — nothing to stop");
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (pid !== undefined) {
|
|
69
|
+
try {
|
|
70
|
+
process.kill(pid, "SIGTERM");
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
/* already gone */
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
else if (running) {
|
|
77
|
+
console.error("[data-loom] a daemon is answering the port but no PID file was found; stop it manually");
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
// Wait for the port to actually free (up to ~5s), then drop the PID file.
|
|
81
|
+
for (let i = 0; i < 25 && (await isRunning()); i++)
|
|
82
|
+
await delay(200);
|
|
83
|
+
await rm(pidFile(), { force: true }).catch(() => { });
|
|
84
|
+
console.log("[data-loom] stopped");
|
|
85
|
+
}
|
|
86
|
+
/** Restart: stop any running instance, then start a fresh one. */
|
|
87
|
+
export async function restart(project) {
|
|
88
|
+
await stop();
|
|
89
|
+
await start(project);
|
|
90
|
+
}
|
|
91
|
+
/** Report whether the daemon is running, and where to reach it / find its logs. */
|
|
92
|
+
export async function status() {
|
|
93
|
+
if (await isRunning()) {
|
|
94
|
+
const pid = await readPid();
|
|
95
|
+
console.log(`[data-loom] running — ${baseUrl()}${pid !== undefined ? ` (pid ${pid})` : ""}`);
|
|
96
|
+
console.log(`[data-loom] logs: ${logFile()}`);
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
console.log("[data-loom] not running");
|
|
100
|
+
}
|
|
101
|
+
}
|
package/dist/mcpServer.js
CHANGED
|
@@ -15,6 +15,14 @@ import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprot
|
|
|
15
15
|
import { OpenSpecClient } from "./openspecClient.js";
|
|
16
16
|
import { deriveModel } from "./derive.js";
|
|
17
17
|
import { discoverProjects, isViewableProject } from "./projects.js";
|
|
18
|
+
/**
|
|
19
|
+
* A validation error whose message is safe to return to the client because it
|
|
20
|
+
* only references caller-supplied input (a change name, a path the caller named).
|
|
21
|
+
* Anything that is NOT a ToolError is treated as an unexpected internal failure
|
|
22
|
+
* and reported generically, so host filesystem detail never leaks.
|
|
23
|
+
*/
|
|
24
|
+
class ToolError extends Error {
|
|
25
|
+
}
|
|
18
26
|
// Advertised to the client on connect. The "confirm before writing" gate lives
|
|
19
27
|
// here, in the agent's behavior — the server cannot verify a human approved.
|
|
20
28
|
const INSTRUCTIONS = `This server exposes a project's open OpenSpec proposals and lets you record the dependency order between them. It holds no model and cannot infer anything on its own — the judgment is yours and the decision is the user's.
|
|
@@ -67,18 +75,18 @@ const PROJECT_ARG = {
|
|
|
67
75
|
* the dashboard selection is the single fallback source of project truth.
|
|
68
76
|
*/
|
|
69
77
|
export function createMcpServer(deps) {
|
|
70
|
-
const server = new Server({ name: "data-loom", version: "0.4.
|
|
78
|
+
const server = new Server({ name: "data-loom", version: "0.4.1" }, { capabilities: { tools: {} }, instructions: INSTRUCTIONS });
|
|
71
79
|
// Resolve the target project for a call: explicit arg, then dashboard
|
|
72
80
|
// selection, then an instructive error. Validated as a real workspace before
|
|
73
81
|
// any read or write.
|
|
74
82
|
const resolveProject = (explicit) => {
|
|
75
83
|
const raw = typeof explicit === "string" && explicit.trim() ? explicit : deps.getCurrentProject();
|
|
76
84
|
if (!raw) {
|
|
77
|
-
throw new
|
|
85
|
+
throw new ToolError("No `project` given and no project is selected in the DataLoom dashboard. Call list_projects to see the available OpenSpec workspaces, then pass one as the `project` argument.");
|
|
78
86
|
}
|
|
79
87
|
const abs = resolve(raw);
|
|
80
88
|
if (!isViewableProject(abs)) {
|
|
81
|
-
throw new
|
|
89
|
+
throw new ToolError(`${abs} is not an OpenSpec workspace (no openspec/ directory). Call list_projects to see the available workspaces.`);
|
|
82
90
|
}
|
|
83
91
|
return abs;
|
|
84
92
|
};
|
|
@@ -161,7 +169,12 @@ export function createMcpServer(deps) {
|
|
|
161
169
|
return err(`unknown tool: ${name}`);
|
|
162
170
|
}
|
|
163
171
|
catch (e) {
|
|
164
|
-
|
|
172
|
+
// Validation errors (caller-supplied input) are safe to surface; anything
|
|
173
|
+
// else is an unexpected internal failure → generic message, detail to log.
|
|
174
|
+
if (e instanceof ToolError)
|
|
175
|
+
return err(e.message);
|
|
176
|
+
console.error("[data-loom mcp] tool error:", e);
|
|
177
|
+
return err("internal error");
|
|
165
178
|
}
|
|
166
179
|
});
|
|
167
180
|
return server;
|
|
@@ -202,14 +215,14 @@ async function listOpenProposals(client, changesDir, project) {
|
|
|
202
215
|
}
|
|
203
216
|
async function setDependency(client, changesDir, project, from, to) {
|
|
204
217
|
if (!from || !to)
|
|
205
|
-
throw new
|
|
218
|
+
throw new ToolError("both 'from' and 'to' are required");
|
|
206
219
|
if (from === to)
|
|
207
|
-
throw new
|
|
220
|
+
throw new ToolError("a change cannot depend on itself");
|
|
208
221
|
const names = new Set((await openChanges(client)).map((c) => c.name));
|
|
209
222
|
if (!names.has(from))
|
|
210
|
-
throw new
|
|
223
|
+
throw new ToolError(`unknown open change: ${from}`);
|
|
211
224
|
if (!names.has(to))
|
|
212
|
-
throw new
|
|
225
|
+
throw new ToolError(`unknown open change: ${to}`);
|
|
213
226
|
const path = join(changesDir, from, "proposal.md");
|
|
214
227
|
const text = await readFile(path, "utf8");
|
|
215
228
|
const updated = addDependsOn(text, to);
|
|
@@ -220,10 +233,10 @@ async function setDependency(client, changesDir, project, from, to) {
|
|
|
220
233
|
}
|
|
221
234
|
async function markIndependent(client, changesDir, project, change) {
|
|
222
235
|
if (!change)
|
|
223
|
-
throw new
|
|
236
|
+
throw new ToolError("'change' is required");
|
|
224
237
|
const names = new Set((await openChanges(client)).map((c) => c.name));
|
|
225
238
|
if (!names.has(change))
|
|
226
|
-
throw new
|
|
239
|
+
throw new ToolError(`unknown open change: ${change}`);
|
|
227
240
|
const path = join(changesDir, change, "proposal.md");
|
|
228
241
|
const text = await readFile(path, "utf8");
|
|
229
242
|
const updated = ensureDependsOnSection(text);
|
package/dist/paths.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Shared config + per-user state locations for the daemon and its CLI verbs.
|
|
2
|
+
// The lifecycle commands (start/stop/status), autostart registration, and the
|
|
3
|
+
// Claude Desktop integration all need to agree on the loopback address, the
|
|
4
|
+
// state directory, and the PID/log paths — so they live here, in one place.
|
|
5
|
+
import { mkdir } from "node:fs/promises";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
/** Loopback host + port the daemon binds. Port overridable via env for tests. */
|
|
9
|
+
export const HOST = "127.0.0.1";
|
|
10
|
+
export const PORT = Number(process.env.PORT ?? 4317);
|
|
11
|
+
/** Dashboard base URL and the MCP endpoint hosted on the same authority. */
|
|
12
|
+
export const baseUrl = () => `http://${HOST}:${PORT}`;
|
|
13
|
+
export const mcpUrl = () => `${baseUrl()}/mcp`;
|
|
14
|
+
/**
|
|
15
|
+
* Per-user state directory, using the OS convention:
|
|
16
|
+
* Windows %LOCALAPPDATA%\data-loom
|
|
17
|
+
* macOS ~/Library/Application Support/data-loom
|
|
18
|
+
* Linux ${XDG_STATE_HOME:-~/.local/state}/data-loom
|
|
19
|
+
* Holds the PID file and the background log — never the project dir.
|
|
20
|
+
*/
|
|
21
|
+
export function stateDir() {
|
|
22
|
+
if (process.platform === "win32") {
|
|
23
|
+
const local = process.env.LOCALAPPDATA ?? join(homedir(), "AppData", "Local");
|
|
24
|
+
return join(local, "data-loom");
|
|
25
|
+
}
|
|
26
|
+
if (process.platform === "darwin") {
|
|
27
|
+
return join(homedir(), "Library", "Application Support", "data-loom");
|
|
28
|
+
}
|
|
29
|
+
const xdg = process.env.XDG_STATE_HOME ?? join(homedir(), ".local", "state");
|
|
30
|
+
return join(xdg, "data-loom");
|
|
31
|
+
}
|
|
32
|
+
export const pidFile = () => join(stateDir(), "daemon.pid");
|
|
33
|
+
export const logFile = () => join(stateDir(), "daemon.log");
|
|
34
|
+
/** Create the state dir on demand; safe to call repeatedly. */
|
|
35
|
+
export async function ensureStateDir() {
|
|
36
|
+
const dir = stateDir();
|
|
37
|
+
await mkdir(dir, { recursive: true });
|
|
38
|
+
return dir;
|
|
39
|
+
}
|
package/dist/server.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Serve the SPA on loopback and push roadmap + MCP + project state over a websocket.
|
|
2
2
|
import { createServer } from "node:http";
|
|
3
3
|
import { randomUUID } from "node:crypto";
|
|
4
|
-
import { extname } from "node:path";
|
|
4
|
+
import { extname, resolve, sep } from "node:path";
|
|
5
5
|
import { WebSocketServer, WebSocket } from "ws";
|
|
6
6
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
7
7
|
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
|
|
@@ -24,53 +24,110 @@ export async function startServer(opts) {
|
|
|
24
24
|
ws.send(data);
|
|
25
25
|
}
|
|
26
26
|
};
|
|
27
|
+
// Loopback origin/host allowlist — the DNS-rebinding + CSRF guard. Built from
|
|
28
|
+
// the actually-bound port after listen; seeded from the requested port so the
|
|
29
|
+
// guard is active from the first request.
|
|
30
|
+
const loopbackHosts = ["127.0.0.1", "localhost", "[::1]"];
|
|
31
|
+
let allowedAuthorities = new Set();
|
|
32
|
+
const setAllowed = (p) => {
|
|
33
|
+
allowedAuthorities = new Set(loopbackHosts.map((h) => `${h}:${p}`));
|
|
34
|
+
};
|
|
35
|
+
setAllowed(port);
|
|
36
|
+
// Reject what a hostile web page or a rebound DNS name could mount: a Host
|
|
37
|
+
// that is not our loopback authority (DNS rebinding), or any *present* Origin
|
|
38
|
+
// that is not a loopback origin (CSRF). An absent Origin = a native, non-
|
|
39
|
+
// browser client (e.g. Claude Code), which is allowed.
|
|
40
|
+
const isAllowed = (req) => {
|
|
41
|
+
const host = req.headers.host;
|
|
42
|
+
if (!host || !allowedAuthorities.has(host))
|
|
43
|
+
return false;
|
|
44
|
+
const origin = req.headers.origin;
|
|
45
|
+
if (origin) {
|
|
46
|
+
try {
|
|
47
|
+
if (!allowedAuthorities.has(new URL(origin).host))
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return true;
|
|
55
|
+
};
|
|
27
56
|
// One MCP server + transport per client session (stateful Streamable-HTTP),
|
|
28
|
-
// keyed by
|
|
29
|
-
//
|
|
30
|
-
// every project.
|
|
31
|
-
const
|
|
57
|
+
// keyed by session id. Sessions are capped and idle-evicted so churn cannot
|
|
58
|
+
// grow memory without bound. Tools resolve their target project per call from
|
|
59
|
+
// getCurrentProject (see mcpServer.ts), so one daemon serves every project.
|
|
60
|
+
const MAX_SESSIONS = 32;
|
|
61
|
+
const SESSION_IDLE_TTL_MS = 30 * 60 * 1000;
|
|
62
|
+
const mcpSessions = new Map();
|
|
63
|
+
const idleSweep = setInterval(() => {
|
|
64
|
+
const now = Date.now();
|
|
65
|
+
for (const [sid, s] of mcpSessions) {
|
|
66
|
+
if (now - s.lastSeen > SESSION_IDLE_TTL_MS) {
|
|
67
|
+
mcpSessions.delete(sid);
|
|
68
|
+
void s.transport.close();
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}, 60 * 1000);
|
|
72
|
+
idleSweep.unref();
|
|
32
73
|
const handleMcp = async (req, res) => {
|
|
33
74
|
const sessionId = req.headers["mcp-session-id"];
|
|
34
75
|
try {
|
|
35
76
|
if (req.method === "POST") {
|
|
36
77
|
const body = await readJsonBody(req);
|
|
37
|
-
|
|
38
|
-
if (
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
mcpTransports.set(sid, transport);
|
|
46
|
-
},
|
|
47
|
-
});
|
|
48
|
-
transport.onclose = () => {
|
|
49
|
-
if (transport.sessionId)
|
|
50
|
-
mcpTransports.delete(transport.sessionId);
|
|
51
|
-
};
|
|
52
|
-
await createMcpServer({ getCurrentProject }).connect(transport);
|
|
78
|
+
const existing = sessionId ? mcpSessions.get(sessionId) : undefined;
|
|
79
|
+
if (existing) {
|
|
80
|
+
existing.lastSeen = Date.now();
|
|
81
|
+
await existing.transport.handleRequest(req, res, body);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (!isInitializeRequest(body)) {
|
|
85
|
+
return sendError(res, 400, "missing or invalid mcp-session-id");
|
|
53
86
|
}
|
|
87
|
+
if (mcpSessions.size >= MAX_SESSIONS) {
|
|
88
|
+
return sendError(res, 503, "too many sessions");
|
|
89
|
+
}
|
|
90
|
+
const transport = new StreamableHTTPServerTransport({
|
|
91
|
+
sessionIdGenerator: () => randomUUID(),
|
|
92
|
+
onsessioninitialized: (sid) => {
|
|
93
|
+
mcpSessions.set(sid, { transport, lastSeen: Date.now() });
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
transport.onclose = () => {
|
|
97
|
+
if (transport.sessionId)
|
|
98
|
+
mcpSessions.delete(transport.sessionId);
|
|
99
|
+
};
|
|
100
|
+
await createMcpServer({ getCurrentProject }).connect(transport);
|
|
54
101
|
await transport.handleRequest(req, res, body);
|
|
55
102
|
return;
|
|
56
103
|
}
|
|
57
104
|
if (req.method === "GET" || req.method === "DELETE") {
|
|
58
|
-
const
|
|
59
|
-
if (!
|
|
105
|
+
const s = sessionId ? mcpSessions.get(sessionId) : undefined;
|
|
106
|
+
if (!s)
|
|
60
107
|
return sendError(res, 400, "missing or invalid mcp-session-id");
|
|
61
|
-
|
|
108
|
+
s.lastSeen = Date.now();
|
|
109
|
+
await s.transport.handleRequest(req, res);
|
|
62
110
|
return;
|
|
63
111
|
}
|
|
64
112
|
return sendError(res, 405, "method not allowed");
|
|
65
113
|
}
|
|
66
114
|
catch (e) {
|
|
67
|
-
if (
|
|
68
|
-
|
|
115
|
+
if (e instanceof PayloadTooLargeError) {
|
|
116
|
+
if (!res.headersSent)
|
|
117
|
+
sendError(res, 413, "request body too large");
|
|
118
|
+
return;
|
|
69
119
|
}
|
|
120
|
+
// Unexpected failures stay in the server log; the client gets nothing
|
|
121
|
+
// about the host (no paths, no stack).
|
|
122
|
+
console.error("[data-loom] mcp request error:", e);
|
|
123
|
+
if (!res.headersSent)
|
|
124
|
+
sendError(res, 500, "internal error");
|
|
70
125
|
}
|
|
71
126
|
};
|
|
72
127
|
const http = createServer(async (req, res) => {
|
|
73
128
|
try {
|
|
129
|
+
if (!isAllowed(req))
|
|
130
|
+
return sendError(res, 403, "forbidden");
|
|
74
131
|
const url = new URL(req.url ?? "/", `http://${host}`);
|
|
75
132
|
if (url.pathname === "/mcp")
|
|
76
133
|
return handleMcp(req, res);
|
|
@@ -96,9 +153,12 @@ export async function startServer(opts) {
|
|
|
96
153
|
return sendError(res, 400, err instanceof Error ? err.message : "invalid project");
|
|
97
154
|
}
|
|
98
155
|
}
|
|
99
|
-
// static assets (served from public/ on the filesystem)
|
|
156
|
+
// static assets (served from public/ on the filesystem) — resolve and
|
|
157
|
+
// assert the path stays within publicDir before reading.
|
|
100
158
|
const rel = url.pathname === "/" ? "index.html" : url.pathname.replace(/^\/+/, "");
|
|
101
|
-
|
|
159
|
+
const root = resolve(publicDir);
|
|
160
|
+
const full = resolve(root, rel);
|
|
161
|
+
if (full !== root && !full.startsWith(root + sep))
|
|
102
162
|
return sendError(res, 403, "forbidden");
|
|
103
163
|
const file = await loadAsset(rel, publicDir);
|
|
104
164
|
if (!file)
|
|
@@ -110,7 +170,10 @@ export async function startServer(opts) {
|
|
|
110
170
|
sendError(res, 500, "error");
|
|
111
171
|
}
|
|
112
172
|
});
|
|
113
|
-
wss = new WebSocketServer({
|
|
173
|
+
wss = new WebSocketServer({
|
|
174
|
+
server: http,
|
|
175
|
+
verifyClient: (info) => isAllowed(info.req),
|
|
176
|
+
});
|
|
114
177
|
wss.on("connection", async (ws) => {
|
|
115
178
|
const roadmap = getRoadmap();
|
|
116
179
|
if (roadmap)
|
|
@@ -123,11 +186,14 @@ export async function startServer(opts) {
|
|
|
123
186
|
/* project list optional */
|
|
124
187
|
}
|
|
125
188
|
});
|
|
126
|
-
await new Promise((
|
|
189
|
+
await new Promise((ready) => http.listen(port, host, ready));
|
|
190
|
+
const actualPort = http.address()?.port ?? port;
|
|
191
|
+
setAllowed(actualPort);
|
|
127
192
|
return {
|
|
128
|
-
port,
|
|
193
|
+
port: actualPort,
|
|
129
194
|
broadcast,
|
|
130
195
|
close: () => {
|
|
196
|
+
clearInterval(idleSweep);
|
|
131
197
|
wss.close();
|
|
132
198
|
http.close();
|
|
133
199
|
},
|
|
@@ -141,12 +207,32 @@ function sendError(res, code, message) {
|
|
|
141
207
|
res.writeHead(code, { "content-type": MIME[".json"] });
|
|
142
208
|
res.end(JSON.stringify({ error: message }));
|
|
143
209
|
}
|
|
210
|
+
/** Raised when a request body exceeds the cap; mapped to HTTP 413. */
|
|
211
|
+
class PayloadTooLargeError extends Error {
|
|
212
|
+
}
|
|
213
|
+
/** Largest request body we will buffer (JSON-RPC tool calls are tiny). */
|
|
214
|
+
const MAX_BODY_BYTES = 4 * 1024 * 1024;
|
|
144
215
|
/** Read and JSON-parse a request body; returns undefined for an empty body. */
|
|
145
216
|
function readJsonBody(req) {
|
|
146
217
|
return new Promise((resolve, reject) => {
|
|
147
218
|
let data = "";
|
|
148
|
-
|
|
219
|
+
let size = 0;
|
|
220
|
+
let aborted = false;
|
|
221
|
+
req.on("data", (chunk) => {
|
|
222
|
+
if (aborted)
|
|
223
|
+
return;
|
|
224
|
+
size += chunk.length;
|
|
225
|
+
if (size > MAX_BODY_BYTES) {
|
|
226
|
+
aborted = true;
|
|
227
|
+
reject(new PayloadTooLargeError("request body too large"));
|
|
228
|
+
req.destroy();
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
data += chunk;
|
|
232
|
+
});
|
|
149
233
|
req.on("end", () => {
|
|
234
|
+
if (aborted)
|
|
235
|
+
return;
|
|
150
236
|
if (!data)
|
|
151
237
|
return resolve(undefined);
|
|
152
238
|
try {
|
package/package.json
CHANGED