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 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
- # Loom Code
1
+ ```
2
+ ██╗ ██████╗ ██████╗ ███╗ ███╗ ██████╗ ██████╗ ██████╗ ███████╗
3
+ ██║ ██╔═══██╗██╔═══██╗████╗ ████║ ██╔════╝██╔═══██╗██╔══██╗██╔════╝
4
+ ██║ ██║ ██║██║ ██║██╔████╔██║ ██║ ██║ ██║██║ ██║█████╗
5
+ ██║ ██║ ██║██║ ██║██║╚██╔╝██║ ██║ ██║ ██║██║ ██║██╔══╝
6
+ ███████╗╚██████╔╝╚██████╔╝██║ ╚═╝ ██║ ╚██████╗██████╔╝██████╔╝███████╗
7
+ ╚══════╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝ ╚═════╝╚═════╝ ╚═════╝ ╚══════╝
8
+ ```
2
9
 
3
10
  [![npm version](https://img.shields.io/npm/v/loom-agent?style=flat&color=blue)](https://www.npmjs.com/package/loom-agent)
4
11
  [![License: MIT](https://img.shields.io/npm/l/loom-agent?style=flat&color=green)](LICENSE)
5
12
  [![Platform: Win/Mac/Linux](https://img.shields.io/badge/platform-Win--Mac--Linux-orange)](#)
6
13
 
14
+ ![Loom Code splash — connected to NVIDIA](docs/screenshot.png)
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
- // Bun discovers bunfig.toml ONLY from its starting cwd, and the Solid JSX
9
- // plugin ONLY works when loaded through that bunfig preload phase runtime
10
- // registration from another cwd silently no-ops (proven). So unless bun is
11
- // ALREADY sitting in the package root, respawn it there once. The user's
12
- // real directory rides along in LOOM_START_CWD and is restored by
13
- // tui-open.tsx after its imports finish loading.
14
- const pkgRoot = path.join(__dirname, "..");
15
- const underBun = typeof Bun !== "undefined" && !!process.versions.bun;
16
- if (!underBun) {
17
- const result = spawnSync(
18
- process.platform === "win32" ? "bun.exe" : "bun",
19
- [__filename, ...process.argv.slice(2)],
20
- {
21
- stdio: "inherit",
22
- cwd: pkgRoot,
23
- env: { ...process.env, LOOM_START_CWD: process.env.LOOM_START_CWD || process.cwd() },
24
- windowsHide: false,
25
- }
26
- );
27
- if (result.error) {
28
- console.error("[loom] Bun is required for the full CLI. Install it from https://bun.sh/");
29
- process.exit(1);
30
- }
31
- process.exit(result.status == null ? 1 : result.status);
32
- }
33
- process.title = "loom-code";
34
- (async () => {
35
- try {
36
- // The npm bin entry bypasses src/index.js, so load dotenv here for both
37
- // the package environment and the project from which `loom` was run.
38
- const dotenv = require("dotenv");
39
- dotenv.config({ path: path.join(__dirname, "..", ".env") });
40
- const startCwd = process.env.LOOM_START_CWD || process.cwd();
41
- const projectEnv = path.join(startCwd, ".env");
42
- if (fs.existsSync(projectEnv)) dotenv.config({ path: projectEnv, override: false });
43
- // The npm global shim can start Bun outside the package directory, so do
44
- // not depend on bunfig.toml discovery for Windows console setup.
45
- await import("../src/tui-preload.js");
46
- const args = process.argv.slice(2);
47
- const coreMode = args.includes("--basic") || args.includes("-p") || args.includes("--print")
48
- || args.includes("--help") || args.includes("-h") || args.includes("--version") || args.includes("-v")
49
- || ["acp", "web", "attach", "graph"].includes(args[0]);
50
- if (!coreMode) {
51
- await import("@opentui/solid/preload");
52
- await import("../src/tui-open.tsx");
53
- return;
54
- }
55
- const { main } = require("../src/core/cli.js");
56
- await main();
57
- } catch (err) {
58
- console.error(err && err.message ? err.message : String(err));
59
- process.exit(1);
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
- // Respawn from the package root so bunfig.toml's preload chain (Solid JSX
8
- // transform) loads natively — see bin/loom-bun.js.
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
- const result = spawnSync(
13
- process.platform === "win32" ? "bun.exe" : "bun",
14
- [__filename, ...process.argv.slice(2)],
15
- {
16
- stdio: "inherit",
17
- cwd: pkgRoot,
18
- env: { ...process.env, LOOM_START_CWD: process.env.LOOM_START_CWD || process.cwd() },
19
- windowsHide: false,
20
- }
21
- );
22
- if (result.error) {
23
- console.error("[loom] Bun is required for the TUI. Install it from https://bun.sh/");
24
- process.exit(1);
25
- }
26
- process.exit(result.status == null ? 1 : result.status);
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
- await import("../src/tui-preload.js");
31
- await import("@opentui/solid/preload");
32
- await import("../src/tui-open.tsx");
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.27",
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 — AI-powered coding agent for the terminal. Multi-provider support including NVIDIA. OpenTUI interface.",
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": "ISC",
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.27 — AI Coding Agent for the terminal`);
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
- // Check common installation paths
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
- // Start bun from the package root so it discovers bunfig.toml /
579
- // tsconfig.json (Solid JSX preloader) even for global installs;
580
- // LOOM_START_CWD restores the user's project dir in tui-bootstrap.js.
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
- process.env.BUN_CONFIG = path.join(pkgRoot, "bunfig.toml");
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: pkgRoot, env: process.env }).status ?? 0);
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');
@@ -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: Object.assign({}, process.env, cfg.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 || path.basename(src);
115
- const dest = path.join(globalSkillsDir(), name);
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
  };
@@ -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
- const resp = await fetch(params.url, { signal: AbortSignal.timeout(15000) });
256
- const text = await resp.text();
257
- return text.slice(0, 10000);
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)) { setIndex(i => stepSelectable(i, -1)); firePreview(); return; }
215
- if (kbs.dialogIs("dialog_select_next", ks)) { setIndex(i => stepSelectable(i, 1)); firePreview(); return; }
216
- if (kbs.dialogIs("dialog_select_page_up", ks)) { setIndex(i => pageJump(i, -1)); firePreview(); return; }
217
- if (kbs.dialogIs("dialog_select_page_down", ks)) { setIndex(i => pageJump(i, 1)); firePreview(); return; }
218
- if (kbs.dialogIs("dialog_select_home", ks)) { setIndex(firstSelectable()); firePreview(); return; }
219
- if (kbs.dialogIs("dialog_select_end", ks)) { setIndex(lastSelectable()); firePreview(); return; }
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)); setIndex(0); firePreview(); return; }
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
- setIndex(0);
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
- setIndex(i);
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
- let winStart = 0;
261
- const win = () => {
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
- winStart = windowFor(index(), total, 12, winStart);
264
- return { total, start: winStart, items: filtered().slice(winStart, winStart + 12) };
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
- <box onMouseScroll={scrollBy}>
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} marginTop={i === 0 ? 0 : 1}>
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
- flexDirection="row" paddingLeft={2}
286
- // Hover moves the selection (live theme preview via onPreview);
287
- // a click on the hovered row still selects+submits.
288
- onMouseOver={() => { if (abs !== index()) { setIndex(abs); firePreview(); } }}
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
  >
@@ -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
@@ -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, { 'Content-Type': 'application/json; charset=utf-8', 'Content-Length': Buffer.byteLength(body) });
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({ 'Content-Type': 'text/plain; charset=utf-8', 'Content-Length': Buffer.byteLength(text) }, extra || {});
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 -> true (in-memory auth, server-lifetime)
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 && tokens.has(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, true);
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, { 'Content-Type': 'text/html; charset=utf-8', 'Content-Length': Buffer.byteLength(html) });
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));