loom-agent 1.2.27 → 1.2.36
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/CHANGELOG.md +90 -0
- package/README.md +10 -1
- package/bin/loom-bun.js +104 -61
- package/bin/loom-tui.js +46 -21
- package/package.json +13 -5
- package/scripts/postinstall.js +140 -0
- package/src/core/cli.js +36 -9
- package/src/mcp/mcp-client.js +42 -2
- package/src/skills/skills-manager.js +25 -2
- package/src/tools/index.js +99 -4
- package/src/tui/components/Modals.tsx +37 -21
- package/src/tui-preload.js +55 -0
- package/src/web/web-server.js +72 -7
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,96 @@ All notable changes to **Loom Code** are documented here.
|
|
|
4
4
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
5
5
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## [1.2.36] — fix `loom web` / `loom acp` / `loom attach`
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
- **`loom web` launched the TUI instead of the browser server.** Since 1.2.34
|
|
11
|
+
the npm shims prefer `bin/loom-bun.js`, whose non-TUI branch routed every
|
|
12
|
+
subcommand into `src/core/cli.js`'s interactive `main()` — which has no
|
|
13
|
+
handler for `web`/`acp`/`attach`, so control fell through to the full-screen
|
|
14
|
+
app (reproduced: splash painted, no HTTP listener). `bin/loom-bun.js` now
|
|
15
|
+
routes those three subcommands directly to their own entry points, exactly
|
|
16
|
+
like `bin/loom.js` always did. Verified end-to-end on a global install:
|
|
17
|
+
`/api/health` → 200, UI served, banner prints.
|
|
18
|
+
|
|
19
|
+
## [1.2.35] — security hardening
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
- **webfetch SSRF guard (`src/tools/index.js`).** The fetch tool is driven by
|
|
23
|
+
the model, and prompt-injected page content could steer it at cloud
|
|
24
|
+
metadata (`169.254.169.254`), localhost services, or internal hosts.
|
|
25
|
+
webfetch now allows only `http:`/`https:` (no `file:`/`ftp:`/`data:`),
|
|
26
|
+
refuses loopback, RFC1918, CGNAT, link-local/metadata, IPv6 ULA/link-local
|
|
27
|
+
and IPv4-mapped targets, **resolves DNS before connecting** so
|
|
28
|
+
hostname→internal-IP tricks fail, follows redirects manually and re-checks
|
|
29
|
+
every hop, and fails closed on unresolvable hosts.
|
|
30
|
+
- **MCP servers no longer inherit the full shell environment**
|
|
31
|
+
(`src/mcp/mcp-client.js`). Every spawned MCP server used to receive all of
|
|
32
|
+
`process.env` — provider API keys, cloud tokens, cookies — handing them to
|
|
33
|
+
whatever npm package a config starts. Servers now get a minimal OS/runtime
|
|
34
|
+
baseline (PATH, ComSpec, TEMP/HOME, proxy vars) plus only what their own
|
|
35
|
+
config explicitly declares (`cfg.env`). Secrets are opt-in per server.
|
|
36
|
+
- **Skills install name traversal blocked (`src/skills/skills-manager.js`).**
|
|
37
|
+
A remote-provided install name like `../../escapee` was joined raw onto
|
|
38
|
+
`~/.loom/skills`. Names are now a strict allowlist
|
|
39
|
+
(`^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$`); traversal attempts error instead of
|
|
40
|
+
silently renaming or escaping the skills directory.
|
|
41
|
+
|
|
42
|
+
### Added
|
|
43
|
+
- **`loom web` session hardening (`src/web/web-server.js`).** Login tokens
|
|
44
|
+
previously lived until server restart; they now expire after **12 h**
|
|
45
|
+
(tunable via `LOOM_SERVER_TOKEN_TTL_MS`, cookie `Max-Age` kept in sync), a
|
|
46
|
+
lazy sweep drops expired tokens, and new **`POST /api/auth/logout`**
|
|
47
|
+
revokes the presented token immediately.
|
|
48
|
+
- **Security headers on every response:** `X-Content-Type-Options: nosniff`,
|
|
49
|
+
`X-Frame-Options: DENY`, `Referrer-Policy: no-referrer`; the browser UI
|
|
50
|
+
additionally ships a strict `Content-Security-Policy`
|
|
51
|
+
(`default-src 'none'; connect-src 'self'` — the page is self-contained).
|
|
52
|
+
- **LAN-exposure warning:** `loom web --hostname 0.0.0.0` (or `::`) without
|
|
53
|
+
`LOOM_SERVER_PASSWORD` now prints an explicit warning that anyone on the
|
|
54
|
+
network can run an agent and reach its tools/files.
|
|
55
|
+
- **New regression suite `src/tools/security.test.js`** plus five web-server
|
|
56
|
+
hardening tests (headers, CSP, token TTL expiry, Max-Age sync, logout).
|
|
57
|
+
|
|
58
|
+
## [1.2.34] — bundle bun with the npm install
|
|
59
|
+
|
|
60
|
+
### Added
|
|
61
|
+
- **`npm i -g loom-agent` now brings its own bun.** The `@oven/bun-<platform>`
|
|
62
|
+
binaries (pinned to bun 1.3.14, the version the project builds against) ship
|
|
63
|
+
as `optionalDependencies`; npm downloads only the one matching the user's
|
|
64
|
+
OS/arch, and no install scripts are involved (npm allow-scripts policies
|
|
65
|
+
cannot break it). The postinstall rewrites the `loom` shims to prefer the
|
|
66
|
+
bundled binary, then `bun` from PATH.
|
|
67
|
+
- **No-bun users are never stranded:** if neither the bundled binary nor a
|
|
68
|
+
PATH bun exists, the shim falls back to `node bin/loom.js` (the line-mode
|
|
69
|
+
REPL) with a hint — instead of dying with "'bun' is not recognized".
|
|
70
|
+
`findBun()` and the bin respawn paths check the bundled location first too.
|
|
71
|
+
|
|
72
|
+
## [1.2.33] — fix frozen splash on global installs + stable model picker
|
|
73
|
+
|
|
74
|
+
### Fixed
|
|
75
|
+
- **Global npm installs froze on the splash screen** ("Build … no key", no
|
|
76
|
+
keyboard input) while repo checkouts worked. Root cause: the launch chain
|
|
77
|
+
started Bun at the package root and later ran `process.chdir()` back to the
|
|
78
|
+
user's project inside `tui-open.tsx`. That mid-flight chdir killed OpenTUI's
|
|
79
|
+
repaint/input pipeline after the first frame — signals kept updating and
|
|
80
|
+
Solid effects kept firing (verified with runtime instrumentation), but no
|
|
81
|
+
frame ever reached the terminal again.
|
|
82
|
+
- The launcher now registers the Solid JSX preloader with **absolute
|
|
83
|
+
`--preload` paths** and starts Bun **directly in the user's project
|
|
84
|
+
directory**, so no chdir ever happens: `bin/loom-bun.js`, `bin/loom-tui.js`,
|
|
85
|
+
the postinstall-rewritten shims, and the core-CLI TUI spawn
|
|
86
|
+
(`src/core/cli.js`) were all switched to that scheme.
|
|
87
|
+
- **Model picker jitter** — scrolling `/models` (and every `SelectModal`:
|
|
88
|
+
`/connect`, theme pickers, MCP preset picker) bounced the whole modal.
|
|
89
|
+
Section headers added an extra margin row, so the centered frame's height
|
|
90
|
+
flipped between 12/13/14 rows on every page of a header-heavy list. The
|
|
91
|
+
list window is now a fixed 12 rows (headers are plain one-row entries), the
|
|
92
|
+
window is computed once per change instead of mutating during render, and
|
|
93
|
+
mouse-hover no longer yanks the selection while a keyboard/wheel scroll is
|
|
94
|
+
settling. Regression test added (29b: modal title row must never move while
|
|
95
|
+
scrolling).
|
|
96
|
+
|
|
7
97
|
## [Unreleased]
|
|
8
98
|
|
|
9
99
|
### Added
|
package/README.md
CHANGED
|
@@ -1,9 +1,18 @@
|
|
|
1
|
-
|
|
1
|
+
```
|
|
2
|
+
██╗ ██████╗ ██████╗ ███╗ ███╗ ██████╗ ██████╗ ██████╗ ███████╗
|
|
3
|
+
██║ ██╔═══██╗██╔═══██╗████╗ ████║ ██╔════╝██╔═══██╗██╔══██╗██╔════╝
|
|
4
|
+
██║ ██║ ██║██║ ██║██╔████╔██║ ██║ ██║ ██║██║ ██║█████╗
|
|
5
|
+
██║ ██║ ██║██║ ██║██║╚██╔╝██║ ██║ ██║ ██║██║ ██║██╔══╝
|
|
6
|
+
███████╗╚██████╔╝╚██████╔╝██║ ╚═╝ ██║ ╚██████╗██████╔╝██████╔╝███████╗
|
|
7
|
+
╚══════╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝ ╚═════╝╚═════╝ ╚═════╝ ╚══════╝
|
|
8
|
+
```
|
|
2
9
|
|
|
3
10
|
[](https://www.npmjs.com/package/loom-agent)
|
|
4
11
|
[](LICENSE)
|
|
5
12
|
[](#)
|
|
6
13
|
|
|
14
|
+

|
|
15
|
+
|
|
7
16
|
An AI-powered coding agent for the terminal with multi-provider support and a full terminal UI.
|
|
8
17
|
|
|
9
18
|
## Features
|
package/bin/loom-bun.js
CHANGED
|
@@ -1,61 +1,104 @@
|
|
|
1
|
-
#!/usr/bin/env bun
|
|
2
|
-
// npm invokes bin targets through Node on Windows, so re-launch under Bun
|
|
3
|
-
// when this file was not started by Bun itself.
|
|
4
|
-
const path = require("path");
|
|
5
|
-
const fs = require("fs");
|
|
6
|
-
const { spawnSync } = require("child_process");
|
|
7
|
-
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
}
|
|
31
|
-
process.
|
|
32
|
-
|
|
33
|
-
process.
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// npm invokes bin targets through Node on Windows, so re-launch under Bun
|
|
3
|
+
// when this file was not started by Bun itself.
|
|
4
|
+
const path = require("path");
|
|
5
|
+
const fs = require("fs");
|
|
6
|
+
const { spawnSync } = require("child_process");
|
|
7
|
+
|
|
8
|
+
// The Solid JSX transform must register in Bun's preload phase, so the
|
|
9
|
+
// preloads are passed as ABSOLUTE paths. That lets Bun start directly in the
|
|
10
|
+
// user's project directory — the old "spawn at the package root, then chdir
|
|
11
|
+
// to the project inside tui-open.tsx" dance froze the TUI after the first
|
|
12
|
+
// frame (splash paints once, then no repaints and no keyboard input; proven
|
|
13
|
+
// by A/B-launching the identical entry with and without the mid-flight
|
|
14
|
+
// process.chdir). No chdir ever happens now: LOOM_START_CWD always equals
|
|
15
|
+
// the starting directory, so the restore in tui-open.tsx is a no-op kept
|
|
16
|
+
// only as a safety net.
|
|
17
|
+
const pkgRoot = path.join(__dirname, "..");
|
|
18
|
+
const underBun = typeof Bun !== "undefined" && !!process.versions.bun;
|
|
19
|
+
if (!underBun) {
|
|
20
|
+
// Prefer the bundled @oven bun binary (shipped as an optional dep), then
|
|
21
|
+
// whatever bun is on PATH. Without either, still respawn so the inner
|
|
22
|
+
// failure message can guide the user.
|
|
23
|
+
const OVEN = {
|
|
24
|
+
"win32-x64": "bun-windows-x64",
|
|
25
|
+
"win32-arm64": "bun-windows-aarch64",
|
|
26
|
+
"darwin-x64": "bun-darwin-x64",
|
|
27
|
+
"darwin-arm64": "bun-darwin-aarch64",
|
|
28
|
+
"linux-x64": "bun-linux-x64",
|
|
29
|
+
"linux-arm64": "bun-linux-aarch64",
|
|
30
|
+
};
|
|
31
|
+
let bunCmd = process.platform === "win32" ? "bun.exe" : "bun";
|
|
32
|
+
const short = OVEN[process.platform + "-" + process.arch];
|
|
33
|
+
const exe = process.platform === "win32" ? "bun.exe" : "bun";
|
|
34
|
+
const ovenCandidates = short ? [
|
|
35
|
+
path.join(pkgRoot, "node_modules", "@oven", short, "bin", exe),
|
|
36
|
+
path.join(pkgRoot, "..", "@oven", short, "bin", exe),
|
|
37
|
+
] : [];
|
|
38
|
+
for (const c of ovenCandidates) {
|
|
39
|
+
if (fs.existsSync(c)) { bunCmd = c; break; }
|
|
40
|
+
}
|
|
41
|
+
const result = spawnSync(
|
|
42
|
+
bunCmd,
|
|
43
|
+
[
|
|
44
|
+
"--preload", path.join(pkgRoot, "src", "tui-preload.js"),
|
|
45
|
+
__filename,
|
|
46
|
+
...process.argv.slice(2),
|
|
47
|
+
],
|
|
48
|
+
{
|
|
49
|
+
stdio: "inherit",
|
|
50
|
+
cwd: process.env.LOOM_START_CWD || process.cwd(),
|
|
51
|
+
env: { ...process.env, LOOM_START_CWD: process.env.LOOM_START_CWD || process.cwd() },
|
|
52
|
+
windowsHide: false,
|
|
53
|
+
}
|
|
54
|
+
);
|
|
55
|
+
if (result.error) {
|
|
56
|
+
console.error("[loom] Bun is required for the full CLI. Install it from https://bun.sh/");
|
|
57
|
+
process.exit(1);
|
|
58
|
+
}
|
|
59
|
+
process.exit(result.status == null ? 1 : result.status);
|
|
60
|
+
}
|
|
61
|
+
process.title = "loom-code";
|
|
62
|
+
(async () => {
|
|
63
|
+
try {
|
|
64
|
+
// The npm bin entry bypasses src/index.js, so load dotenv here for both
|
|
65
|
+
// the package environment and the project from which `loom` was run.
|
|
66
|
+
const dotenv = require("dotenv");
|
|
67
|
+
dotenv.config({ path: path.join(__dirname, "..", ".env") });
|
|
68
|
+
const startCwd = process.env.LOOM_START_CWD || process.cwd();
|
|
69
|
+
const projectEnv = path.join(startCwd, ".env");
|
|
70
|
+
if (fs.existsSync(projectEnv)) dotenv.config({ path: projectEnv, override: false });
|
|
71
|
+
// The npm global shim can start Bun outside the package directory, so do
|
|
72
|
+
// not depend on bunfig.toml discovery for Windows console setup.
|
|
73
|
+
await import("../src/tui-preload.js");
|
|
74
|
+
const args = process.argv.slice(2);
|
|
75
|
+
const coreMode = args.includes("--basic") || args.includes("-p") || args.includes("--print")
|
|
76
|
+
|| args.includes("--help") || args.includes("-h") || args.includes("--version") || args.includes("-v")
|
|
77
|
+
|| ["acp", "web", "attach", "graph"].includes(args[0]);
|
|
78
|
+
if (!coreMode) {
|
|
79
|
+
await import("@opentui/solid/preload");
|
|
80
|
+
await import("../src/tui-open.tsx");
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
// Subcommands that must never reach the interactive CLI/TUI: cli.main()
|
|
84
|
+
// has no handler for them and would fall through to launching the full
|
|
85
|
+
// screen app (`loom web` painted the splash instead of serving HTTP).
|
|
86
|
+
if (args[0] === "web") {
|
|
87
|
+
require("../src/web/web-server.js").main();
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
if (args[0] === "acp") {
|
|
91
|
+
require("../src/acp/acp-server.js").main();
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if (args[0] === "attach") {
|
|
95
|
+
require("../src/web/attach.js").main();
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
const { main } = require("../src/core/cli.js");
|
|
99
|
+
await main();
|
|
100
|
+
} catch (err) {
|
|
101
|
+
console.error(err && err.message ? err.message : String(err));
|
|
102
|
+
process.exit(1);
|
|
103
|
+
}
|
|
104
|
+
})();
|
package/bin/loom-tui.js
CHANGED
|
@@ -4,30 +4,55 @@
|
|
|
4
4
|
const path = require("path");
|
|
5
5
|
const { spawnSync } = require("child_process");
|
|
6
6
|
|
|
7
|
-
//
|
|
8
|
-
//
|
|
7
|
+
// Preloads are ABSOLUTE paths so Bun starts directly in the user's project
|
|
8
|
+
// directory — see bin/loom-bun.js for why the package-root + chdir dance is
|
|
9
|
+
// gone (it froze the TUI after the first frame).
|
|
9
10
|
const pkgRoot = path.join(__dirname, "..");
|
|
10
11
|
const underBun = typeof Bun !== "undefined" && !!process.versions.bun;
|
|
11
12
|
if (!underBun) {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
13
|
+
// Prefer the bundled @oven bun binary (shipped as an optional dep), then
|
|
14
|
+
// whatever bun is on PATH — same scheme as bin/loom-bun.js.
|
|
15
|
+
const OVEN = {
|
|
16
|
+
"win32-x64": "bun-windows-x64",
|
|
17
|
+
"win32-arm64": "bun-windows-aarch64",
|
|
18
|
+
"darwin-x64": "bun-darwin-x64",
|
|
19
|
+
"darwin-arm64": "bun-darwin-aarch64",
|
|
20
|
+
"linux-x64": "bun-linux-x64",
|
|
21
|
+
"linux-arm64": "bun-linux-aarch64",
|
|
22
|
+
};
|
|
23
|
+
let bunCmd = process.platform === "win32" ? "bun.exe" : "bun";
|
|
24
|
+
const short = OVEN[process.platform + "-" + process.arch];
|
|
25
|
+
const exe = process.platform === "win32" ? "bun.exe" : "bun";
|
|
26
|
+
const ovenCandidates = short ? [
|
|
27
|
+
path.join(pkgRoot, "node_modules", "@oven", short, "bin", exe),
|
|
28
|
+
path.join(pkgRoot, "..", "@oven", short, "bin", exe),
|
|
29
|
+
] : [];
|
|
30
|
+
for (const c of ovenCandidates) {
|
|
31
|
+
if (fs.existsSync(c)) { bunCmd = c; break; }
|
|
32
|
+
}
|
|
33
|
+
const result = spawnSync(
|
|
34
|
+
bunCmd,
|
|
35
|
+
[
|
|
36
|
+
"--preload", path.join(pkgRoot, "src", "tui-preload.js"),
|
|
37
|
+
__filename,
|
|
38
|
+
...process.argv.slice(2),
|
|
39
|
+
],
|
|
40
|
+
{
|
|
41
|
+
stdio: "inherit",
|
|
42
|
+
cwd: process.env.LOOM_START_CWD || process.cwd(),
|
|
43
|
+
env: { ...process.env, LOOM_START_CWD: process.env.LOOM_START_CWD || process.cwd() },
|
|
44
|
+
windowsHide: false,
|
|
45
|
+
}
|
|
46
|
+
);
|
|
47
|
+
if (result.error) {
|
|
48
|
+
console.error("[loom] Bun is required for the TUI. Install it from https://bun.sh/");
|
|
49
|
+
process.exit(1);
|
|
50
|
+
}
|
|
51
|
+
process.exit(result.status == null ? 1 : result.status);
|
|
27
52
|
}
|
|
28
53
|
|
|
29
54
|
(async () => {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
})();
|
|
55
|
+
await import("../src/tui-preload.js");
|
|
56
|
+
await import("@opentui/solid/preload");
|
|
57
|
+
await import("../src/tui-open.tsx");
|
|
58
|
+
})();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "loom-agent",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.36",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/toshalkumbhar8979-design/loomcode.git"
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"bugs": {
|
|
10
10
|
"url": "https://github.com/toshalkumbhar8979-design/loomcode/issues"
|
|
11
11
|
},
|
|
12
|
-
"description": "Loom Code
|
|
12
|
+
"description": "Loom Code  AI-powered coding agent for the terminal. Multi-provider support including NVIDIA. OpenTUI interface.",
|
|
13
13
|
"main": "src/index.js",
|
|
14
14
|
"publishConfig": {
|
|
15
15
|
"access": "public",
|
|
@@ -24,12 +24,13 @@
|
|
|
24
24
|
"dev": "node --watch src/index.js",
|
|
25
25
|
"setup": "node src/setup.js",
|
|
26
26
|
"test": "bun run src/tui/test-interactive.tsx && bun run test:unit && bun run lint:core",
|
|
27
|
-
"test:unit": "bun test src/core/agents.test.js src/core/hooks.test.js src/core/custom-commands.test.js src/core/background-tasks.test.js src/core/memory.test.js src/core/subagent-log.test.js src/core/session.test.js src/tools/index.test.js src/providers/providers.test.js src/providers/caching.test.js src/providers/caching-thinking.test.js src/providers/registry.test.js src/mcp/mcp-client.test.js src/mcp/mcp-manager.test.js src/core/session-store.test.js src/skills/skills-manager.test.js src/core/usage.test.js src/core/permissions.test.js src/core/format.test.js src/acp/acp-server.test.js src/web/web-server.test.js src/tui/keybinds.test.ts",
|
|
27
|
+
"test:unit": "bun test src/core/agents.test.js src/core/hooks.test.js src/core/custom-commands.test.js src/core/background-tasks.test.js src/core/memory.test.js src/core/subagent-log.test.js src/core/session.test.js src/tools/index.test.js src/tools/security.test.js src/providers/providers.test.js src/providers/caching.test.js src/providers/caching-thinking.test.js src/providers/registry.test.js src/mcp/mcp-client.test.js src/mcp/mcp-manager.test.js src/core/session-store.test.js src/skills/skills-manager.test.js src/core/usage.test.js src/core/permissions.test.js src/core/format.test.js src/acp/acp-server.test.js src/web/web-server.test.js src/tui/keybinds.test.ts",
|
|
28
28
|
"lint:core": "node node_modules/typescript/bin/tsc -p tsconfig.core.json",
|
|
29
29
|
"smoke:acp": "node scripts/acp-smoke.js",
|
|
30
30
|
"tui": "bun bin/loom-tui.js",
|
|
31
31
|
"tui:win": "\"%USERPROFILE%\\..\\bun\\bin\\bun.exe\" run src/tui-open.tsx",
|
|
32
32
|
"prepublishOnly": "npm test",
|
|
33
|
+
"postinstall": "node scripts/postinstall.js",
|
|
33
34
|
"lint": "tsc --noEmit"
|
|
34
35
|
},
|
|
35
36
|
"dependencies": {
|
|
@@ -61,7 +62,13 @@
|
|
|
61
62
|
"@opentui/core-darwin-x64": "0.5.3",
|
|
62
63
|
"@opentui/core-linux-arm64": "0.5.3",
|
|
63
64
|
"@opentui/core-linux-x64": "0.5.3",
|
|
64
|
-
"@opentui/core-win32-x64": "0.5.3"
|
|
65
|
+
"@opentui/core-win32-x64": "0.5.3",
|
|
66
|
+
"@oven/bun-darwin-aarch64": "1.3.14",
|
|
67
|
+
"@oven/bun-darwin-x64": "1.3.14",
|
|
68
|
+
"@oven/bun-linux-aarch64": "1.3.14",
|
|
69
|
+
"@oven/bun-linux-x64": "1.3.14",
|
|
70
|
+
"@oven/bun-windows-aarch64": "1.3.14",
|
|
71
|
+
"@oven/bun-windows-x64": "1.3.14"
|
|
65
72
|
},
|
|
66
73
|
"overrides": {
|
|
67
74
|
"glob": "^13.0.6"
|
|
@@ -90,6 +97,7 @@
|
|
|
90
97
|
"LOOM.md",
|
|
91
98
|
"docs/acp.md",
|
|
92
99
|
"scripts/acp-smoke.js",
|
|
100
|
+
"scripts/postinstall.js",
|
|
93
101
|
"docs/web.md",
|
|
94
102
|
"src/web/index.html",
|
|
95
103
|
"src/web/graph-view.html"
|
|
@@ -113,6 +121,6 @@
|
|
|
113
121
|
"doc": "docs"
|
|
114
122
|
},
|
|
115
123
|
"author": "",
|
|
116
|
-
"license": "
|
|
124
|
+
"license": "MIT",
|
|
117
125
|
"type": "commonjs"
|
|
118
126
|
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// Postinstall: rewrite the npm-generated bin shims so `loom` starts ONE bun
|
|
2
|
+
// process directly IN THE USER'S PROJECT DIRECTORY. The Solid JSX preloader
|
|
3
|
+
// is registered with absolute --preload paths (the package-root cwd trick is
|
|
4
|
+
// gone — starting at the package root forced a later process.chdir back to
|
|
5
|
+
// the project inside tui-open.tsx, which froze the TUI after the first frame:
|
|
6
|
+
// splash painted once, then no repaints and no keyboard input).
|
|
7
|
+
const fs = require("fs");
|
|
8
|
+
const path = require("path");
|
|
9
|
+
|
|
10
|
+
const pkgRoot = path.resolve(__dirname, "..");
|
|
11
|
+
|
|
12
|
+
// Bun, bundled: loom-agent lists the @oven/bun-<platform> binaries as
|
|
13
|
+
// optionalDependencies, so `npm i -g loom-agent` brings the REAL bun binary
|
|
14
|
+
// (pinned, matching the repo's bun) along even when the user has no bun
|
|
15
|
+
// installed. npm skips non-matching platforms via each package's os/cpu
|
|
16
|
+
// fields, and this resolution never needs install scripts (npm
|
|
17
|
+
// allow-scripts policies can't break it). Resolved here once at install
|
|
18
|
+
// time and baked into the shims as the preferred runtime; the shims still
|
|
19
|
+
// fall back to whatever `bun` is on PATH, and — if NO bun exists at all —
|
|
20
|
+
// to `node bin/loom.js` (line-mode REPL) instead of dying with
|
|
21
|
+
// "'bun' is not recognized".
|
|
22
|
+
function ovenBunPath() {
|
|
23
|
+
const PKGS = {
|
|
24
|
+
"win32-x64": "bun-windows-x64",
|
|
25
|
+
"win32-arm64": "bun-windows-aarch64",
|
|
26
|
+
"darwin-x64": "bun-darwin-x64",
|
|
27
|
+
"darwin-arm64": "bun-darwin-aarch64",
|
|
28
|
+
"linux-x64": "bun-linux-x64",
|
|
29
|
+
"linux-arm64": "bun-linux-aarch64",
|
|
30
|
+
};
|
|
31
|
+
const short = PKGS[process.platform + "-" + process.arch];
|
|
32
|
+
if (!short) return "";
|
|
33
|
+
const exe = process.platform === "win32" ? "bun.exe" : "bun";
|
|
34
|
+
const candidates = [
|
|
35
|
+
path.join(pkgRoot, "node_modules", "@oven", short, "bin", exe), // nested (npm default)
|
|
36
|
+
path.join(pkgRoot, "..", "@oven", short, "bin", exe), // hoisted
|
|
37
|
+
];
|
|
38
|
+
for (var i = 0; i < candidates.length; i++) {
|
|
39
|
+
try { if (fs.existsSync(candidates[i])) return candidates[i]; } catch {}
|
|
40
|
+
}
|
|
41
|
+
return "";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
var ovenPath = "";
|
|
45
|
+
|
|
46
|
+
function shimDirs() {
|
|
47
|
+
const dirs = new Set();
|
|
48
|
+
if (path.basename(path.resolve(pkgRoot, "..")) !== "node_modules") return [];
|
|
49
|
+
dirs.add(path.resolve(pkgRoot, "..", ".."));
|
|
50
|
+
dirs.add(path.join(path.resolve(pkgRoot, ".."), ".bin"));
|
|
51
|
+
return [...dirs].filter(function(d) {
|
|
52
|
+
try {
|
|
53
|
+
return fs.existsSync(path.join(d, "loom.cmd")) || fs.existsSync(path.join(d, "loom"));
|
|
54
|
+
} catch {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function cmdShim(pkg, script) {
|
|
61
|
+
var pkgEscaped = pkg.replace(/\//g, "\\");
|
|
62
|
+
return [
|
|
63
|
+
"@ECHO off",
|
|
64
|
+
"SETLOCAL",
|
|
65
|
+
'SET "LOOM_START_CWD=%CD%"',
|
|
66
|
+
'IF EXIST "' + ovenPath + '" (SET "_prog=' + ovenPath + '") ELSE (SET "_prog=bun")',
|
|
67
|
+
'"%_prog%" --preload "%~dp0' + pkgEscaped + '\\src\\tui-preload.js" "%~dp0' + pkgEscaped + '\\bin\\' + script + '" %*',
|
|
68
|
+
"IF %ERRORLEVEL% NEQ 9009 EXIT /b %ERRORLEVEL%",
|
|
69
|
+
"REM bun not found (errorlevel 9009): fall back to the Node REPL",
|
|
70
|
+
"node \"%~dp0" + pkgEscaped + "\\bin\\loom.js\" %*",
|
|
71
|
+
"EXIT /b %ERRORLEVEL%",
|
|
72
|
+
""
|
|
73
|
+
].join("\r\n");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function psShim(pkg, script) {
|
|
77
|
+
return [
|
|
78
|
+
"#!/usr/bin/env pwsh",
|
|
79
|
+
"# rewritten by loom-agent postinstall (bundled bun + node fallback)",
|
|
80
|
+
"$env:LOOM_START_CWD = (Get-Location).Path",
|
|
81
|
+
"$pkg = Join-Path $PSScriptRoot '" + pkg + "'",
|
|
82
|
+
"$bun = '" + ovenPath + "'",
|
|
83
|
+
"if (-not ($bun -and (Test-Path $bun))) { $bun = 'bun' }",
|
|
84
|
+
"& $bun --preload (Join-Path $pkg 'src/tui-preload.js') (Join-Path $pkg 'bin/" + script + "') @args",
|
|
85
|
+
"if ($LASTEXITCODE -eq 9009) { node (Join-Path $pkg 'bin/loom.js') @args; exit $LASTEXITCODE }",
|
|
86
|
+
"exit $LASTEXITCODE",
|
|
87
|
+
""
|
|
88
|
+
].join("\n");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function shShim(pkg, script) {
|
|
92
|
+
return [
|
|
93
|
+
"#!/bin/sh",
|
|
94
|
+
"# rewritten by loom-agent postinstall (bundled bun + node fallback)",
|
|
95
|
+
'LOOM_START_CWD="$(pwd)"',
|
|
96
|
+
"export LOOM_START_CWD",
|
|
97
|
+
'PKG="$(dirname "$0")/' + pkg + '"',
|
|
98
|
+
'BUN="' + ovenPath + '"',
|
|
99
|
+
'[ -x "$BUN" ] || BUN="$(command -v bun || true)"',
|
|
100
|
+
'if [ -n "$BUN" ]; then',
|
|
101
|
+
' exec "$BUN" --preload "$PKG/src/tui-preload.js" "$PKG/bin/' + script + '" "$@"',
|
|
102
|
+
"fi",
|
|
103
|
+
'echo "[loom] full TUI needs bun - install with: npm i -g bun (https://bun.sh)" >&2',
|
|
104
|
+
'exec node "$PKG/bin/loom.js" "$@"',
|
|
105
|
+
""
|
|
106
|
+
].join("\n");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
ovenPath = ovenBunPath();
|
|
111
|
+
if (ovenPath) {
|
|
112
|
+
console.log("[loom-agent] bundled bun found: " + ovenPath);
|
|
113
|
+
} else {
|
|
114
|
+
console.log("[loom-agent] bundled bun not present for " + process.platform + "-" + process.arch + " (optional dep skipped?) - shims will use bun from PATH, or fall back to the Node REPL");
|
|
115
|
+
}
|
|
116
|
+
for (var i = 0; i < shimDirs().length; i++) {
|
|
117
|
+
var dir = shimDirs()[i];
|
|
118
|
+
var relPkg = path.relative(dir, pkgRoot).split(path.sep).join("/");
|
|
119
|
+
if (!relPkg || relPkg.startsWith("..")) continue;
|
|
120
|
+
for (var j = 0; j < 2; j++) {
|
|
121
|
+
var name = j === 0 ? "loom" : "loom-tui";
|
|
122
|
+
var script = name === "loom" ? "loom-bun.js" : "loom-tui.js";
|
|
123
|
+
var targets = [
|
|
124
|
+
[name + ".cmd", cmdShim(relPkg, script)],
|
|
125
|
+
[name + ".ps1", psShim(relPkg, script)],
|
|
126
|
+
[name, shShim(relPkg, script)]
|
|
127
|
+
];
|
|
128
|
+
for (var k = 0; k < targets.length; k++) {
|
|
129
|
+
var file = targets[k][0];
|
|
130
|
+
var content = targets[k][1];
|
|
131
|
+
var p = path.join(dir, file);
|
|
132
|
+
if (!fs.existsSync(p)) continue;
|
|
133
|
+
fs.writeFileSync(p, content, { mode: 0o755 });
|
|
134
|
+
console.log("[loom-agent] rewrote shim " + p);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
} catch (err) {
|
|
139
|
+
console.warn("[loom-agent] shim rewrite skipped: " + (err && err.message));
|
|
140
|
+
}
|
package/src/core/cli.js
CHANGED
|
@@ -38,7 +38,7 @@ class LoomCLI {
|
|
|
38
38
|
terminal: true,
|
|
39
39
|
});
|
|
40
40
|
console.log(LOOM_BASE);
|
|
41
|
-
console.log(`\n Loom Code v1.2.
|
|
41
|
+
console.log(`\n Loom Code v1.2.28 — AI Coding Agent for the terminal`);
|
|
42
42
|
console.log(` Press Ctrl+C or ESC to interrupt | /help for commands\n`);
|
|
43
43
|
|
|
44
44
|
process.stdin.on('keypress', (str, key) => {
|
|
@@ -460,7 +460,31 @@ class LoomCLI {
|
|
|
460
460
|
}
|
|
461
461
|
|
|
462
462
|
function findBun() {
|
|
463
|
-
//
|
|
463
|
+
// 1. The bundled bun binary shipped via @oven/bun-<platform> optional deps
|
|
464
|
+
// (npm i -g loom-agent brings its own bun — works with zero setup).
|
|
465
|
+
try {
|
|
466
|
+
const PKGS = {
|
|
467
|
+
'win32-x64': 'bun-windows-x64',
|
|
468
|
+
'win32-arm64': 'bun-windows-aarch64',
|
|
469
|
+
'darwin-x64': 'bun-darwin-x64',
|
|
470
|
+
'darwin-arm64': 'bun-darwin-aarch64',
|
|
471
|
+
'linux-x64': 'bun-linux-x64',
|
|
472
|
+
'linux-arm64': 'bun-linux-aarch64',
|
|
473
|
+
};
|
|
474
|
+
const short = PKGS[process.platform + '-' + process.arch];
|
|
475
|
+
if (short) {
|
|
476
|
+
const exe = process.platform === 'win32' ? 'bun.exe' : 'bun';
|
|
477
|
+
const pkgRoot = path.join(__dirname, '..', '..');
|
|
478
|
+
const candidates = [
|
|
479
|
+
path.join(pkgRoot, 'node_modules', '@oven', short, 'bin', exe), // nested
|
|
480
|
+
path.join(pkgRoot, '..', '@oven', short, 'bin', exe), // hoisted
|
|
481
|
+
];
|
|
482
|
+
for (const c of candidates) {
|
|
483
|
+
if (fs.existsSync(c)) return c;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
} catch {}
|
|
487
|
+
// 2. Common installation paths
|
|
464
488
|
const paths = [
|
|
465
489
|
path.join(os.homedir(), '.bun', 'bin', 'bun.exe'),
|
|
466
490
|
path.join(os.homedir(), '.bun', 'bin', 'bun'),
|
|
@@ -471,7 +495,7 @@ function findBun() {
|
|
|
471
495
|
for (const p of paths) {
|
|
472
496
|
if (fs.existsSync(p)) return p;
|
|
473
497
|
}
|
|
474
|
-
// Search PATH (Windows: where.exe, POSIX: which)
|
|
498
|
+
// 3. Search PATH (Windows: where.exe, POSIX: which)
|
|
475
499
|
try {
|
|
476
500
|
const { execSync } = require('child_process');
|
|
477
501
|
const isWin = process.platform === 'win32';
|
|
@@ -575,19 +599,22 @@ if (args.includes('--help') || args.includes('-h')) {
|
|
|
575
599
|
const tuiEntry = path.join(__dirname, '..', 'tui-open.tsx');
|
|
576
600
|
if (bunPath && fs.existsSync(tuiEntry)) {
|
|
577
601
|
const { spawnSync } = require('child_process');
|
|
578
|
-
//
|
|
579
|
-
//
|
|
580
|
-
//
|
|
602
|
+
// The Solid JSX preloader is registered inside src/tui-preload.js
|
|
603
|
+
// (passed as an ABSOLUTE --preload path), so bun starts directly in
|
|
604
|
+
// the user's project directory. The old "start at the package root,
|
|
605
|
+
// chdir to the project inside tui-open" dance froze the TUI after
|
|
606
|
+
// the first frame (no repaints, no keyboard input) — see
|
|
607
|
+
// bin/loom-bun.js.
|
|
581
608
|
const pkgRoot = path.join(__dirname, '..', '..');
|
|
609
|
+
const tuiPreload = path.join(pkgRoot, 'src', 'tui-preload.js');
|
|
582
610
|
process.env.LOOM_START_CWD = process.cwd();
|
|
583
611
|
process.env.LOOM_BIN_NAME = "loom";
|
|
584
|
-
|
|
585
|
-
const tuiArgs = [tuiEntry];
|
|
612
|
+
const tuiArgs = ['--preload', tuiPreload, tuiEntry];
|
|
586
613
|
if (sessionId) tuiArgs.push('-s', sessionId);
|
|
587
614
|
if (autoMode) tuiArgs.push('--auto');
|
|
588
615
|
const prompt = promptArgs.join(' ');
|
|
589
616
|
if (prompt) tuiArgs.push(...prompt.split(/\s+/));
|
|
590
|
-
process.exit(spawnSync(bunPath, tuiArgs, { stdio: 'inherit', cwd:
|
|
617
|
+
process.exit(spawnSync(bunPath, tuiArgs, { stdio: 'inherit', cwd: process.cwd(), env: process.env }).status ?? 0);
|
|
591
618
|
}
|
|
592
619
|
console.error('[loom] bun not found — full TUI requires bun (https://bun.sh/). Falling back to line-mode REPL.');
|
|
593
620
|
console.error('[loom] Use --basic to skip this warning.\n');
|
package/src/mcp/mcp-client.js
CHANGED
|
@@ -1,4 +1,34 @@
|
|
|
1
1
|
const { spawn } = require('child_process');
|
|
2
|
+
// Minimal OS/runtime baseline handed to every spawned MCP server (see note in
|
|
3
|
+
// connectToJson). Explicit cfg.env keys are layered on top per server.
|
|
4
|
+
const MCP_ENV_KEYS = [
|
|
5
|
+
'PATH', 'PATHEXT', 'COMSPEC', 'SystemRoot', 'windir', 'SystemDrive',
|
|
6
|
+
'TEMP', 'TMP', 'HOME', 'USERPROFILE', 'APPDATA', 'LOCALAPPDATA',
|
|
7
|
+
'PROGRAMFILES', 'PROGRAMDATA', 'ALLUSERSPROFILE', 'COMMONPROGRAMFILES',
|
|
8
|
+
'PROCESSOR_ARCHITECTURE', 'NUMBER_OF_PROCESSORS', 'OS',
|
|
9
|
+
'COMPUTERNAME', 'USERNAME', 'LANG', 'TZ', 'TERM', 'SHELL',
|
|
10
|
+
'XDG_CONFIG_HOME', 'HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY',
|
|
11
|
+
];
|
|
12
|
+
/** @returns {Record<string,string>} filtered copy of process.env */
|
|
13
|
+
function buildMcpBaseEnv() {
|
|
14
|
+
const out = /** @type {Record<string,string>} */ ({});
|
|
15
|
+
for (const k of MCP_ENV_KEYS) {
|
|
16
|
+
const v = process.env[k];
|
|
17
|
+
if (v !== undefined) out[k] = v;
|
|
18
|
+
}
|
|
19
|
+
return out;
|
|
20
|
+
}
|
|
21
|
+
const MCP_BASE_ENV = buildMcpBaseEnv();
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Compose the environment for one MCP server process: the filtered baseline
|
|
25
|
+
* plus whatever that server's config explicitly declares (cfg.env wins).
|
|
26
|
+
* @param {{env?: Record<string,string|undefined>|null}} cfg
|
|
27
|
+
* @returns {Record<string,string>}
|
|
28
|
+
*/
|
|
29
|
+
function mcpSpawnEnv(cfg) {
|
|
30
|
+
return Object.assign({}, MCP_BASE_ENV, cfg.env || {});
|
|
31
|
+
}
|
|
2
32
|
const { loadServers } = require('./mcp-manager');
|
|
3
33
|
|
|
4
34
|
let toolCachePromise = null;
|
|
@@ -22,9 +52,19 @@ function killTree(child) {
|
|
|
22
52
|
|
|
23
53
|
function connectToJson(cfg, timeoutMs) {
|
|
24
54
|
return new Promise((resolve, reject) => {
|
|
55
|
+
// MCP servers are third-party processes. Inheriting the parent's FULL
|
|
56
|
+
// environment would hand every provider API key / cloud token / cookie in
|
|
57
|
+
// the user's shell to whatever npm package a config spawns. Instead pass a
|
|
58
|
+
// minimal OS/runtime baseline plus only what cfg.env explicitly declares —
|
|
59
|
+
// per-server secrets belong in the server's own env config, not leaked by
|
|
60
|
+
// inheritance. (network proxies forwarded; they carry routing, not auth.)
|
|
61
|
+
// Per-server environment via allowlist + explicit cfg.env only — see
|
|
62
|
+
// buildMcpBaseEnv/mcpSpawnEnv above; inheriting all of process.env would
|
|
63
|
+
// leak provider keys to every third-party server.
|
|
64
|
+
const env = mcpSpawnEnv(cfg);
|
|
25
65
|
const child = spawn(cfg.command, cfg.args || [], {
|
|
26
66
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
27
|
-
env
|
|
67
|
+
env,
|
|
28
68
|
// No console window for stdio MCP servers: on Windows spawn() would
|
|
29
69
|
// otherwise pop a flashing console up and down while chats happen.
|
|
30
70
|
windowsHide: true,
|
|
@@ -198,4 +238,4 @@ function getCachedTools() {
|
|
|
198
238
|
return warm();
|
|
199
239
|
}
|
|
200
240
|
|
|
201
|
-
module.exports = { getTools, getCachedTools, clearCache, buildToolName, callTool, warm, callRpc, killTree };
|
|
241
|
+
module.exports = { getTools, getCachedTools, clearCache, buildToolName, callTool, warm, callRpc, killTree, buildMcpBaseEnv, mcpSpawnEnv };
|
|
@@ -11,6 +11,19 @@ function validateUrl(url) {
|
|
|
11
11
|
} catch { return false; }
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Reduce an install target to a single safe folder name under ~/.loom/skills.
|
|
16
|
+
* Strict allowlist (alnum/_/./-, ≤64 chars): drives, separators and '..'
|
|
17
|
+
* segments are rejected outright, never silently collapsed, so remote-provided
|
|
18
|
+
* names can't rename unexpectedly or point outside the skills directory.
|
|
19
|
+
* @param {unknown} name
|
|
20
|
+
* @returns {string|null} cleaned single-segment name, or null when unusable
|
|
21
|
+
*/
|
|
22
|
+
function safeSkillName(name) {
|
|
23
|
+
const s = typeof name === 'string' ? name.trim() : '';
|
|
24
|
+
return /^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/.test(s) ? s : null;
|
|
25
|
+
}
|
|
26
|
+
|
|
14
27
|
const LOOM_DIR = path.join(os.homedir(), '.loom');
|
|
15
28
|
|
|
16
29
|
function globalSkillsDir() {
|
|
@@ -111,8 +124,17 @@ function installFrom(srcDir, targetName) {
|
|
|
111
124
|
if (!fs.existsSync(path.join(src, 'SKILL.md'))) {
|
|
112
125
|
return { error: `No SKILL.md in ${src}` };
|
|
113
126
|
}
|
|
114
|
-
const name = targetName
|
|
115
|
-
|
|
127
|
+
const name = safeSkillName(targetName != null ? targetName : path.basename(src));
|
|
128
|
+
if (!name) {
|
|
129
|
+
return { error: `Invalid skill name: ${JSON.stringify(targetName)} — use letters/digits/-/_ only` };
|
|
130
|
+
}
|
|
131
|
+
const destRoot = globalSkillsDir();
|
|
132
|
+
const dest = path.join(destRoot, name);
|
|
133
|
+
// Paranoia check: `name` is already a sanitized single segment, so this can
|
|
134
|
+
// not trip short of path.join behavior changing underneath us.
|
|
135
|
+
if (path.resolve(dest) !== path.join(path.resolve(destRoot), name)) {
|
|
136
|
+
return { error: 'Invalid install location' };
|
|
137
|
+
}
|
|
116
138
|
if (fs.existsSync(dest)) {
|
|
117
139
|
fs.rmSync(dest, { recursive: true, force: true });
|
|
118
140
|
}
|
|
@@ -210,4 +232,5 @@ module.exports = {
|
|
|
210
232
|
globalSkillsDir,
|
|
211
233
|
agentsSkillsDir,
|
|
212
234
|
projectSkillsDir,
|
|
235
|
+
safeSkillName,
|
|
213
236
|
};
|
package/src/tools/index.js
CHANGED
|
@@ -5,6 +5,7 @@ const { execSync, spawn } = require('child_process');
|
|
|
5
5
|
const { glob: globLib } = require('glob');
|
|
6
6
|
const { commandRiskLabel } = require('../core/permissions');
|
|
7
7
|
const { loadConfig } = require('../config/settings');
|
|
8
|
+
const dnsPromises = require('dns').promises;
|
|
8
9
|
|
|
9
10
|
const cwd = process.cwd();
|
|
10
11
|
|
|
@@ -15,6 +16,74 @@ function globIgnore(full) {
|
|
|
15
16
|
return ['node_modules', '.git'].map((n) => path.posix.join(base, '**', n, '**'));
|
|
16
17
|
}
|
|
17
18
|
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// SSRF guard (webfetch)
|
|
21
|
+
//
|
|
22
|
+
// The agent model decides which URLs webfetch opens, and prompt-injected page
|
|
23
|
+
// content can steer it toward "check http://169.254.169.254/latest/meta-data"
|
|
24
|
+
// or "read localhost:5984/_config". Those must fail closed:
|
|
25
|
+
// - only http/https, never file:/ftp:/data: transport tricks
|
|
26
|
+
// - loopback, link-local (cloud metadata!), RFC1918 private ranges, CGNAT,
|
|
27
|
+
// IPv6 ULA/link-local and IPv4-mapped addresses are refused
|
|
28
|
+
// - hostnames are resolved BEFORE connecting so a DNS name pointing into
|
|
29
|
+
// internal space is caught (checking the literal hostname is not enough)
|
|
30
|
+
// - redirects are followed manually and every hop re-checked, because a
|
|
31
|
+
// public URL can bounce straight at an internal one
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
/**
|
|
34
|
+
* True when an IPv4/IPv6 address string falls in a range the agent must never
|
|
35
|
+
* fetch (loopback, link-local/metadata, private, CGNAT, malformed).
|
|
36
|
+
* @param {string} ip
|
|
37
|
+
* @returns {boolean}
|
|
38
|
+
*/
|
|
39
|
+
function isPrivateAddress(ip) {
|
|
40
|
+
const s = String(ip || '').toLowerCase();
|
|
41
|
+
// IPv6 handling first: exact forms and prefix families we care about.
|
|
42
|
+
if (s.includes(':')) {
|
|
43
|
+
let bare = s.replace(/^\[|\]$/g, '');
|
|
44
|
+
if (bare === '::' || bare === '::1') return true;
|
|
45
|
+
const mapped = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/.exec(bare);
|
|
46
|
+
if (mapped) return isPrivateAddress(mapped[1]);
|
|
47
|
+
if (/^f[cd][0-9a-f]{2}:/.test(bare)) return true; // fc00::/7 ULA
|
|
48
|
+
if (/^fe[89ab][0-9a-f]:/.test(bare)) return true; // fe80::/10 link-local
|
|
49
|
+
if (/^2001:db8:/.test(bare)) return true; // documentation
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(s);
|
|
53
|
+
if (!m) return false;
|
|
54
|
+
const o = m.slice(1).map(Number);
|
|
55
|
+
if (o.some((x) => x > 255)) return true; // malformed → refuse
|
|
56
|
+
const [a, b] = o;
|
|
57
|
+
return (
|
|
58
|
+
a === 0 || // this-network
|
|
59
|
+
a === 10 || // RFC1918
|
|
60
|
+
a === 127 || // loopback
|
|
61
|
+
(a === 100 && b >= 64 && b <= 127) || // CGNAT 100.64/10
|
|
62
|
+
(a === 169 && b === 254) || // link-local / metadata
|
|
63
|
+
(a === 172 && b >= 16 && b <= 31) || // RFC1918
|
|
64
|
+
(a === 192 && b === 168) // RFC1918
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Resolve a URL's host and report whether every resolved address is public.
|
|
70
|
+
* Unresolvable hosts fail closed (false).
|
|
71
|
+
* @param {URL} u
|
|
72
|
+
* @returns {Promise<boolean>}
|
|
73
|
+
*/
|
|
74
|
+
async function urlIsPublic(u) {
|
|
75
|
+
const host = u.hostname.toLowerCase().replace(/^\[|\]$/g, '');
|
|
76
|
+
if (!host || host === 'localhost' || host.endsWith('.localhost')) return false;
|
|
77
|
+
if (isPrivateAddress(host)) return false;
|
|
78
|
+
let addrs;
|
|
79
|
+
try {
|
|
80
|
+
addrs = /** @type {{address: string}[]} */ (await dnsPromises.lookup(host, { all: true }));
|
|
81
|
+
} catch {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
return addrs.length > 0 && addrs.every((a) => !isPrivateAddress(a.address));
|
|
85
|
+
}
|
|
86
|
+
|
|
18
87
|
const MODES = ['build', 'plan', 'chat'];
|
|
19
88
|
|
|
20
89
|
// Tools that never mutate the filesystem/state — safe to expose in plan mode.
|
|
@@ -251,10 +320,36 @@ const TOOLS = {
|
|
|
251
320
|
url: { type: 'string', required: true, description: 'URL to fetch' },
|
|
252
321
|
},
|
|
253
322
|
async execute(params) {
|
|
323
|
+
let u;
|
|
254
324
|
try {
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
return
|
|
325
|
+
u = new URL(String(params.url || ''));
|
|
326
|
+
} catch {
|
|
327
|
+
return { error: 'Invalid URL' };
|
|
328
|
+
}
|
|
329
|
+
if (u.protocol !== 'http:' && u.protocol !== 'https:') {
|
|
330
|
+
return { error: 'Blocked by webfetch policy: only http/https URLs are allowed' };
|
|
331
|
+
}
|
|
332
|
+
try {
|
|
333
|
+
// Follow redirects manually so every hop passes the SSRF guard — a
|
|
334
|
+
// public URL may legitimately bounce into an internal address.
|
|
335
|
+
let current = u;
|
|
336
|
+
const signal = AbortSignal.timeout(15000);
|
|
337
|
+
for (let hop = 0; hop < 5; hop++) {
|
|
338
|
+
if (!(await urlIsPublic(current))) {
|
|
339
|
+
return { error: `Blocked by webfetch policy: ${current.hostname} resolves to a private/reserved address (SSRF guard)` };
|
|
340
|
+
}
|
|
341
|
+
const resp = await fetch(current.toString(), { redirect: 'manual', signal });
|
|
342
|
+
if ([301, 302, 303, 307, 308].includes(resp.status)) {
|
|
343
|
+
const loc = resp.headers.get('location');
|
|
344
|
+
try { resp.body && resp.body.cancel(); } catch {}
|
|
345
|
+
if (!loc) return { error: `Fetch failed: redirect ${resp.status} without Location header` };
|
|
346
|
+
current = new URL(loc, current);
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
const text = await resp.text();
|
|
350
|
+
return text.slice(0, 10000);
|
|
351
|
+
}
|
|
352
|
+
return { error: 'Fetch failed: too many redirects' };
|
|
258
353
|
} catch (e) {
|
|
259
354
|
return { error: `Fetch failed: ${e.message}` };
|
|
260
355
|
}
|
|
@@ -540,4 +635,4 @@ async function getAllToolDefinitions(mode = 'build') {
|
|
|
540
635
|
}
|
|
541
636
|
}
|
|
542
637
|
|
|
543
|
-
module.exports = { TOOLS, MODES, READ_ONLY_TOOLS, getToolDefinitions, getAllToolDefinitions, executeTool };
|
|
638
|
+
module.exports = { TOOLS, MODES, READ_ONLY_TOOLS, getToolDefinitions, getAllToolDefinitions, executeTool, isPrivateAddress };
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Modals -- provider picker, model picker, key input, base URL, settings, palette.
|
|
2
|
-
import { createSignal, onMount } from "solid-js";
|
|
2
|
+
import { createSignal, createMemo, onMount } from "solid-js";
|
|
3
3
|
import { useKeyboard, usePaste } from "@opentui/solid";
|
|
4
4
|
import { palette } from "../theme.ts";
|
|
5
5
|
import * as kbs from "../keybinds.ts";
|
|
@@ -206,17 +206,25 @@ export function SelectModal(props: {
|
|
|
206
206
|
// Start on the first real row (not a header).
|
|
207
207
|
setIndex(firstSelectable());
|
|
208
208
|
|
|
209
|
+
// Hover must not fight scrolling: any keyboard/wheel/search index change
|
|
210
|
+
// locks hover-selection briefly, so the list sliding under a stationary
|
|
211
|
+
// cursor cannot yank the selection back (this feedback loop read as
|
|
212
|
+
// "jitter" while scrolling the model picker).
|
|
213
|
+
let hoverLockUntil = 0;
|
|
214
|
+
const lockHover = () => { hoverLockUntil = Date.now() + 250; };
|
|
215
|
+
const setIndexByKey = (fn: (i: number) => number) => { setIndex(fn); lockHover(); };
|
|
216
|
+
|
|
209
217
|
const nav = kbNav();
|
|
210
218
|
|
|
211
219
|
useKeyboard(key => {
|
|
212
220
|
const ks = kbs.keyString(key);
|
|
213
221
|
if (kbs.is("modal_cancel", ks)) { closeModal(); if (props.onCancel) props.onCancel(); return; }
|
|
214
|
-
if (kbs.dialogIs("dialog_select_prev", ks)) {
|
|
215
|
-
if (kbs.dialogIs("dialog_select_next", ks)) {
|
|
216
|
-
if (kbs.dialogIs("dialog_select_page_up", ks)) {
|
|
217
|
-
if (kbs.dialogIs("dialog_select_page_down", ks)) {
|
|
218
|
-
if (kbs.dialogIs("dialog_select_home", ks)) {
|
|
219
|
-
if (kbs.dialogIs("dialog_select_end", ks)) {
|
|
222
|
+
if (kbs.dialogIs("dialog_select_prev", ks)) { setIndexByKey(i => stepSelectable(i, -1)); firePreview(); return; }
|
|
223
|
+
if (kbs.dialogIs("dialog_select_next", ks)) { setIndexByKey(i => stepSelectable(i, 1)); firePreview(); return; }
|
|
224
|
+
if (kbs.dialogIs("dialog_select_page_up", ks)) { setIndexByKey(i => pageJump(i, -1)); firePreview(); return; }
|
|
225
|
+
if (kbs.dialogIs("dialog_select_page_down", ks)) { setIndexByKey(i => pageJump(i, 1)); firePreview(); return; }
|
|
226
|
+
if (kbs.dialogIs("dialog_select_home", ks)) { setIndexByKey(firstSelectable); firePreview(); return; }
|
|
227
|
+
if (kbs.dialogIs("dialog_select_end", ks)) { setIndexByKey(lastSelectable); firePreview(); return; }
|
|
220
228
|
if (kbs.dialogIs("dialog_select_submit", ks)) {
|
|
221
229
|
const opt = filtered()[index()];
|
|
222
230
|
if (!opt || opt.isHeader) return;
|
|
@@ -226,11 +234,11 @@ export function SelectModal(props: {
|
|
|
226
234
|
if (props.searchable) {
|
|
227
235
|
// Reset to 0, not firstSelectable(): setQ is batched, so firstSelectable()
|
|
228
236
|
// would read the STALE list and land past its end (dead arrows/blank row).
|
|
229
|
-
if (key.name === "backspace") { setQ(v => v.slice(0, -1));
|
|
237
|
+
if (key.name === "backspace") { setQ(v => v.slice(0, -1)); setIndexByKey(() => 0); firePreview(); return; }
|
|
230
238
|
const s = key.sequence;
|
|
231
239
|
if (!key.ctrl && !key.meta && s && s.length <= 10 && s !== "\r" && s !== "\n") {
|
|
232
240
|
setQ(v => v + s);
|
|
233
|
-
|
|
241
|
+
setIndexByKey(() => 0);
|
|
234
242
|
firePreview();
|
|
235
243
|
return;
|
|
236
244
|
}
|
|
@@ -246,7 +254,7 @@ export function SelectModal(props: {
|
|
|
246
254
|
if (j === i) break;
|
|
247
255
|
i = j;
|
|
248
256
|
}
|
|
249
|
-
|
|
257
|
+
setIndexByKey(() => i);
|
|
250
258
|
};
|
|
251
259
|
const clickRow = (i: number) => {
|
|
252
260
|
if (i !== index()) {
|
|
@@ -257,12 +265,14 @@ export function SelectModal(props: {
|
|
|
257
265
|
if (o?.isHeader) return;
|
|
258
266
|
props.onPick(o?.value, o);
|
|
259
267
|
};
|
|
260
|
-
|
|
261
|
-
|
|
268
|
+
// Window of 12 rows, computed ONCE per reactive change (the old version
|
|
269
|
+
// mutated `winStart` from inside the JSX — called three times per render).
|
|
270
|
+
let lastStart = 0;
|
|
271
|
+
const win = createMemo(() => {
|
|
262
272
|
const total = filtered().length;
|
|
263
|
-
|
|
264
|
-
return { total, start:
|
|
265
|
-
};
|
|
273
|
+
lastStart = windowFor(index(), total, 12, lastStart);
|
|
274
|
+
return { total, start: lastStart, items: filtered().slice(lastStart, lastStart + 12) };
|
|
275
|
+
});
|
|
266
276
|
const rangeSub = () => {
|
|
267
277
|
const w = win();
|
|
268
278
|
if (w.total <= 12) return "";
|
|
@@ -271,21 +281,27 @@ export function SelectModal(props: {
|
|
|
271
281
|
|
|
272
282
|
return (
|
|
273
283
|
<ModalFrame title={props.title} subtitle={(props.searchable ? "search: " + (q() || "_") + rangeSub() : rangeSub())} footer={nav.prev + "/" + nav.next + " navigate | " + nav.submit + " select | wheel scroll | " + nav.cancel + " cancel" + (props.searchable ? " | type to search" : "")}>
|
|
274
|
-
|
|
284
|
+
{/* Fixed height: the modal frame must not resize while scrolling.
|
|
285
|
+
Headers used to add an extra margin row, so the centered modal
|
|
286
|
+
bounced between 12/13/14 rows on every page of a header-heavy list
|
|
287
|
+
(the model picker) — the "jitter". Every item is now exactly one
|
|
288
|
+
row and the window is always 12 rows tall. */}
|
|
289
|
+
<box onMouseScroll={scrollBy} height={12} flexShrink={0}>
|
|
275
290
|
{win().items.map((opt, i) => {
|
|
276
291
|
const abs = win().start + i;
|
|
277
292
|
if (opt.isHeader) return (
|
|
278
|
-
<text fg={ui.secondary}
|
|
293
|
+
<text fg={ui.secondary}>
|
|
279
294
|
{opt.header + ":"}
|
|
280
295
|
</text>
|
|
281
296
|
);
|
|
282
297
|
const active = abs === index();
|
|
283
298
|
return (
|
|
284
299
|
<box
|
|
285
|
-
|
|
286
|
-
// Hover moves the selection (live theme preview via onPreview)
|
|
287
|
-
//
|
|
288
|
-
|
|
300
|
+
flexDirection="row" paddingLeft={2}
|
|
301
|
+
// Hover moves the selection (live theme preview via onPreview) —
|
|
302
|
+
// but only for genuine pointer movement, never while a
|
|
303
|
+
// keyboard/wheel scroll is settling (see hoverLockUntil).
|
|
304
|
+
onMouseOver={() => { if (Date.now() >= hoverLockUntil && abs !== index()) { setIndex(abs); firePreview(); } }}
|
|
289
305
|
onMouseDown={() => setIndex(abs)}
|
|
290
306
|
onMouseUp={() => clickRow(abs)}
|
|
291
307
|
>
|
package/src/tui-preload.js
CHANGED
|
@@ -51,6 +51,61 @@ globalThis.__loomTrace = record;
|
|
|
51
51
|
process.on("uncaughtException", (e) => record("uncaughtException", e));
|
|
52
52
|
process.on("unhandledRejection", (r) => record("unhandledRejection", r));
|
|
53
53
|
|
|
54
|
+
// Global npm installs live INSIDE node_modules, and @opentui/solid's loader
|
|
55
|
+
// filter deliberately skips every node_modules path — so for installs the
|
|
56
|
+
// app's own TSX would fall through to Bun's default React JSX transform and
|
|
57
|
+
// crash at startup ("Cannot find module 'react/jsx-dev-runtime'").
|
|
58
|
+
//
|
|
59
|
+
// This preload is THE single registration point for the TUI launch chain
|
|
60
|
+
// (shims and respawns pass ONLY this file via --preload, with an absolute
|
|
61
|
+
// path), so it registers both plugins itself:
|
|
62
|
+
// 1. The Solid JSX plugin — via the bare "@opentui/solid/bun-plugin"
|
|
63
|
+
// specifier, which resolves by walking up from this file and therefore
|
|
64
|
+
// works whether the dependency is nested inside the package or hoisted
|
|
65
|
+
// to the install root. Idempotent (symbol-guarded upstream).
|
|
66
|
+
// 2. A supplemental loader scoped to THIS package's src directory only —
|
|
67
|
+
// real dependencies are never touched. It is a no-op in repo checkouts,
|
|
68
|
+
// where the solid plugin (non-node_modules paths) already handles these
|
|
69
|
+
// files first.
|
|
70
|
+
if (typeof Bun !== "undefined" && Bun.plugin) {
|
|
71
|
+
try {
|
|
72
|
+
require("@opentui/solid/bun-plugin").ensureSolidTransformPlugin();
|
|
73
|
+
} catch {}
|
|
74
|
+
if (!globalThis.__loomAppTsxPlugin) {
|
|
75
|
+
try {
|
|
76
|
+
globalThis.__loomAppTsxPlugin = true;
|
|
77
|
+
const pkgSrc = __dirname; // tui-preload.js lives in src/
|
|
78
|
+
const pkgRoot = path.join(pkgSrc, "..");
|
|
79
|
+
const esc = pkgSrc.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
80
|
+
// Layout-proof transform lookup: nested (npm i -g default observed)
|
|
81
|
+
// and hoisted (top-level install node_modules) candidates.
|
|
82
|
+
const candidates = [
|
|
83
|
+
path.join(pkgRoot, "node_modules", "@opentui", "solid", "scripts", "solid-transform.js"),
|
|
84
|
+
path.join(pkgRoot, "..", "..", "@opentui", "solid", "scripts", "solid-transform.js"),
|
|
85
|
+
];
|
|
86
|
+
let transformSolidSource = null;
|
|
87
|
+
for (const c of candidates) {
|
|
88
|
+
try { transformSolidSource = require(c).transformSolidSource; break; } catch {}
|
|
89
|
+
}
|
|
90
|
+
if (transformSolidSource) {
|
|
91
|
+
Bun.plugin({
|
|
92
|
+
name: "loom-app-solid-tsx",
|
|
93
|
+
setup(build) {
|
|
94
|
+
build.onLoad({ filter: new RegExp("^" + esc + "[\\\\/].+\\.tsx$") }, async (args) => {
|
|
95
|
+
const code = await Bun.file(args.path).text();
|
|
96
|
+
const contents = await transformSolidSource(code, {
|
|
97
|
+
filename: args.path,
|
|
98
|
+
moduleName: "@opentui/solid",
|
|
99
|
+
});
|
|
100
|
+
return { contents, loader: "js" };
|
|
101
|
+
});
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
} catch {}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
54
109
|
// stdout byte-counter: frames flowing = counter climbs. This splits the two
|
|
55
110
|
// remaining frozen-splash suspects with certainty — if the counter climbs but
|
|
56
111
|
// the screen is frozen, the console is dropping VT repaints (mode flags); if
|
package/src/web/web-server.js
CHANGED
|
@@ -111,22 +111,47 @@ function readBody(req, limit = 1_048_576) {
|
|
|
111
111
|
|
|
112
112
|
function sendJson(res, status, obj) {
|
|
113
113
|
const body = JSON.stringify(obj);
|
|
114
|
-
res.writeHead(status,
|
|
114
|
+
res.writeHead(status, Object.assign(securityHeaders(), {
|
|
115
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
116
|
+
'Content-Length': Buffer.byteLength(body),
|
|
117
|
+
}));
|
|
115
118
|
res.end(body);
|
|
116
119
|
}
|
|
117
120
|
|
|
118
121
|
function sendText(res, status, text, extra) {
|
|
119
|
-
const headers = Object.assign(
|
|
122
|
+
const headers = Object.assign(securityHeaders(), {
|
|
123
|
+
'Content-Type': 'text/plain; charset=utf-8',
|
|
124
|
+
'Content-Length': Buffer.byteLength(text),
|
|
125
|
+
}, extra || {});
|
|
120
126
|
res.writeHead(status, headers);
|
|
121
127
|
res.end(text);
|
|
122
128
|
}
|
|
123
129
|
|
|
130
|
+
// Baseline hardening on every response: stops MIME sniffing, clickjacking
|
|
131
|
+
// and referrer leakage. The API is JSON-only and index.html is a single
|
|
132
|
+
// self-contained page (one inline script, no external assets), so this
|
|
133
|
+
// cannot break the UI.
|
|
134
|
+
const SECURITY_HEADERS = {
|
|
135
|
+
'X-Content-Type-Options': 'nosniff',
|
|
136
|
+
'X-Frame-Options': 'DENY',
|
|
137
|
+
'Referrer-Policy': 'no-referrer',
|
|
138
|
+
};
|
|
139
|
+
const HTML_SECURITY_HEADERS = Object.assign({}, SECURITY_HEADERS, {
|
|
140
|
+
'Content-Security-Policy': "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src 'self' data:; connect-src 'self'",
|
|
141
|
+
});
|
|
142
|
+
/** @returns {Record<string,string>} fresh copy so callers can extend safely */
|
|
143
|
+
function securityHeaders() {
|
|
144
|
+
return Object.assign({}, SECURITY_HEADERS);
|
|
145
|
+
}
|
|
146
|
+
|
|
124
147
|
// ── The web server ──────────────────────────────────────────────────────
|
|
125
148
|
|
|
126
149
|
function createWebServer(opts) {
|
|
127
|
-
const tokens = new Map(); // token ->
|
|
150
|
+
const tokens = new Map(); // token -> issued-at ms (auth is server-lifetime, per-token TTL)
|
|
128
151
|
const password = process.env.LOOM_SERVER_PASSWORD || '';
|
|
129
152
|
const username = process.env.LOOM_SERVER_USERNAME || DEFAULT_USERNAME;
|
|
153
|
+
const tokenTtlMs =
|
|
154
|
+
Number((opts && opts.tokenTtlMs) || process.env.LOOM_SERVER_TOKEN_TTL_MS) || 12 * 60 * 60 * 1000;
|
|
130
155
|
const hub = new Map(); // sessionId -> { session, active }
|
|
131
156
|
const stats = { requests: 0, sessionsCreated: 0, messagesRun: 0 };
|
|
132
157
|
const authFails = new Map(); // ip -> { count, lockedUntil }
|
|
@@ -173,10 +198,27 @@ function createWebServer(opts) {
|
|
|
173
198
|
|
|
174
199
|
function authOk(req) {
|
|
175
200
|
if (!password) return true;
|
|
201
|
+
sweepTokens();
|
|
176
202
|
const cookieHeader = req.headers.cookie || '';
|
|
177
203
|
const token = /(?:^|;\s*)loom_token=([^;\s]+)/.exec(cookieHeader)?.[1]
|
|
178
204
|
|| new URL(req.url, 'http://x').searchParams.get('token');
|
|
179
|
-
return !!(token &&
|
|
205
|
+
return !!(token && tokenIsLive(token));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Tokens are stamped at issue time and expire after tokenTtlMs (default 12h,
|
|
209
|
+
// env LOOM_SERVER_TOKEN_TTL_MS). Sweep runs opportunistically on each auth
|
|
210
|
+
// check — the map holds only this server's logins, so it stays tiny.
|
|
211
|
+
function tokenIsLive(token) {
|
|
212
|
+
const issuedAt = tokens.get(token);
|
|
213
|
+
if (typeof issuedAt !== 'number') return false;
|
|
214
|
+
return Date.now() - issuedAt <= tokenTtlMs && Date.now() >= issuedAt - 60_000;
|
|
215
|
+
}
|
|
216
|
+
function sweepTokens() {
|
|
217
|
+
if (!tokens.size) return;
|
|
218
|
+
const now = Date.now();
|
|
219
|
+
for (const [tok, issuedAt] of tokens) {
|
|
220
|
+
if (typeof issuedAt === 'number' && now - issuedAt > tokenTtlMs) tokens.delete(tok);
|
|
221
|
+
}
|
|
180
222
|
}
|
|
181
223
|
|
|
182
224
|
function requireAuth(req, res) {
|
|
@@ -266,8 +308,8 @@ function createWebServer(opts) {
|
|
|
266
308
|
if (body.username === username && passwordMatches(body.password, password)) {
|
|
267
309
|
authFails.delete(ip);
|
|
268
310
|
const token = crypto.randomBytes(24).toString('hex');
|
|
269
|
-
tokens.set(token,
|
|
270
|
-
res.setHeader('Set-Cookie', 'loom_token=' + token + '; Path=/; HttpOnly; SameSite=Strict');
|
|
311
|
+
tokens.set(token, Date.now());
|
|
312
|
+
res.setHeader('Set-Cookie', 'loom_token=' + token + '; Path=/; HttpOnly; SameSite=Strict; Max-Age=' + Math.floor(tokenTtlMs / 1000));
|
|
271
313
|
return sendJson(res, 200, { ok: true, username });
|
|
272
314
|
}
|
|
273
315
|
authAttemptFailed(ip);
|
|
@@ -277,6 +319,16 @@ function createWebServer(opts) {
|
|
|
277
319
|
return sendJson(res, 200, { required: !password ? false : true, username: password ? username : null });
|
|
278
320
|
}
|
|
279
321
|
|
|
322
|
+
// POST /api/auth/logout — revoke the presented session token immediately.
|
|
323
|
+
if (seg[0] === 'api' && seg.length === 3 && seg[1] === 'auth' && seg[2] === 'logout') {
|
|
324
|
+
if (req.method !== 'POST') return sendJson(res, 405, { error: 'method not allowed' });
|
|
325
|
+
const cookieHeader = req.headers.cookie || '';
|
|
326
|
+
const token = /(?:^|;\s*)loom_token=([^;\s]+)/.exec(cookieHeader)?.[1];
|
|
327
|
+
if (token) tokens.delete(token);
|
|
328
|
+
res.setHeader('Set-Cookie', 'loom_token=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0');
|
|
329
|
+
return sendJson(res, 200, { ok: true });
|
|
330
|
+
}
|
|
331
|
+
|
|
280
332
|
if (seg[0] === 'api' && seg[1] === 'health') return sendJson(res, 200, { ok: true });
|
|
281
333
|
|
|
282
334
|
if (!requireAuth(req, res)) return;
|
|
@@ -371,7 +423,10 @@ function createWebServer(opts) {
|
|
|
371
423
|
if (p === '/' || p === '/index.html') {
|
|
372
424
|
fs.readFile(INDEX_FILE, 'utf8', (err, html) => {
|
|
373
425
|
if (err) return sendText(res, 500, 'index.html missing: ' + err.message);
|
|
374
|
-
res.writeHead(200,
|
|
426
|
+
res.writeHead(200, Object.assign(HTML_SECURITY_HEADERS, {
|
|
427
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
428
|
+
'Content-Length': Buffer.byteLength(html),
|
|
429
|
+
}));
|
|
375
430
|
res.end(html);
|
|
376
431
|
});
|
|
377
432
|
return;
|
|
@@ -445,6 +500,16 @@ async function main() {
|
|
|
445
500
|
|
|
446
501
|
console.log('Loom Web — version ' + require('../../package.json').version);
|
|
447
502
|
if (o.password) console.log('Authentication required — user: ' + o.username + ' (LOOM_SERVER_USERNAME)');
|
|
503
|
+
const exposedToLan = o.hostname === '0.0.0.0' || o.hostname === '::';
|
|
504
|
+
if (exposedToLan) {
|
|
505
|
+
console.log('! NETWORK EXPOSURE WARNING !');
|
|
506
|
+
if (!o.password) {
|
|
507
|
+
console.log('! Binding ' + o.hostname + ' WITHOUT a password: anyone on your LAN can run an AI agent ');
|
|
508
|
+
console.log('! on this machine and reach its tools/files. Set LOOM_SERVER_PASSWORD before exposing.');
|
|
509
|
+
} else {
|
|
510
|
+
console.log('! Bound to ' + o.hostname + ' — protected by login, but keep the port firewalled where possible.');
|
|
511
|
+
}
|
|
512
|
+
}
|
|
448
513
|
for (const a of addresses) console.log(' ' + a.label + ': ' + a.url);
|
|
449
514
|
if (o.mdns) {
|
|
450
515
|
const m = advertiseMdns(o, port, (msg) => console.error('[loom web] ' + msg));
|