@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 +1 -1
- package/dist/cli-options.js +1 -1
- package/dist/cli.js +12 -14
- package/dist/data-dir-lock.js +58 -19
- package/dist/http/ui.js +47 -33
- package/dist/local-host.js +157 -0
- package/dist/mcp/server.js +2 -2
- package/dist/server.js +33 -27
- package/dist/stdio-proxy.js +349 -0
- package/dist/ui/assets/highlight-DkgVr4Tn.js +153 -0
- package/dist/ui/assets/{index-0b2NNRC5.js → index-C2IYVK_7.js} +33 -33
- package/dist/ui/index.html +1 -1
- package/dist/version.js +1 -1
- package/package.json +3 -1
- package/dist/ui/assets/highlight-CW19vWvw.js +0 -153
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
|
|
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
|
|
package/dist/cli-options.js
CHANGED
|
@@ -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
|
|
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 {
|
|
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
|
|
63
|
+
let proxy;
|
|
71
64
|
try {
|
|
72
|
-
|
|
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(() =>
|
|
79
|
-
void
|
|
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);
|
package/dist/data-dir-lock.js
CHANGED
|
@@ -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
|
-
|
|
5
|
-
|
|
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
|
|
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
|
-
*
|
|
18
|
-
* Returns a
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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
|
|
55
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
84
|
-
.
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
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
|
package/dist/mcp/server.js
CHANGED
|
@@ -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
|
|
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,
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
303
|
-
export async function
|
|
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
|
-
|
|
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
|
-
|
|
337
|
+
endpointPath: host.endpointPath,
|
|
332
338
|
close: () => {
|
|
333
339
|
closing ??= (async () => {
|
|
334
340
|
stopSync();
|
|
335
|
-
await
|
|
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
|
})();
|