@ferrule-io/ok-fine 0.3.7 → 0.3.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -21,7 +21,7 @@ claude mcp add --scope user ok-fine -- npx -y @ferrule-io/ok-fine
21
21
  ```
22
22
 
23
23
  Other harnesses: [Coding agents](https://github.com/ferrule-io/ok-fine/wiki/Coding-Agents#local-setup-stdio). Git
24
- remotes, flags, and several concurrent sessions: [Running locally](https://github.com/ferrule-io/ok-fine/wiki/Running-Locally).
24
+ remotes, flags, and concurrent sessions: [Running locally](https://github.com/ferrule-io/ok-fine/wiki/Running-Locally).
25
25
 
26
26
  ### HTTP server (REST + MCP)
27
27
 
@@ -3,7 +3,7 @@ import { parseArgs } from "node:util";
3
3
  export const USAGE = `Usage: ok-fine [stdio|serve] [options]
4
4
 
5
5
  Commands:
6
- stdio (default) Serve MCP over stdin/stdout for one local client
6
+ stdio (default) Serve MCP over stdin/stdout
7
7
  serve Serve MCP and the REST API over HTTP
8
8
 
9
9
  Options:
package/dist/cli.js CHANGED
@@ -5,7 +5,8 @@ import { pino } from "pino";
5
5
  import { parseCli, USAGE } from "./cli-options.js";
6
6
  import { loadConfig, loadStorageConfig } from "./config.js";
7
7
  import { lockDataDir } from "./data-dir-lock.js";
8
- import { startServer, startStdioServer } from "./server.js";
8
+ import { startLocalHost, startServer } from "./server.js";
9
+ import { startStdioProxy } from "./stdio-proxy.js";
9
10
  import { VERSION } from "./version.js";
10
11
  const message = (err) => (err instanceof Error ? err.message : String(err));
11
12
  /** `git config user.email` in the launch directory; null when unset or git is unavailable. Not `Git`: it forces ok-fine's own email. */
@@ -57,26 +58,23 @@ if (inv.command === "stdio") {
57
58
  }
58
59
  // stdout carries JSON-RPC only; every log line goes to stderr.
59
60
  const log = pino({ level: config.logLevel }, pino.destination({ dest: 2, sync: true }));
60
- try {
61
- const release = await lockDataDir(config.dataDir);
62
- process.once("exit", release);
63
- }
64
- catch (err) {
65
- log.error(message(err));
66
- process.exit(1);
67
- }
68
61
  const identity = await localGitEmail(inv.env);
69
62
  log.info({ dataDir: config.dataDir, identity }, "serving MCP over stdio");
70
- let server;
63
+ let proxy;
71
64
  try {
72
- server = await startStdioServer(config, { log, identity });
65
+ proxy = await startStdioProxy({
66
+ config,
67
+ identity,
68
+ log,
69
+ startHost: () => startLocalHost(config, { log }),
70
+ });
73
71
  }
74
72
  catch (err) {
75
73
  log.error({ err }, "ok-fine failed to start");
76
74
  process.exit(1);
77
75
  }
78
- const shutdown = shutdownOnce(() => server.close(), (err) => log.error({ err }, "shutdown failed"));
79
- void server.done.then(shutdown);
76
+ const shutdown = shutdownOnce(() => proxy.close(), (err) => log.error({ err }, "shutdown failed"));
77
+ void proxy.done.then(shutdown);
80
78
  process.once("SIGINT", shutdown);
81
79
  process.once("SIGTERM", shutdown);
82
80
  }
@@ -99,7 +97,7 @@ else {
99
97
  }
100
98
  let server;
101
99
  try {
102
- server = await startServer(config);
100
+ server = await startServer(config, { hostEndpoint: config.auth.mode === "none" });
103
101
  }
104
102
  catch (err) {
105
103
  console.error("ok-fine failed to start:", err);
@@ -1,43 +1,52 @@
1
1
  import { readFileSync, unlinkSync } from "node:fs";
2
2
  import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
3
- import { join } from "node:path";
4
- function errorCode(err) {
5
- return err?.code;
6
- }
7
- function isRunning(pid) {
3
+ import { join, resolve } from "node:path";
4
+ /** Checks if a process with the given PID is alive. */
5
+ export function isProcessAlive(pid) {
8
6
  try {
9
7
  process.kill(pid, 0);
10
8
  return true;
11
9
  }
12
10
  catch (err) {
13
- return errorCode(err) === "EPERM";
11
+ return err?.code === "EPERM";
14
12
  }
15
13
  }
14
+ /** In-process tracking so concurrent sessions in the same process do not clobber each other's locks. */
15
+ const inProcessLocks = new Set();
16
16
  /**
17
- * Takes the exclusive per-data-dir lock for a local CLI process; a lock left by a dead process is taken over.
18
- * Returns a synchronous release, safe to call from `process.on("exit")`.
17
+ * Attempts to acquire the exclusive per-data-dir lock without throwing.
18
+ * Returns a release function (callable directly or via .release()) on success,
19
+ * or { acquired: false, holderPid } when held by another process or concurrent session.
19
20
  */
20
- export async function lockDataDir(dataDir) {
21
+ export async function tryLockDataDir(dataDir) {
22
+ const canonicalDir = resolve(dataDir);
23
+ if (inProcessLocks.has(canonicalDir)) {
24
+ return { acquired: false, holderPid: process.pid };
25
+ }
21
26
  await mkdir(dataDir, { recursive: true });
22
27
  const lockPath = join(dataDir, "ok-fine.lock");
23
28
  const pid = String(process.pid);
24
- let holder = "unknown";
25
29
  for (let attempt = 0; attempt < 2; attempt++) {
26
30
  try {
27
31
  await writeFile(lockPath, `${pid}\n`, { flag: "wx" });
28
- return () => {
32
+ inProcessLocks.add(canonicalDir);
33
+ const releaseFn = (() => {
34
+ inProcessLocks.delete(canonicalDir);
29
35
  try {
30
36
  if (readFileSync(lockPath, "utf8").trim() === pid)
31
37
  unlinkSync(lockPath);
32
38
  }
33
39
  catch (err) {
34
- if (errorCode(err) !== "ENOENT")
40
+ if (err?.code !== "ENOENT")
35
41
  throw err;
36
42
  }
37
- };
43
+ });
44
+ releaseFn.acquired = true;
45
+ releaseFn.release = releaseFn;
46
+ return releaseFn;
38
47
  }
39
48
  catch (err) {
40
- if (errorCode(err) !== "EEXIST")
49
+ if (err?.code !== "EEXIST")
41
50
  throw err;
42
51
  }
43
52
  let content;
@@ -45,16 +54,46 @@ export async function lockDataDir(dataDir) {
45
54
  content = (await readFile(lockPath, "utf8")).trim();
46
55
  }
47
56
  catch (err) {
48
- if (errorCode(err) === "ENOENT")
57
+ if (err?.code === "ENOENT")
49
58
  continue;
50
59
  throw err;
51
60
  }
52
- holder = content || "unknown";
53
61
  const holderPid = Number.parseInt(content, 10);
54
- if (Number.isInteger(holderPid) && holderPid > 0 && holderPid !== process.pid && isRunning(holderPid))
55
- break;
62
+ if (Number.isInteger(holderPid) && holderPid > 0) {
63
+ if (holderPid !== process.pid && isProcessAlive(holderPid)) {
64
+ return { acquired: false, holderPid };
65
+ }
66
+ if (holderPid === process.pid && inProcessLocks.has(canonicalDir)) {
67
+ return { acquired: false, holderPid: process.pid };
68
+ }
69
+ }
56
70
  await rm(lockPath, { force: true });
57
71
  }
58
- throw new Error(`ok-fine is already running on ${dataDir} (pid ${holder}). One process serves a data directory: connect more clients to \`ok-fine serve\` over HTTP, or pass a different --data-dir. If no ok-fine process is running, delete ${lockPath}.`);
72
+ let content = null;
73
+ try {
74
+ content = (await readFile(lockPath, "utf8")).trim();
75
+ }
76
+ catch {
77
+ // ignore
78
+ }
79
+ const finalHolder = content ? Number.parseInt(content, 10) : null;
80
+ return {
81
+ acquired: false,
82
+ holderPid: finalHolder !== null && Number.isInteger(finalHolder) && finalHolder > 0 ? finalHolder : null,
83
+ };
84
+ }
85
+ /**
86
+ * Takes the exclusive per-data-dir lock for a local CLI process; a lock left by a dead process is taken over.
87
+ * Returns a synchronous release, safe to call from `process.on("exit")`.
88
+ * Throws if already held by a live process or concurrent session.
89
+ */
90
+ export async function lockDataDir(dataDir) {
91
+ const result = await tryLockDataDir(dataDir);
92
+ if (result.acquired) {
93
+ return result;
94
+ }
95
+ const lockPath = join(dataDir, "ok-fine.lock");
96
+ const holder = result.holderPid ?? "unknown";
97
+ throw new Error(`ok-fine is already running on ${dataDir} (pid ${holder}). Close existing ok-fine sessions or pass a different --data-dir. If no ok-fine process is running, delete ${lockPath}.`);
59
98
  }
60
99
  //# sourceMappingURL=data-dir-lock.js.map
package/dist/http/ui.js CHANGED
@@ -17,6 +17,9 @@ const CONTENT_TYPES = {
17
17
  ".txt": "text/plain; charset=utf-8",
18
18
  };
19
19
  const IMMUTABLE = "public, max-age=31536000, immutable";
20
+ const PERMISSIONS_POLICY = "camera=(), display-capture=(), geolocation=(), microphone=()";
21
+ const SVG_CSP = "default-src 'none'; style-src 'unsafe-inline'; sandbox";
22
+ const DEFAULT_CSP = "frame-ancestors 'none'";
20
23
  /** Unique origins of the string http(s) URLs among `endpoints`; ignores anything else. */
21
24
  export function endpointOrigins(...endpoints) {
22
25
  const origins = new Set();
@@ -67,50 +70,61 @@ export async function registerUiRoutes(app, options) {
67
70
  app.log.warn({ dir: options.dir }, "web UI assets not found; /ui disabled");
68
71
  return false;
69
72
  }
70
- const csp = [
73
+ const htmlCsp = [
71
74
  "default-src 'self'",
72
75
  "script-src 'self'",
73
- // Shiki emits inline style attributes.
76
+ // Shiki emits inline style attributes for code block syntax highlighting.
74
77
  "style-src 'self' 'unsafe-inline'",
75
- "img-src 'self' data: https:",
78
+ "img-src 'self' data:",
76
79
  "font-src 'self' data:",
77
80
  `connect-src 'self'${options.connectSrc.map((origin) => ` ${origin}`).join("")}`,
78
81
  "object-src 'none'",
79
82
  "base-uri 'none'",
80
83
  "form-action 'none'",
81
84
  "frame-ancestors 'none'",
85
+ ...(options.publicBaseUrl.startsWith("https://") ? ["upgrade-insecure-requests"] : []),
82
86
  ].join("; ");
83
- const sendHtml = (reply) => reply
84
- .header("content-type", "text/html; charset=utf-8")
85
- .header("cache-control", "no-cache")
86
- .header("referrer-policy", "no-referrer")
87
- .header("x-frame-options", "DENY")
88
- .header("x-content-type-options", "nosniff")
89
- .header("content-security-policy", csp)
90
- .send(index.body);
91
- app.get("/", async (_req, reply) => reply.redirect("/ui/", 302));
92
- app.get("/ui", async (_req, reply) => reply.redirect("/ui/", 301));
93
- app.get("/ui/config.json", async (_req, reply) => reply.header("cache-control", "no-store").header("x-content-type-options", "nosniff").send(options.clientConfig));
94
- app.get("/ui/*", async (req, reply) => {
95
- const rel = req.params["*"];
96
- if (rel === "" || rel === "index.html")
87
+ await app.register(async (ui) => {
88
+ ui.addHook("onSend", async (_req, reply, payload) => {
89
+ reply.header("x-content-type-options", "nosniff");
90
+ reply.header("x-frame-options", "DENY");
91
+ reply.header("referrer-policy", "no-referrer");
92
+ reply.header("cross-origin-opener-policy", "same-origin");
93
+ reply.header("permissions-policy", PERMISSIONS_POLICY);
94
+ const contentType = reply.getHeader("content-type");
95
+ if (typeof contentType === "string" && contentType.startsWith("image/svg+xml")) {
96
+ reply.header("content-security-policy", SVG_CSP);
97
+ }
98
+ else if (!reply.hasHeader("content-security-policy")) {
99
+ reply.header("content-security-policy", DEFAULT_CSP);
100
+ }
101
+ return payload;
102
+ });
103
+ const sendHtml = (reply) => reply
104
+ .header("content-type", "text/html; charset=utf-8")
105
+ .header("cache-control", "no-cache")
106
+ .header("content-security-policy", htmlCsp)
107
+ .send(index.body);
108
+ ui.get("/", async (_req, reply) => reply.redirect("/ui/", 302));
109
+ ui.get("/ui", async (_req, reply) => reply.redirect("/ui/", 301));
110
+ ui.get("/ui/config.json", async (_req, reply) => reply.header("cache-control", "no-store").send(options.clientConfig));
111
+ ui.get("/ui/*", async (req, reply) => {
112
+ const rel = req.params["*"];
113
+ if (rel === "" || rel === "index.html")
114
+ return sendHtml(reply);
115
+ const asset = assets.get(rel);
116
+ if (asset) {
117
+ return reply.header("content-type", asset.type).header("cache-control", asset.cache).send(asset.body);
118
+ }
119
+ const last = rel.slice(rel.lastIndexOf("/") + 1);
120
+ if (/\.[A-Za-z0-9]+$/.test(last)) {
121
+ return reply
122
+ .code(404)
123
+ .header("cache-control", "no-cache")
124
+ .send({ error: { code: "not_found", message: "not found" } });
125
+ }
97
126
  return sendHtml(reply);
98
- const asset = assets.get(rel);
99
- if (asset) {
100
- return reply
101
- .header("content-type", asset.type)
102
- .header("cache-control", asset.cache)
103
- .header("x-content-type-options", "nosniff")
104
- .send(asset.body);
105
- }
106
- const last = rel.slice(rel.lastIndexOf("/") + 1);
107
- if (/\.[A-Za-z0-9]+$/.test(last)) {
108
- return reply
109
- .code(404)
110
- .header("x-content-type-options", "nosniff")
111
- .send({ error: { code: "not_found", message: "not found" } });
112
- }
113
- return sendHtml(reply);
127
+ });
114
128
  });
115
129
  app.log.info({ url: `${options.publicBaseUrl}/ui/` }, "web UI enabled");
116
130
  return true;
@@ -0,0 +1,157 @@
1
+ import { createHash } from "node:crypto";
2
+ import { chmod, rm } from "node:fs/promises";
3
+ import net from "node:net";
4
+ import { tmpdir } from "node:os";
5
+ import { join, resolve } from "node:path";
6
+ import { PassThrough } from "node:stream";
7
+ import { StdioServerTransport, serveStdio } from "@modelcontextprotocol/server/stdio";
8
+ import { z } from "zod";
9
+ import { createMcpServer } from "./mcp/server.js";
10
+ const HelloMessageSchema = z.object({
11
+ okFine: z.literal(1),
12
+ identity: z.string().nullable(),
13
+ });
14
+ /**
15
+ * Resolves the local IPC endpoint path for a given data directory:
16
+ * - Windows: named pipe `\\.\pipe\ok-fine-<first 16 hex of sha256(resolved dataDir)>`
17
+ * - Unix: `DATA_DIR/ok-fine.sock` (or `os.tmpdir()/ok-fine-<hash>.sock` if default exceeds 100 bytes)
18
+ */
19
+ export function localEndpointPath(dataDir) {
20
+ const resolvedDir = resolve(dataDir);
21
+ const hash = createHash("sha256").update(resolvedDir).digest("hex").slice(0, 16);
22
+ if (process.platform === "win32") {
23
+ return `\\\\.\\pipe\\ok-fine-${hash}`;
24
+ }
25
+ const defaultPath = join(resolvedDir, "ok-fine.sock");
26
+ if (Buffer.byteLength(defaultPath) > 100) {
27
+ return join(tmpdir(), `ok-fine-${hash}.sock`);
28
+ }
29
+ return defaultPath;
30
+ }
31
+ /**
32
+ * Starts the local endpoint listener on Unix domain socket or Windows named pipe.
33
+ * Each incoming connection sends a newline-delimited JSON hello with its identity,
34
+ * and receives its own MCP server instance with full local permissions.
35
+ */
36
+ export async function createLocalHost(options) {
37
+ const { service, config, log } = options;
38
+ const endpointPath = localEndpointPath(config.dataDir);
39
+ if (process.platform !== "win32") {
40
+ try {
41
+ await rm(endpointPath, { force: true });
42
+ }
43
+ catch {
44
+ // safe: we hold the data dir lock
45
+ }
46
+ }
47
+ const connections = new Set();
48
+ const server = net.createServer((socket) => {
49
+ socket.on("error", (err) => log.debug({ err }, "local host client socket error"));
50
+ const connRecord = { socket };
51
+ connections.add(connRecord);
52
+ const cleanup = () => {
53
+ connections.delete(connRecord);
54
+ };
55
+ socket.once("close", cleanup);
56
+ socket.once("end", cleanup);
57
+ let buffer = "";
58
+ const onData = (chunk) => {
59
+ buffer += chunk.toString("utf8");
60
+ const idx = buffer.indexOf("\n");
61
+ if (idx === -1) {
62
+ if (buffer.length > 65536) {
63
+ socket.destroy();
64
+ }
65
+ return;
66
+ }
67
+ socket.off("data", onData);
68
+ const firstLine = buffer.slice(0, idx).trim();
69
+ const remainder = buffer.slice(idx + 1);
70
+ let helloRaw;
71
+ try {
72
+ helloRaw = JSON.parse(firstLine);
73
+ }
74
+ catch {
75
+ socket.destroy();
76
+ return;
77
+ }
78
+ const parsed = HelloMessageSchema.safeParse(helloRaw);
79
+ if (!parsed.success) {
80
+ socket.destroy();
81
+ return;
82
+ }
83
+ const identity = parsed.data.identity;
84
+ const principal = {
85
+ subject: "local",
86
+ clientId: "stdio",
87
+ identity,
88
+ scopes: [],
89
+ canRead: true,
90
+ canWrite: true,
91
+ canAdmin: true,
92
+ };
93
+ const inStream = new PassThrough();
94
+ inStream.on("error", (err) => log.debug({ err }, "client inStream error"));
95
+ if (remainder.length > 0) {
96
+ inStream.write(Buffer.from(remainder, "utf8"));
97
+ }
98
+ socket.pipe(inStream);
99
+ const handle = serveStdio(() => createMcpServer(service, principal, log), {
100
+ transport: new StdioServerTransport(inStream, socket),
101
+ onerror: (err) => log.error({ err }, "local host stdio transport error"),
102
+ });
103
+ connRecord.handle = handle;
104
+ };
105
+ socket.on("data", onData);
106
+ });
107
+ const { promise: listenPromise, resolve: resolveListen, reject: rejectListen } = Promise.withResolvers();
108
+ server.once("error", rejectListen);
109
+ server.listen(endpointPath, () => {
110
+ server.off("error", rejectListen);
111
+ resolveListen();
112
+ });
113
+ await listenPromise;
114
+ if (process.platform !== "win32") {
115
+ try {
116
+ await chmod(endpointPath, 0o600);
117
+ }
118
+ catch {
119
+ // safe ignore
120
+ }
121
+ }
122
+ let closing;
123
+ return {
124
+ endpointPath,
125
+ close: () => {
126
+ closing ??= (async () => {
127
+ const { promise: closePromise, resolve: resolveClose } = Promise.withResolvers();
128
+ try {
129
+ server.close(() => resolveClose());
130
+ }
131
+ catch {
132
+ resolveClose();
133
+ }
134
+ const closePromises = [];
135
+ for (const conn of connections) {
136
+ if (conn.handle) {
137
+ closePromises.push(conn.handle.close().catch(() => { }));
138
+ }
139
+ conn.socket.destroy();
140
+ }
141
+ connections.clear();
142
+ await Promise.all(closePromises);
143
+ await closePromise;
144
+ if (process.platform !== "win32") {
145
+ try {
146
+ await rm(endpointPath, { force: true });
147
+ }
148
+ catch {
149
+ // ignore
150
+ }
151
+ }
152
+ })();
153
+ return closing;
154
+ },
155
+ };
156
+ }
157
+ //# sourceMappingURL=local-host.js.map
@@ -3,9 +3,9 @@ import { z } from "zod";
3
3
  import { OkfError } from "../errors.js";
4
4
  import { FEEDBACK_TYPES, feedbackLink } from "../service/feedback.js";
5
5
  import { VERSION } from "../version.js";
6
- export const INSTRUCTIONS = `ok-fine holds shared project knowledge outside the codebase, as OKF v0.2 markdown concepts grouped into projects. In a git repository, first run \`git remote get-url origin\` and call list_projects with that URL as \`repository\`; use the returned project(s) for every read and write. If none match, say the repository is not onboarded and offer to onboard it (ok-fine-onboard skill). Search before non-trivial work; record durable decisions, conventions, and runbooks afterwards.
6
+ export const INSTRUCTIONS = `ok-fine holds shared project knowledge outside the codebase, as OKF v0.2 markdown concepts grouped into projects. In a git repository, first run \`git remote get-url origin\` and call list_projects with that URL as \`repository\`; use the returned project(s) for every read and write. If none match, say the repository is not onboarded and offer to onboard it (ok-fine-onboard skill). Search before planning or editing; record durable decisions, conventions, and runbooks afterwards.
7
7
  1. Discover: get_index (progressive disclosure) or search_concepts with \`project\`.
8
- 2. Read: read_concept returns frontmatter, body, trust tier (unverified | machine-confirmed | human-reviewed), staleness, and links. Freshness comes first: a concept that is stale, or whose code sources changed since \`sources[].commit\`, is a lead to re-check whatever its tier; among fresh concepts prefer higher trust tiers. Deprecated concepts are history; when code contradicts a concept, trust the code and update the concept.
8
+ 2. Read: read_concept returns frontmatter, body, trust tier (unverified | machine-confirmed | human-reviewed), staleness, and links. Freshness comes first: a concept that is stale, when its code sources changed since \`sources[].commit\`, or when that commit is not an ancestor of HEAD (unmerged or rebased away), is a lead to re-check whatever its tier; among fresh concepts prefer higher trust tiers. Knowledge about unmerged work goes into \`proposals/<slug>\` concepts with a \`proposal: { ref: <URI> }\` frontmatter key, never into current-state concepts; concepts carrying \`proposal\` are not current truth. Deprecated concepts are history; when code contradicts a concept, trust the code and update the concept.
9
9
  3. Write: write_concept with frontmatter containing \`type\` (e.g. Decision, Convention, Architecture, Component, Playbook, Interface, Reference) plus \`title\`, \`description\`, \`tags\`, and \`stale_after\` (ISO 8601, e.g. 180 days ahead). Record provenance in \`sources\` (each with \`resource\` and a stable \`id\`; code sources carry \`commit\`) and cite claims with footnotes [^id]. Link concepts with bundle-absolute links such as [orders](/tables/orders.md). After re-checking a concept against the code, refresh \`sources[].commit\` and \`stale_after\` with write_concept, then call verify_concept.
10
10
  4. Pass \`actor\` as <harness>/<model> (e.g. claude-code/claude-opus-4-5, codex/gpt-5-codex, gemini-cli/gemini-2.5-pro). Use human:<email>, with the email from \`git config user.email\`, only when the user personally reviewed the concept; on forbidden_actor, report both identities instead of retrying as another. The server stamps \`generated\`; \`verified\` changes only through verify_concept.
11
11
  5. When updating, pass expectedRevision from read_concept (null to create only).
package/dist/server.js CHANGED
@@ -1,6 +1,5 @@
1
1
  import { setTimeout as sleep } from "node:timers/promises";
2
2
  import { buildOAuthProtectedResourceMetadata, createMcpHandler, getOAuthProtectedResourceMetadataUrl, } from "@modelcontextprotocol/server";
3
- import { StdioServerTransport, serveStdio } from "@modelcontextprotocol/server/stdio";
4
3
  import Fastify, {} from "fastify";
5
4
  import { ZodError } from "zod";
6
5
  import { discoverAuthorizationServer } from "./auth/discovery.js";
@@ -10,6 +9,7 @@ import { OkfError } from "./errors.js";
10
9
  import { registerMcpRoute } from "./http/mcp-route.js";
11
10
  import { registerRestRoutes } from "./http/rest.js";
12
11
  import { DEFAULT_UI_DIR, endpointOrigins, registerUiRoutes } from "./http/ui.js";
12
+ import { createLocalHost } from "./local-host.js";
13
13
  import { createMcpServer } from "./mcp/server.js";
14
14
  import { KnowledgeService } from "./service/knowledge-service.js";
15
15
  import { Catalog } from "./store/catalog.js";
@@ -42,6 +42,9 @@ function parseHostHeader(hostHeader) {
42
42
  return trimmed.toLowerCase();
43
43
  }
44
44
  export async function startServer(config, options = {}) {
45
+ if (options.hostEndpoint && config.auth.mode !== "none") {
46
+ throw new Error("Cannot enable hostEndpoint with auth mode oidc");
47
+ }
45
48
  const tp = config.trustProxy;
46
49
  const trustProxy = typeof tp === "number"
47
50
  ? (_addr, hop) => hop < tp
@@ -147,7 +150,10 @@ export async function startServer(config, options = {}) {
147
150
  }
148
151
  const host = parseHostHeader(req.headers.host);
149
152
  if (!host || !allowedHosts[host]) {
150
- return reply.code(403).send({
153
+ return reply
154
+ .code(403)
155
+ .header("x-content-type-options", "nosniff")
156
+ .send({
151
157
  error: {
152
158
  code: "forbidden",
153
159
  message: `Host header '${req.headers.host ?? ""}' is not allowed in AUTH_MODE=none`,
@@ -170,7 +176,10 @@ export async function startServer(config, options = {}) {
170
176
  }
171
177
  }
172
178
  if (!originAllowed) {
173
- return reply.code(403).send({
179
+ return reply
180
+ .code(403)
181
+ .header("x-content-type-options", "nosniff")
182
+ .send({
174
183
  error: {
175
184
  code: "forbidden",
176
185
  message: `Origin '${rawOrigin ?? ""}' is not allowed in AUTH_MODE=none`,
@@ -262,13 +271,28 @@ export async function startServer(config, options = {}) {
262
271
  registerMcpRoute(secured, (request, options) => mcpHandler.fetch(request, options), config.publicBaseUrl);
263
272
  registerRestRoutes(secured, service);
264
273
  });
265
- const address = await app.listen({ port: config.port, host: config.host });
266
274
  const stopSync = startPeriodicSync(config, service, app.log);
275
+ let localHost;
276
+ if (options.hostEndpoint) {
277
+ localHost = await createLocalHost({ service, config, log: app.log });
278
+ }
279
+ let address;
280
+ try {
281
+ address = await app.listen({ port: config.port, host: config.host });
282
+ }
283
+ catch (err) {
284
+ stopSync();
285
+ await localHost?.close();
286
+ await storage.close();
287
+ await mcpHandler.close();
288
+ throw err;
289
+ }
267
290
  return {
268
291
  app,
269
292
  url: address,
270
293
  close: async () => {
271
294
  stopSync();
295
+ await localHost?.close();
272
296
  await app.close();
273
297
  await storage.close();
274
298
  await mcpHandler.close();
@@ -299,40 +323,22 @@ function startPeriodicSync(config, service, log) {
299
323
  clearTimeout(syncTimer);
300
324
  };
301
325
  }
302
- /** Serves MCP over stdio to one local client with full permissions. Only the transport writes to stdout. */
303
- export async function startStdioServer(config, options) {
326
+ /** Starts the local endpoint host that listens on a domain socket / named pipe. */
327
+ export async function startLocalHost(config, options) {
304
328
  const { log } = options;
305
- const stdin = options.stdin ?? process.stdin;
306
- const stdout = options.stdout ?? process.stdout;
307
329
  const storage = await GitBackend.open(config, log);
308
330
  const catalog = new Catalog();
309
331
  const service = new KnowledgeService({ config, storage, catalog, log });
310
332
  await service.initialize();
311
- const principal = {
312
- subject: "local",
313
- clientId: "stdio",
314
- identity: options.identity,
315
- scopes: [],
316
- canRead: true,
317
- canWrite: true,
318
- canAdmin: true,
319
- };
320
333
  const stopSync = startPeriodicSync(config, service, log);
321
- // serveStdio owns transport.onclose, so watch stdin directly.
322
- const { promise: done, resolve } = Promise.withResolvers();
323
- stdin.once("end", resolve);
324
- stdin.once("close", resolve);
325
- const handle = serveStdio(() => createMcpServer(service, principal, log), {
326
- transport: new StdioServerTransport(stdin, stdout),
327
- onerror: (err) => log.error({ err }, "stdio transport error"),
328
- });
334
+ const host = await createLocalHost({ service, config, log });
329
335
  let closing;
330
336
  return {
331
- done,
337
+ endpointPath: host.endpointPath,
332
338
  close: () => {
333
339
  closing ??= (async () => {
334
340
  stopSync();
335
- await handle.close();
341
+ await host.close();
336
342
  // Waits for the storage mutex, so an in-flight write still commits.
337
343
  await storage.close();
338
344
  })();