localant 1.0.2 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (177) hide show
  1. package/README.ja.md +185 -0
  2. package/README.md +137 -20
  3. package/SECURITY.md +63 -8
  4. package/assets/hero.png +0 -0
  5. package/assets/localant-icon.png +0 -0
  6. package/examples/skills/article-publisher/README.md +41 -0
  7. package/examples/skills/article-publisher/package.json +9 -0
  8. package/examples/skills/article-publisher/skill.json +134 -0
  9. package/examples/skills/article-publisher/src/index.ts +186 -0
  10. package/examples/skills/article-publisher/tests/skill.test.ts +72 -0
  11. package/package.json +15 -5
  12. package/packages/cli/dist/autostart.d.ts +14 -0
  13. package/packages/cli/dist/autostart.d.ts.map +1 -0
  14. package/packages/cli/dist/autostart.js +98 -0
  15. package/packages/cli/dist/autostart.js.map +1 -0
  16. package/packages/cli/dist/bin.js +214 -2
  17. package/packages/cli/dist/bin.js.map +1 -1
  18. package/packages/cli/dist/runtime.d.ts.map +1 -1
  19. package/packages/cli/dist/runtime.js +56 -8
  20. package/packages/cli/dist/runtime.js.map +1 -1
  21. package/packages/cli/dist/serveo-setup.d.ts +37 -0
  22. package/packages/cli/dist/serveo-setup.d.ts.map +1 -0
  23. package/packages/cli/dist/serveo-setup.js +168 -0
  24. package/packages/cli/dist/serveo-setup.js.map +1 -0
  25. package/packages/cli/dist/util.d.ts +6 -0
  26. package/packages/cli/dist/util.d.ts.map +1 -1
  27. package/packages/cli/dist/util.js +20 -0
  28. package/packages/cli/dist/util.js.map +1 -1
  29. package/packages/cli/package.json +1 -1
  30. package/packages/dashboard/dist/index.d.ts +5 -4
  31. package/packages/dashboard/dist/index.d.ts.map +1 -1
  32. package/packages/dashboard/dist/index.js +781 -44
  33. package/packages/dashboard/dist/index.js.map +1 -1
  34. package/packages/gateway/dist/gateway.d.ts +14 -1
  35. package/packages/gateway/dist/gateway.d.ts.map +1 -1
  36. package/packages/gateway/dist/gateway.js +59 -6
  37. package/packages/gateway/dist/gateway.js.map +1 -1
  38. package/packages/gateway/dist/index.d.ts +3 -0
  39. package/packages/gateway/dist/index.d.ts.map +1 -1
  40. package/packages/gateway/dist/index.js +3 -0
  41. package/packages/gateway/dist/index.js.map +1 -1
  42. package/packages/gateway/dist/managers/coding-agent-manager.d.ts +14 -0
  43. package/packages/gateway/dist/managers/coding-agent-manager.d.ts.map +1 -1
  44. package/packages/gateway/dist/managers/coding-agent-manager.js +21 -2
  45. package/packages/gateway/dist/managers/coding-agent-manager.js.map +1 -1
  46. package/packages/gateway/dist/managers/fs-manager.d.ts +73 -0
  47. package/packages/gateway/dist/managers/fs-manager.d.ts.map +1 -1
  48. package/packages/gateway/dist/managers/fs-manager.js +290 -6
  49. package/packages/gateway/dist/managers/fs-manager.js.map +1 -1
  50. package/packages/gateway/dist/managers/git-manager.d.ts +6 -0
  51. package/packages/gateway/dist/managers/git-manager.d.ts.map +1 -1
  52. package/packages/gateway/dist/managers/git-manager.js +24 -0
  53. package/packages/gateway/dist/managers/git-manager.js.map +1 -1
  54. package/packages/gateway/dist/managers/lsp-service.d.ts +88 -0
  55. package/packages/gateway/dist/managers/lsp-service.d.ts.map +1 -0
  56. package/packages/gateway/dist/managers/lsp-service.js +249 -0
  57. package/packages/gateway/dist/managers/lsp-service.js.map +1 -0
  58. package/packages/gateway/dist/managers/mcp-bridge.d.ts +2 -1
  59. package/packages/gateway/dist/managers/mcp-bridge.d.ts.map +1 -1
  60. package/packages/gateway/dist/managers/mcp-bridge.js +23 -2
  61. package/packages/gateway/dist/managers/mcp-bridge.js.map +1 -1
  62. package/packages/gateway/dist/managers/shell-manager.d.ts +19 -0
  63. package/packages/gateway/dist/managers/shell-manager.d.ts.map +1 -1
  64. package/packages/gateway/dist/managers/shell-manager.js +28 -0
  65. package/packages/gateway/dist/managers/shell-manager.js.map +1 -1
  66. package/packages/gateway/dist/managers/skill-runtime.d.ts +8 -0
  67. package/packages/gateway/dist/managers/skill-runtime.d.ts.map +1 -1
  68. package/packages/gateway/dist/managers/skill-runtime.js +15 -0
  69. package/packages/gateway/dist/managers/skill-runtime.js.map +1 -1
  70. package/packages/gateway/dist/managers/tunnel-manager.d.ts +19 -1
  71. package/packages/gateway/dist/managers/tunnel-manager.d.ts.map +1 -1
  72. package/packages/gateway/dist/managers/tunnel-manager.js +289 -8
  73. package/packages/gateway/dist/managers/tunnel-manager.js.map +1 -1
  74. package/packages/gateway/dist/security/command-guard.d.ts +3 -0
  75. package/packages/gateway/dist/security/command-guard.d.ts.map +1 -1
  76. package/packages/gateway/dist/security/command-guard.js +15 -7
  77. package/packages/gateway/dist/security/command-guard.js.map +1 -1
  78. package/packages/gateway/dist/security/path-guard.d.ts +3 -0
  79. package/packages/gateway/dist/security/path-guard.d.ts.map +1 -1
  80. package/packages/gateway/dist/security/path-guard.js +8 -2
  81. package/packages/gateway/dist/security/path-guard.js.map +1 -1
  82. package/packages/gateway/dist/stores/config-store.d.ts +10 -0
  83. package/packages/gateway/dist/stores/config-store.d.ts.map +1 -1
  84. package/packages/gateway/dist/stores/config-store.js +47 -3
  85. package/packages/gateway/dist/stores/config-store.js.map +1 -1
  86. package/packages/gateway/dist/stores/secret-vault.d.ts +19 -3
  87. package/packages/gateway/dist/stores/secret-vault.d.ts.map +1 -1
  88. package/packages/gateway/dist/stores/secret-vault.js +47 -6
  89. package/packages/gateway/dist/stores/secret-vault.js.map +1 -1
  90. package/packages/gateway/dist/tools/adapters.d.ts.map +1 -1
  91. package/packages/gateway/dist/tools/adapters.js +198 -7
  92. package/packages/gateway/dist/tools/adapters.js.map +1 -1
  93. package/packages/gateway/dist/tools/adb.d.ts.map +1 -1
  94. package/packages/gateway/dist/tools/adb.js +42 -0
  95. package/packages/gateway/dist/tools/adb.js.map +1 -1
  96. package/packages/gateway/dist/tools/agent.d.ts +10 -0
  97. package/packages/gateway/dist/tools/agent.d.ts.map +1 -0
  98. package/packages/gateway/dist/tools/agent.js +35 -0
  99. package/packages/gateway/dist/tools/agent.js.map +1 -0
  100. package/packages/gateway/dist/tools/aliases.d.ts +7 -0
  101. package/packages/gateway/dist/tools/aliases.d.ts.map +1 -0
  102. package/packages/gateway/dist/tools/aliases.js +64 -0
  103. package/packages/gateway/dist/tools/aliases.js.map +1 -0
  104. package/packages/gateway/dist/tools/bash.d.ts +10 -0
  105. package/packages/gateway/dist/tools/bash.d.ts.map +1 -0
  106. package/packages/gateway/dist/tools/bash.js +67 -0
  107. package/packages/gateway/dist/tools/bash.js.map +1 -0
  108. package/packages/gateway/dist/tools/browser.d.ts.map +1 -1
  109. package/packages/gateway/dist/tools/browser.js +9 -0
  110. package/packages/gateway/dist/tools/browser.js.map +1 -1
  111. package/packages/gateway/dist/tools/control.d.ts +8 -0
  112. package/packages/gateway/dist/tools/control.d.ts.map +1 -0
  113. package/packages/gateway/dist/tools/control.js +134 -0
  114. package/packages/gateway/dist/tools/control.js.map +1 -0
  115. package/packages/gateway/dist/tools/editing.d.ts +8 -0
  116. package/packages/gateway/dist/tools/editing.d.ts.map +1 -0
  117. package/packages/gateway/dist/tools/editing.js +102 -0
  118. package/packages/gateway/dist/tools/editing.js.map +1 -0
  119. package/packages/gateway/dist/tools/git.d.ts.map +1 -1
  120. package/packages/gateway/dist/tools/git.js +67 -0
  121. package/packages/gateway/dist/tools/git.js.map +1 -1
  122. package/packages/gateway/dist/tools/index.d.ts.map +1 -1
  123. package/packages/gateway/dist/tools/index.js +17 -2
  124. package/packages/gateway/dist/tools/index.js.map +1 -1
  125. package/packages/gateway/dist/tools/lsp.d.ts +10 -0
  126. package/packages/gateway/dist/tools/lsp.d.ts.map +1 -0
  127. package/packages/gateway/dist/tools/lsp.js +111 -0
  128. package/packages/gateway/dist/tools/lsp.js.map +1 -0
  129. package/packages/gateway/dist/tools/question.d.ts +10 -0
  130. package/packages/gateway/dist/tools/question.d.ts.map +1 -0
  131. package/packages/gateway/dist/tools/question.js +30 -0
  132. package/packages/gateway/dist/tools/question.js.map +1 -0
  133. package/packages/gateway/dist/tools/shell.d.ts +1 -1
  134. package/packages/gateway/dist/tools/shell.d.ts.map +1 -1
  135. package/packages/gateway/dist/tools/shell.js +15 -0
  136. package/packages/gateway/dist/tools/shell.js.map +1 -1
  137. package/packages/gateway/dist/tools/skill.d.ts.map +1 -1
  138. package/packages/gateway/dist/tools/skill.js +2 -7
  139. package/packages/gateway/dist/tools/skill.js.map +1 -1
  140. package/packages/gateway/dist/tools/system.js +2 -2
  141. package/packages/gateway/dist/tools/system.js.map +1 -1
  142. package/packages/gateway/dist/tools/validation.d.ts +3 -0
  143. package/packages/gateway/dist/tools/validation.d.ts.map +1 -0
  144. package/packages/gateway/dist/tools/validation.js +120 -0
  145. package/packages/gateway/dist/tools/validation.js.map +1 -0
  146. package/packages/mcp/dist/http-server.d.ts +1 -1
  147. package/packages/mcp/dist/http-server.d.ts.map +1 -1
  148. package/packages/mcp/dist/http-server.js +544 -20
  149. package/packages/mcp/dist/http-server.js.map +1 -1
  150. package/packages/mcp/dist/mcp-server.d.ts.map +1 -1
  151. package/packages/mcp/dist/mcp-server.js +5 -1
  152. package/packages/mcp/dist/mcp-server.js.map +1 -1
  153. package/packages/shared/dist/config.d.ts +146 -16
  154. package/packages/shared/dist/config.d.ts.map +1 -1
  155. package/packages/shared/dist/config.js +93 -7
  156. package/packages/shared/dist/config.js.map +1 -1
  157. package/packages/shared/dist/index.d.ts +2 -0
  158. package/packages/shared/dist/index.d.ts.map +1 -1
  159. package/packages/shared/dist/index.js +2 -0
  160. package/packages/shared/dist/index.js.map +1 -1
  161. package/packages/shared/dist/paths.d.ts +19 -2
  162. package/packages/shared/dist/paths.d.ts.map +1 -1
  163. package/packages/shared/dist/paths.js +50 -3
  164. package/packages/shared/dist/paths.js.map +1 -1
  165. package/packages/shared/dist/tool-profiles.d.ts +34 -0
  166. package/packages/shared/dist/tool-profiles.d.ts.map +1 -0
  167. package/packages/shared/dist/tool-profiles.js +188 -0
  168. package/packages/shared/dist/tool-profiles.js.map +1 -0
  169. package/packages/shared/dist/version.d.ts +9 -0
  170. package/packages/shared/dist/version.d.ts.map +1 -0
  171. package/packages/shared/dist/version.js +9 -0
  172. package/packages/shared/dist/version.js.map +1 -0
  173. package/assets/icon.svg +0 -25
  174. package/packages/gateway/dist/tools/article.d.ts +0 -3
  175. package/packages/gateway/dist/tools/article.d.ts.map +0 -1
  176. package/packages/gateway/dist/tools/article.js +0 -230
  177. package/packages/gateway/dist/tools/article.js.map +0 -1
@@ -1,8 +1,12 @@
1
1
  import http from "node:http";
2
2
  import crypto from "node:crypto";
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
3
6
  import express from "express";
4
7
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
5
- import { createLogger, findAvailablePort } from "@localant/shared";
8
+ import { createLogger, findAvailablePort, APP_VERSION, ConfigSchema, isToolInProfile } from "@localant/shared";
9
+ import { commandExists } from "@localant/gateway";
6
10
  import { dashboardHtml } from "@localant/dashboard";
7
11
  import { buildMcpServer } from "./mcp-server.js";
8
12
  const log = createLogger("http");
@@ -26,13 +30,61 @@ function extractToken(req) {
26
30
  return headerKey;
27
31
  return undefined;
28
32
  }
33
+ /** Hostnames considered local. Used to defend the dashboard against
34
+ * DNS-rebinding (an attacker domain resolving to 127.0.0.1 carries its own
35
+ * Host header, which will not be in this set). */
36
+ const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);
37
+ function isLocalRequest(req) {
38
+ // express strips the port from req.hostname.
39
+ return LOCAL_HOSTS.has(req.hostname);
40
+ }
41
+ /**
42
+ * Minimal fixed-window in-memory rate limiter keyed by client IP. No external
43
+ * dependency; resets every `windowMs`. Returns false when the caller is over
44
+ * the limit.
45
+ */
46
+ function createRateLimiter(limit, windowMs) {
47
+ let windowStart = Date.now();
48
+ let counts = new Map();
49
+ return (key) => {
50
+ const now = Date.now();
51
+ if (now - windowStart >= windowMs) {
52
+ windowStart = now;
53
+ counts = new Map();
54
+ }
55
+ const next = (counts.get(key) ?? 0) + 1;
56
+ counts.set(key, next);
57
+ return next <= limit;
58
+ };
59
+ }
60
+ /** Locate a bundled asset across the dev-tree and published-package layouts. */
61
+ function findAsset(file) {
62
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
63
+ const candidates = [
64
+ path.join(__dirname, "..", "..", "..", "assets", file),
65
+ path.join(__dirname, "..", "assets", file),
66
+ path.join(__dirname, "assets", file),
67
+ path.join(process.cwd(), "assets", file),
68
+ ];
69
+ return candidates.find((c) => fs.existsSync(c));
70
+ }
71
+ function serveAsset(file, res) {
72
+ const found = findAsset(file);
73
+ if (found)
74
+ res.sendFile(found);
75
+ else
76
+ res.status(404).end();
77
+ }
29
78
  /**
30
79
  * Start the public gateway server (/healthz, /status, /mcp) and the local-only
31
80
  * dashboard server. /mcp requires the auth token.
32
81
  */
33
82
  export async function startHttpServers(gw) {
34
83
  const cfg = gw.config();
35
- const token = gw.configStore.getToken();
84
+ // Read the token fresh on every check so rotating it from the dashboard/CLI
85
+ // takes effect immediately, without restarting the gateway.
86
+ const currentToken = () => gw.configStore.getToken();
87
+ const pendingCodes = new Map();
36
88
  // ---------- Public gateway app ----------
37
89
  const app = express();
38
90
  app.use(express.json({ limit: "8mb" }));
@@ -40,14 +92,22 @@ export async function startHttpServers(gw) {
40
92
  app.get("/status", (_req, res) => res.json(gw.runtimeInfo()));
41
93
  const requireAuth = (req, res) => {
42
94
  const provided = extractToken(req);
43
- if (!provided || !tokenMatches(provided, token)) {
95
+ if (!provided || !tokenMatches(provided, currentToken())) {
44
96
  res.status(401).json({ error: "Unauthorized. Provide the auth token via Authorization: Bearer <token> or ?key=<token>." });
45
97
  return false;
46
98
  }
47
99
  return true;
48
100
  };
101
+ // Rate limit the public MCP endpoint to blunt brute-force / abuse over the
102
+ // tunnel. Generous enough for normal ChatGPT use; keyed by client IP.
103
+ const mcpRateLimit = createRateLimiter(120, 60_000);
49
104
  // Streamable HTTP MCP endpoint (stateless: one server+transport per request).
50
105
  app.post("/mcp", async (req, res) => {
106
+ const ip = req.ip ?? req.socket.remoteAddress ?? "unknown";
107
+ if (!mcpRateLimit(ip)) {
108
+ res.status(429).json({ error: "Too many requests. Slow down." });
109
+ return;
110
+ }
51
111
  if (!requireAuth(req, res))
52
112
  return;
53
113
  try {
@@ -69,6 +129,44 @@ export async function startHttpServers(gw) {
69
129
  const methodNotAllowed = (_req, res) => res.status(405).json({ error: "Method not allowed. Use POST for /mcp." });
70
130
  app.get("/mcp", methodNotAllowed);
71
131
  app.delete("/mcp", methodNotAllowed);
132
+ // OAuth 認可エンドポイント
133
+ app.get("/oauth/authorize", (req, res) => {
134
+ const { redirect_uri, state } = req.query;
135
+ if (!redirect_uri) {
136
+ return res.status(400).send("Missing redirect_uri");
137
+ }
138
+ const rt = gw.runtimeInfo();
139
+ if (!rt.dashboard) {
140
+ return res.status(500).send("Dashboard is not running, cannot authorize.");
141
+ }
142
+ const target = `${rt.dashboard}/#oauth/approve?state=${encodeURIComponent(String(state || ""))}&redirect_uri=${encodeURIComponent(String(redirect_uri))}`;
143
+ res.redirect(target);
144
+ });
145
+ // OAuth トークンエンドポイント
146
+ app.post("/oauth/token", express.urlencoded({ extended: true }), (req, res) => {
147
+ const body = req.body || {};
148
+ const { grant_type, code } = body;
149
+ if (grant_type !== "authorization_code") {
150
+ return res.status(400).json({ error: "unsupported_grant_type" });
151
+ }
152
+ if (!code) {
153
+ return res.status(400).json({ error: "invalid_request", error_description: "Missing code" });
154
+ }
155
+ const pending = pendingCodes.get(code);
156
+ if (!pending) {
157
+ return res.status(400).json({ error: "invalid_grant", error_description: "Invalid or expired code" });
158
+ }
159
+ if (Date.now() - pending.createdAt > 600_000) {
160
+ pendingCodes.delete(code);
161
+ return res.status(400).json({ error: "invalid_grant", error_description: "Code expired" });
162
+ }
163
+ pendingCodes.delete(code);
164
+ res.json({
165
+ access_token: currentToken(),
166
+ token_type: "Bearer",
167
+ expires_in: 315360000,
168
+ });
169
+ });
72
170
  const gatewayPort = await findAvailablePort(cfg.gateway.port, cfg.gateway.host);
73
171
  if (gatewayPort !== cfg.gateway.port) {
74
172
  log.info(`port ${cfg.gateway.port} is busy — falling back to ${gatewayPort}`);
@@ -80,40 +178,218 @@ export async function startHttpServers(gw) {
80
178
  let dashboardPort;
81
179
  if (cfg.dashboard.enabled) {
82
180
  dashboardPort = await findAvailablePort(cfg.dashboard.port, "127.0.0.1", [gatewayPort]);
181
+ // Per-process token embedded in the served HTML and required on /api/*.
182
+ // A cross-origin page cannot read the dashboard HTML (so it cannot learn
183
+ // the token) and cannot forge the custom header without a CORS preflight
184
+ // we never grant — this closes the CSRF / token-theft hole.
185
+ const dashToken = crypto.randomBytes(24).toString("base64url");
83
186
  const dash = express();
84
187
  dash.use(express.json({ limit: "2mb" }));
85
- mountDashboardApi(dash, gw);
86
- dash.get("/", (_req, res) => res.type("html").send(dashboardHtml()));
188
+ // DNS-rebinding defense: only serve requests whose Host is local.
189
+ dash.use((req, res, next) => {
190
+ if (!isLocalRequest(req)) {
191
+ res.status(403).json({ error: "Forbidden: dashboard is local-only." });
192
+ return;
193
+ }
194
+ next();
195
+ });
196
+ mountDashboardApi(dash, gw, dashToken, pendingCodes);
197
+ dash.get("/favicon.png", (_req, res) => serveAsset("hero.png", res));
198
+ dash.get("/favicon.ico", (_req, res) => serveAsset("hero.png", res));
199
+ dash.get("/hero.png", (_req, res) => serveAsset("hero.png", res));
200
+ dash.get("/", (_req, res) => res.type("html").send(dashboardHtml(dashToken)));
87
201
  dashboardServer = await listen(dash, dashboardPort, "127.0.0.1");
88
202
  log.info(`dashboard listening on http://127.0.0.1:${dashboardPort}`);
89
203
  }
90
204
  gw.setBoundPorts(gatewayPort, dashboardPort);
91
205
  return { gateway: gatewayServer, dashboard: dashboardServer, gatewayPort, dashboardPort };
92
206
  }
93
- /** Dashboard API — bound to 127.0.0.1 only, so no external auth is required. */
94
- function mountDashboardApi(app, gw) {
207
+ /**
208
+ * Dashboard API — bound to 127.0.0.1 only and additionally gated by a
209
+ * per-process token (defends against CSRF / DNS-rebinding from a browser tab).
210
+ */
211
+ function mountDashboardApi(app, gw, dashToken, pendingCodes) {
95
212
  const r = express.Router();
213
+ r.post("/oauth/approve", (q, s) => {
214
+ const { redirect_uri } = q.body;
215
+ if (!redirect_uri) {
216
+ s.status(400).json({ error: "Missing redirect_uri" });
217
+ return;
218
+ }
219
+ const code = crypto.randomBytes(16).toString("hex");
220
+ pendingCodes.set(code, {
221
+ redirectUri: redirect_uri,
222
+ createdAt: Date.now(),
223
+ });
224
+ s.json({ code });
225
+ });
226
+ // Require the dashboard token on every /api/* call. The token is embedded in
227
+ // the served HTML, so the legitimate same-origin page always has it; a
228
+ // cross-origin attacker cannot read it nor forge the custom header.
229
+ r.use((req, res, next) => {
230
+ const provided = req.header("x-dashboard-token");
231
+ if (!provided || !tokenMatches(provided, dashToken)) {
232
+ res.status(401).json({ error: "Unauthorized dashboard request." });
233
+ return;
234
+ }
235
+ next();
236
+ });
237
+ r.get("/tools", (_q, s) => {
238
+ const profile = gw.config().tools.profile;
239
+ s.json(gw.registry.list().map((t) => {
240
+ const shape = t.inputSchema.shape ?? {};
241
+ const inputSchema = {};
242
+ for (const [key, field] of Object.entries(shape)) {
243
+ const f = field;
244
+ inputSchema[key] = {
245
+ type: getZodTypeString(f),
246
+ description: f.description ?? f._def?.description ?? undefined,
247
+ };
248
+ }
249
+ return {
250
+ name: t.name,
251
+ description: t.description,
252
+ risk: t.risk,
253
+ inputSchema,
254
+ // Whether this tool is advertised to ChatGPT under the active profile.
255
+ active: isToolInProfile(t.name, profile),
256
+ };
257
+ }));
258
+ });
259
+ // --- Tool profile ---
260
+ r.get("/tools/profile", (_q, s) => s.json({ profile: gw.config().tools.profile }));
261
+ r.post("/tools/profile/:name", (q, s) => {
262
+ const name = q.params.name;
263
+ if (!["minimal", "coding", "full"].includes(name)) {
264
+ return s.status(400).json({ error: "profile must be minimal|coding|full" });
265
+ }
266
+ gw.saveConfig({ ...gw.config(), tools: { profile: name } });
267
+ s.json({ profile: name, note: "Restart the gateway for the MCP surface to refresh." });
268
+ });
269
+ // --- Agents detect ---
270
+ r.get("/agents/detect", async (_q, s) => s.json(await gw.agents.list()));
271
+ // --- MCP import-all ---
272
+ r.post("/mcp-servers/import-all", async (_q, s) => {
273
+ const res = await gw.executeTool("mcp_import_all_agent_configs", {}, { caller: "dashboard" });
274
+ s.json(res.data ?? { error: res.error });
275
+ });
276
+ // --- Running shell processes (local visibility; ChatGPT has no equivalent) ---
277
+ r.get("/processes", (_q, s) => s.json({ processes: gw.shell.listProcesses() }));
96
278
  r.get("/status", (_q, s) => s.json(gw.runtimeInfo()));
97
- r.get("/health", (_q, s) => s.json({ status: "ok", version: "1.0.0", time: new Date().toISOString() }));
279
+ r.get("/health", (_q, s) => s.json({ status: "ok", version: APP_VERSION, time: new Date().toISOString() }));
280
+ r.get("/doctor", async (_q, s) => {
281
+ const tools = ["git", "node", "pnpm", "npm", "npx", "claude", "codex", "openclaw", "agy", "hermes", "opencode", "cloudflared", "ngrok", "adb", "docker"];
282
+ const checks = await Promise.all(tools.map(async (name) => ({ name, available: await commandExists(name) })));
283
+ const nodeMajor = Number(process.versions.node.split(".")[0]);
284
+ s.json({
285
+ node: process.version,
286
+ nodeOk: nodeMajor >= 20,
287
+ skillExecOk: nodeMajor >= 22,
288
+ platform: process.platform,
289
+ tools: checks,
290
+ });
291
+ });
98
292
  r.get("/config", (_q, s) => s.json(gw.config()));
99
293
  r.get("/mcp-endpoint", (_q, s) => {
100
294
  const t = gw.tunnel.current();
101
295
  s.json({ endpoint: t.url ? `${t.url.replace(/\/$/, "")}/mcp?key=${gw.configStore.getToken()}` : null, tunnel: t });
102
296
  });
297
+ r.get("/token", (_q, s) => s.json({ token: gw.configStore.getToken() }));
298
+ r.post("/token/rotate", (_q, s) => {
299
+ const token = gw.configStore.rotateToken();
300
+ s.json({ token });
301
+ });
103
302
  r.get("/approvals", (_q, s) => s.json(gw.approvals.listPending()));
104
303
  r.post("/approvals/:id/approve", (q, s) => s.json(gw.approvals.approve(q.params.id, q.body?.scope === "session" ? "session" : "once") ?? { error: "not found" }));
105
304
  r.post("/approvals/:id/deny", (q, s) => s.json(gw.approvals.deny(q.params.id) ?? { error: "not found" }));
106
- r.get("/audit", (_q, s) => s.json(gw.audit.list(100)));
107
- r.get("/skills", (_q, s) => s.json(gw.skills.list().map((sk) => ({
108
- name: sk.manifest.name,
109
- version: sk.manifest.version,
110
- description: sk.manifest.description,
111
- enabled: sk.enabled,
112
- generated: sk.generated,
113
- riskLevel: sk.manifest.riskLevel,
114
- valid: sk.valid,
115
- tools: sk.manifest.tools.map((t) => t.name),
116
- }))));
305
+ r.get("/audit", (q, s) => {
306
+ const query = typeof q.query.q === "string" ? q.query.q.trim() : "";
307
+ s.json(query ? gw.audit.search(query, 100) : gw.audit.list(100));
308
+ });
309
+ r.get("/audit/:id", (q, s) => {
310
+ const entry = gw.audit.get(q.params.id);
311
+ if (!entry) {
312
+ s.status(404).json({ error: "Audit entry not found." });
313
+ return;
314
+ }
315
+ s.json(entry);
316
+ });
317
+ r.get("/skills", (_q, s) => s.json({
318
+ skillsDir: gw.paths.skillsDir,
319
+ skills: gw.skills.list().map((sk) => ({
320
+ name: sk.manifest.name,
321
+ version: sk.manifest.version,
322
+ description: sk.manifest.description,
323
+ enabled: sk.enabled,
324
+ generated: sk.generated,
325
+ riskLevel: sk.manifest.riskLevel,
326
+ valid: sk.valid,
327
+ bundled: !sk.dir.startsWith(gw.paths.skillsDir),
328
+ tools: sk.manifest.tools.map((t) => t.name),
329
+ })),
330
+ }));
331
+ r.get("/skills/:name", (q, s) => {
332
+ const sk = gw.skills.get(q.params.name);
333
+ if (!sk) {
334
+ s.status(404).json({ error: `Skill not found: ${q.params.name}` });
335
+ return;
336
+ }
337
+ s.json({
338
+ ...sk,
339
+ bundled: !sk.dir.startsWith(gw.paths.skillsDir),
340
+ validation: gw.skills.validate(q.params.name),
341
+ });
342
+ });
343
+ r.post("/skills", (q, s) => {
344
+ try {
345
+ const { name, description, riskLevel, requirements } = q.body ?? {};
346
+ if (!name || !description) {
347
+ s.status(400).json({ error: "name and description are required." });
348
+ return;
349
+ }
350
+ const sk = gw.skills.generate({ name, description, riskLevel, requirements });
351
+ s.json({ ...sk, note: "Skill generated DISABLED. Review permissions, then enable it." });
352
+ }
353
+ catch (e) {
354
+ s.status(400).json({ error: e.message });
355
+ }
356
+ });
357
+ r.post("/skills/install", async (q, s) => {
358
+ try {
359
+ const url = q.body?.url;
360
+ if (!url) {
361
+ s.status(400).json({ error: "url is required." });
362
+ return;
363
+ }
364
+ const res = await gw.skills.installFromGit(url);
365
+ s.json({ ...res, note: "Cloned DISABLED. Review permissions, then enable it." });
366
+ }
367
+ catch (e) {
368
+ s.status(400).json({ error: e.message });
369
+ }
370
+ });
371
+ r.post("/skills/:name/run", async (q, s) => {
372
+ try {
373
+ const { tool, input } = q.body ?? {};
374
+ if (!tool) {
375
+ s.status(400).json({ error: "tool is required." });
376
+ return;
377
+ }
378
+ const result = await gw.skills.run(q.params.name, tool, input ?? {});
379
+ s.json({ result });
380
+ }
381
+ catch (e) {
382
+ s.status(400).json({ error: e.message });
383
+ }
384
+ });
385
+ r.delete("/skills/:name", (q, s) => {
386
+ try {
387
+ s.json({ removed: gw.skills.uninstall(q.params.name) });
388
+ }
389
+ catch (e) {
390
+ s.status(400).json({ error: e.message });
391
+ }
392
+ });
117
393
  r.post("/skills/:name/enable", (q, s) => {
118
394
  try {
119
395
  s.json(gw.skills.setEnabled(q.params.name, true));
@@ -122,12 +398,239 @@ function mountDashboardApi(app, gw) {
122
398
  s.status(400).json({ error: e.message });
123
399
  }
124
400
  });
125
- r.post("/skills/:name/disable", (q, s) => s.json(gw.skills.setEnabled(q.params.name, false)));
401
+ r.post("/skills/:name/disable", (q, s) => {
402
+ try {
403
+ s.json(gw.skills.setEnabled(q.params.name, false));
404
+ }
405
+ catch (e) {
406
+ s.status(400).json({ error: e.message });
407
+ }
408
+ });
126
409
  r.get("/projects", (_q, s) => s.json(gw.projects.list()));
410
+ r.post("/projects", (q, s) => {
411
+ try {
412
+ const { path: projectPath, name } = q.body ?? {};
413
+ if (!projectPath) {
414
+ s.status(400).json({ error: "path is required." });
415
+ return;
416
+ }
417
+ s.json(gw.projects.register(projectPath, name));
418
+ }
419
+ catch (e) {
420
+ s.status(400).json({ error: e.message });
421
+ }
422
+ });
423
+ r.delete("/projects/:id", (q, s) => s.json({ removed: gw.projects.unregister(q.params.id) }));
127
424
  r.get("/secrets", (_q, s) => s.json({ names: gw.vault.list() }));
425
+ r.post("/secrets", (q, s) => {
426
+ try {
427
+ const { name, value } = q.body;
428
+ if (!name || !value) {
429
+ s.status(400).json({ error: "Name and value are required." });
430
+ return;
431
+ }
432
+ gw.vault.set(name, value);
433
+ s.json({ ok: true });
434
+ }
435
+ catch (e) {
436
+ s.status(400).json({ error: e.message });
437
+ }
438
+ });
439
+ r.delete("/secrets/:name", (q, s) => {
440
+ try {
441
+ const ok = gw.vault.remove(q.params.name);
442
+ s.json({ ok });
443
+ }
444
+ catch (e) {
445
+ s.status(400).json({ error: e.message });
446
+ }
447
+ });
448
+ r.post("/config", (q, s) => {
449
+ try {
450
+ const current = gw.config();
451
+ const next = mergeConfig(current, q.body);
452
+ const parsed = ConfigSchema.parse(next);
453
+ const saved = gw.saveConfig(parsed);
454
+ s.json(saved);
455
+ }
456
+ catch (e) {
457
+ s.status(400).json({ error: e.message });
458
+ }
459
+ });
128
460
  r.get("/agents", async (_q, s) => s.json(await gw.agents.list()));
461
+ r.post("/agents/:name/enable", async (q, s) => setAgentEnabled(gw, q.params.name, true, s));
462
+ r.post("/agents/:name/disable", async (q, s) => setAgentEnabled(gw, q.params.name, false, s));
463
+ r.post("/agents/run", async (q, s) => {
464
+ try {
465
+ const { agent, projectId, task, mode } = q.body ?? {};
466
+ if (!agent || !projectId || !task) {
467
+ s.status(400).json({ error: "agent, projectId and task are required." });
468
+ return;
469
+ }
470
+ if (mode === "execute") {
471
+ s.json(await gw.agents.startTask(agent, projectId, task));
472
+ }
473
+ else {
474
+ s.json(await gw.agents.plan(agent, projectId, task));
475
+ }
476
+ }
477
+ catch (e) {
478
+ s.status(400).json({ error: e.message });
479
+ }
480
+ });
481
+ r.get("/agents/tasks", (_q, s) => s.json(gw.agents.listTasks()));
482
+ r.get("/agents/tasks/:id/logs", (q, s) => {
483
+ try {
484
+ s.json({ logs: gw.agents.getLogs(q.params.id) });
485
+ }
486
+ catch (e) {
487
+ s.status(404).json({ error: e.message });
488
+ }
489
+ });
490
+ r.post("/agents/tasks/:id/stop", (q, s) => {
491
+ try {
492
+ s.json(gw.agents.stopTask(q.params.id));
493
+ }
494
+ catch (e) {
495
+ s.status(404).json({ error: e.message });
496
+ }
497
+ });
498
+ r.get("/mcp-servers", (_q, s) => {
499
+ const servers = gw.config().mcpServers;
500
+ s.json(Object.entries(servers).map(([name, cfg]) => ({
501
+ name,
502
+ command: cfg.command,
503
+ args: cfg.args,
504
+ transport: cfg.transport,
505
+ enabled: cfg.enabled,
506
+ })));
507
+ });
508
+ r.post("/mcp-servers", (q, s) => {
509
+ try {
510
+ const { name, command, args, enabled } = q.body ?? {};
511
+ if (!name || !command) {
512
+ s.status(400).json({ error: "name and command are required." });
513
+ return;
514
+ }
515
+ const cfg = gw.config();
516
+ const argList = Array.isArray(args) ? args : typeof args === "string" && args.trim() ? args.trim().split(/\s+/) : [];
517
+ gw.saveConfig({
518
+ ...cfg,
519
+ mcpServers: {
520
+ ...cfg.mcpServers,
521
+ [name]: { command, args: argList, transport: "stdio", enabled: enabled !== false },
522
+ },
523
+ });
524
+ s.json({ ok: true });
525
+ }
526
+ catch (e) {
527
+ s.status(400).json({ error: e.message });
528
+ }
529
+ });
530
+ r.delete("/mcp-servers/:name", (q, s) => {
531
+ const cfg = gw.config();
532
+ if (!cfg.mcpServers[q.params.name]) {
533
+ s.status(404).json({ error: `Unknown MCP server: ${q.params.name}` });
534
+ return;
535
+ }
536
+ const next = { ...cfg.mcpServers };
537
+ delete next[q.params.name];
538
+ gw.saveConfig({ ...cfg, mcpServers: next });
539
+ s.json({ removed: true });
540
+ });
541
+ r.post("/mcp-servers/:name/enable", (q, s) => setMcpServerEnabled(gw, q.params.name, true, s));
542
+ r.post("/mcp-servers/:name/disable", (q, s) => setMcpServerEnabled(gw, q.params.name, false, s));
543
+ r.post("/mcp-servers/:name/test", async (q, s) => {
544
+ try {
545
+ const tools = await gw.bridge.listTools(q.params.name);
546
+ s.json({ ok: true, tools: tools.map((t) => t.name) });
547
+ }
548
+ catch (e) {
549
+ s.json({ ok: false, reason: e.message });
550
+ }
551
+ });
552
+ r.get("/tunnel", (_q, s) => s.json(gw.tunnel.current()));
553
+ r.post("/tunnel/test", async (_q, s) => {
554
+ const t = gw.tunnel.current();
555
+ if (!t.url) {
556
+ s.json({ reachable: false, reason: "No tunnel URL — start the tunnel first." });
557
+ return;
558
+ }
559
+ const target = `${t.url.replace(/\/$/, "")}/healthz`;
560
+ const started = Date.now();
561
+ try {
562
+ const ctrl = new AbortController();
563
+ const timer = setTimeout(() => ctrl.abort(), 8000);
564
+ const resp = await fetch(target, { signal: ctrl.signal, redirect: "manual" });
565
+ clearTimeout(timer);
566
+ s.json({ reachable: resp.ok, status: resp.status, ms: Date.now() - started, url: target });
567
+ }
568
+ catch (e) {
569
+ s.json({ reachable: false, ms: Date.now() - started, url: target, reason: e.message });
570
+ }
571
+ });
572
+ r.post("/tunnel/restart", async (_q, s) => {
573
+ try {
574
+ s.json(await gw.restartTunnel());
575
+ }
576
+ catch (e) {
577
+ s.status(500).json({ error: e.message });
578
+ }
579
+ });
580
+ r.post("/tunnel/stop", (_q, s) => {
581
+ gw.tunnel.stop();
582
+ s.json(gw.tunnel.current());
583
+ });
584
+ r.post("/tunnel/start", async (_q, s) => {
585
+ try {
586
+ s.json(await gw.tunnel.start(gw.gatewayPort()));
587
+ }
588
+ catch (e) {
589
+ s.status(500).json({ error: e.message });
590
+ }
591
+ });
129
592
  app.use("/api", r);
130
593
  }
594
+ /** Toggle a coding agent's `enabled` flag in config; 404 if the agent is unknown. */
595
+ async function setAgentEnabled(gw, name, enabled, s) {
596
+ const cfg = gw.config();
597
+ const agent = cfg.codingAgents[name];
598
+ if (!agent) {
599
+ s.status(404).json({ error: `Unknown coding agent: ${name}` });
600
+ return;
601
+ }
602
+ gw.saveConfig({
603
+ ...cfg,
604
+ codingAgents: { ...cfg.codingAgents, [name]: { ...agent, enabled } },
605
+ });
606
+ s.json(await gw.agents.list());
607
+ }
608
+ /** Toggle a downstream MCP server's `enabled` flag in config; 404 if unknown. */
609
+ function setMcpServerEnabled(gw, name, enabled, s) {
610
+ const cfg = gw.config();
611
+ const server = cfg.mcpServers[name];
612
+ if (!server) {
613
+ s.status(404).json({ error: `Unknown MCP server: ${name}` });
614
+ return;
615
+ }
616
+ gw.saveConfig({
617
+ ...cfg,
618
+ mcpServers: { ...cfg.mcpServers, [name]: { ...server, enabled } },
619
+ });
620
+ s.json({ ok: true, enabled });
621
+ }
622
+ function mergeConfig(current, update) {
623
+ const next = { ...current };
624
+ for (const key of Object.keys(update)) {
625
+ if (update[key] !== null && typeof update[key] === "object" && !Array.isArray(update[key])) {
626
+ next[key] = mergeConfig(next[key] || {}, update[key]);
627
+ }
628
+ else {
629
+ next[key] = update[key];
630
+ }
631
+ }
632
+ return next;
633
+ }
131
634
  function listen(app, port, host) {
132
635
  return new Promise((resolve, reject) => {
133
636
  const server = http.createServer(app);
@@ -135,4 +638,25 @@ function listen(app, port, host) {
135
638
  server.listen(port, host, () => resolve(server));
136
639
  });
137
640
  }
641
+ function getZodTypeString(f) {
642
+ if (!f || !f._def)
643
+ return "unknown";
644
+ const typeName = f._def.typeName;
645
+ if (typeName === "ZodOptional") {
646
+ return `${getZodTypeString(f._def.innerType)} (optional)`;
647
+ }
648
+ if (typeName === "ZodNullable") {
649
+ return `${getZodTypeString(f._def.innerType)} (nullable)`;
650
+ }
651
+ if (typeName === "ZodArray") {
652
+ return `${getZodTypeString(f._def.type)}[]`;
653
+ }
654
+ if (typeName === "ZodEnum") {
655
+ return `enum (${f._def.values.join(" | ")})`;
656
+ }
657
+ if (typeName === "ZodEffects") {
658
+ return getZodTypeString(f._def.schema);
659
+ }
660
+ return typeName.replace(/^Zod/, "").toLowerCase();
661
+ }
138
662
  //# sourceMappingURL=http-server.js.map