@ferrule-io/ok-fine 0.3.7 → 0.3.8
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/local-host.js +157 -0
- package/dist/mcp/server.js +2 -2
- package/dist/server.js +25 -25
- package/dist/stdio-proxy.js +349 -0
- package/dist/version.js +1 -1
- package/package.json +1 -1
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
|
|
@@ -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
|
|
@@ -262,13 +265,28 @@ export async function startServer(config, options = {}) {
|
|
|
262
265
|
registerMcpRoute(secured, (request, options) => mcpHandler.fetch(request, options), config.publicBaseUrl);
|
|
263
266
|
registerRestRoutes(secured, service);
|
|
264
267
|
});
|
|
265
|
-
const address = await app.listen({ port: config.port, host: config.host });
|
|
266
268
|
const stopSync = startPeriodicSync(config, service, app.log);
|
|
269
|
+
let localHost;
|
|
270
|
+
if (options.hostEndpoint) {
|
|
271
|
+
localHost = await createLocalHost({ service, config, log: app.log });
|
|
272
|
+
}
|
|
273
|
+
let address;
|
|
274
|
+
try {
|
|
275
|
+
address = await app.listen({ port: config.port, host: config.host });
|
|
276
|
+
}
|
|
277
|
+
catch (err) {
|
|
278
|
+
stopSync();
|
|
279
|
+
await localHost?.close();
|
|
280
|
+
await storage.close();
|
|
281
|
+
await mcpHandler.close();
|
|
282
|
+
throw err;
|
|
283
|
+
}
|
|
267
284
|
return {
|
|
268
285
|
app,
|
|
269
286
|
url: address,
|
|
270
287
|
close: async () => {
|
|
271
288
|
stopSync();
|
|
289
|
+
await localHost?.close();
|
|
272
290
|
await app.close();
|
|
273
291
|
await storage.close();
|
|
274
292
|
await mcpHandler.close();
|
|
@@ -299,40 +317,22 @@ function startPeriodicSync(config, service, log) {
|
|
|
299
317
|
clearTimeout(syncTimer);
|
|
300
318
|
};
|
|
301
319
|
}
|
|
302
|
-
/**
|
|
303
|
-
export async function
|
|
320
|
+
/** Starts the local endpoint host that listens on a domain socket / named pipe. */
|
|
321
|
+
export async function startLocalHost(config, options) {
|
|
304
322
|
const { log } = options;
|
|
305
|
-
const stdin = options.stdin ?? process.stdin;
|
|
306
|
-
const stdout = options.stdout ?? process.stdout;
|
|
307
323
|
const storage = await GitBackend.open(config, log);
|
|
308
324
|
const catalog = new Catalog();
|
|
309
325
|
const service = new KnowledgeService({ config, storage, catalog, log });
|
|
310
326
|
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
327
|
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
|
-
});
|
|
328
|
+
const host = await createLocalHost({ service, config, log });
|
|
329
329
|
let closing;
|
|
330
330
|
return {
|
|
331
|
-
|
|
331
|
+
endpointPath: host.endpointPath,
|
|
332
332
|
close: () => {
|
|
333
333
|
closing ??= (async () => {
|
|
334
334
|
stopSync();
|
|
335
|
-
await
|
|
335
|
+
await host.close();
|
|
336
336
|
// Waits for the storage mutex, so an in-flight write still commits.
|
|
337
337
|
await storage.close();
|
|
338
338
|
})();
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
import net from "node:net";
|
|
2
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { isProcessAlive, tryLockDataDir } from "./data-dir-lock.js";
|
|
5
|
+
import { localEndpointPath } from "./local-host.js";
|
|
6
|
+
const JsonRpcMessageSchema = z
|
|
7
|
+
.object({
|
|
8
|
+
jsonrpc: z.string().optional(),
|
|
9
|
+
id: z.union([z.string(), z.number()]).optional(),
|
|
10
|
+
method: z.string().optional(),
|
|
11
|
+
params: z.unknown().optional(),
|
|
12
|
+
result: z.unknown().optional(),
|
|
13
|
+
error: z.unknown().optional(),
|
|
14
|
+
})
|
|
15
|
+
.passthrough();
|
|
16
|
+
function tryConnectSocket(endpoint) {
|
|
17
|
+
const { promise, resolve } = Promise.withResolvers();
|
|
18
|
+
const socket = net.createConnection(endpoint);
|
|
19
|
+
const onError = () => {
|
|
20
|
+
socket.destroy();
|
|
21
|
+
resolve(null);
|
|
22
|
+
};
|
|
23
|
+
socket.once("error", onError);
|
|
24
|
+
socket.once("connect", () => {
|
|
25
|
+
socket.off("error", onError);
|
|
26
|
+
resolve(socket);
|
|
27
|
+
});
|
|
28
|
+
return promise;
|
|
29
|
+
}
|
|
30
|
+
async function connectSocket(endpoint, timeoutMs) {
|
|
31
|
+
const start = Date.now();
|
|
32
|
+
while (Date.now() - start < timeoutMs) {
|
|
33
|
+
const socket = await tryConnectSocket(endpoint);
|
|
34
|
+
if (socket)
|
|
35
|
+
return socket;
|
|
36
|
+
await sleep(25);
|
|
37
|
+
}
|
|
38
|
+
throw new Error(`Failed to connect to local endpoint at ${endpoint}`);
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Starts a stdio proxy session. Every stdio CLI process is a proxy:
|
|
42
|
+
* runs election to start the local endpoint host if it acquires the lock,
|
|
43
|
+
* or attaches to an existing host.
|
|
44
|
+
*/
|
|
45
|
+
export async function startStdioProxy(options) {
|
|
46
|
+
const { config, identity, log, startHost } = options;
|
|
47
|
+
const stdin = options.stdin ?? process.stdin;
|
|
48
|
+
const stdout = options.stdout ?? process.stdout;
|
|
49
|
+
const timeoutMs = options.electionTimeoutMs ?? 30_000;
|
|
50
|
+
const endpoint = localEndpointPath(config.dataDir);
|
|
51
|
+
const { promise: done, resolve: resolveDone } = Promise.withResolvers();
|
|
52
|
+
let shuttingDown = false;
|
|
53
|
+
let isFailingOver = false;
|
|
54
|
+
let isReconnecting = false;
|
|
55
|
+
let activeSocket = null;
|
|
56
|
+
let activeHost = null;
|
|
57
|
+
let activeLockRelease = null;
|
|
58
|
+
/** Drops the current socket and, if this process hosts, closes the host and releases the lock. */
|
|
59
|
+
async function teardownActive() {
|
|
60
|
+
const socket = activeSocket;
|
|
61
|
+
activeSocket = null;
|
|
62
|
+
if (socket) {
|
|
63
|
+
socket.removeAllListeners();
|
|
64
|
+
socket.destroy();
|
|
65
|
+
}
|
|
66
|
+
const host = activeHost;
|
|
67
|
+
activeHost = null;
|
|
68
|
+
if (host)
|
|
69
|
+
await host.close().catch((err) => log.error({ err }, "closing local host failed"));
|
|
70
|
+
const release = activeLockRelease;
|
|
71
|
+
activeLockRelease = null;
|
|
72
|
+
release?.();
|
|
73
|
+
}
|
|
74
|
+
let storedInitialize = null;
|
|
75
|
+
let storedInitialized = null;
|
|
76
|
+
const inFlightRequests = new Map();
|
|
77
|
+
const pendingStdinLines = [];
|
|
78
|
+
let replaySeq = 0;
|
|
79
|
+
let awaitingReplayId = null;
|
|
80
|
+
let onReplayResponse = null;
|
|
81
|
+
async function runElection(deadline) {
|
|
82
|
+
while (Date.now() < deadline && !shuttingDown) {
|
|
83
|
+
const lockRes = await tryLockDataDir(config.dataDir);
|
|
84
|
+
if (lockRes.acquired) {
|
|
85
|
+
activeLockRelease = lockRes.release;
|
|
86
|
+
try {
|
|
87
|
+
activeHost = await startHost();
|
|
88
|
+
const remainingMs = Math.min(5000, Math.max(100, deadline - Date.now()));
|
|
89
|
+
return await connectSocket(endpoint, remainingMs);
|
|
90
|
+
}
|
|
91
|
+
catch (err) {
|
|
92
|
+
if (activeHost) {
|
|
93
|
+
await activeHost.close().catch(() => { });
|
|
94
|
+
activeHost = null;
|
|
95
|
+
}
|
|
96
|
+
activeLockRelease();
|
|
97
|
+
activeLockRelease = null;
|
|
98
|
+
throw err;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const holderPid = lockRes.holderPid;
|
|
102
|
+
if (holderPid !== null && isProcessAlive(holderPid)) {
|
|
103
|
+
const socket = await tryConnectSocket(endpoint);
|
|
104
|
+
if (socket)
|
|
105
|
+
return socket;
|
|
106
|
+
}
|
|
107
|
+
await sleep(50);
|
|
108
|
+
}
|
|
109
|
+
const timeoutStr = timeoutMs % 1000 === 0 ? `${timeoutMs / 1000}s` : `${timeoutMs}ms`;
|
|
110
|
+
throw new Error(`Timed out waiting for ok-fine host on ${config.dataDir} after ${timeoutStr}`);
|
|
111
|
+
}
|
|
112
|
+
function handleStdinLine(line) {
|
|
113
|
+
let msg = null;
|
|
114
|
+
try {
|
|
115
|
+
const parsed = JsonRpcMessageSchema.safeParse(JSON.parse(line));
|
|
116
|
+
if (parsed.success)
|
|
117
|
+
msg = parsed.data;
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
// Non-JSON line, forward as-is
|
|
121
|
+
}
|
|
122
|
+
if (msg) {
|
|
123
|
+
if (msg.method === "initialize" && msg.id !== undefined) {
|
|
124
|
+
storedInitialize = { raw: line, parsed: msg };
|
|
125
|
+
}
|
|
126
|
+
else if (msg.method === "notifications/initialized") {
|
|
127
|
+
storedInitialized = line;
|
|
128
|
+
}
|
|
129
|
+
if (msg.id !== undefined && msg.method !== undefined) {
|
|
130
|
+
inFlightRequests.set(msg.id, msg.id);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (isReconnecting || !activeSocket || activeSocket.destroyed || !activeSocket.writable) {
|
|
134
|
+
pendingStdinLines.push(line);
|
|
135
|
+
}
|
|
136
|
+
else {
|
|
137
|
+
activeSocket.write(`${line}\n`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
let stdinBuffer = "";
|
|
141
|
+
function onStdinData(chunk) {
|
|
142
|
+
stdinBuffer += typeof chunk === "string" ? chunk : chunk.toString("utf8");
|
|
143
|
+
while (true) {
|
|
144
|
+
const idx = stdinBuffer.indexOf("\n");
|
|
145
|
+
if (idx === -1)
|
|
146
|
+
break;
|
|
147
|
+
const line = stdinBuffer.slice(0, idx).replace(/\r$/, "");
|
|
148
|
+
stdinBuffer = stdinBuffer.slice(idx + 1);
|
|
149
|
+
if (line.length > 0) {
|
|
150
|
+
handleStdinLine(line);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
function handleSocketLine(line) {
|
|
155
|
+
let msg = null;
|
|
156
|
+
try {
|
|
157
|
+
const parsed = JsonRpcMessageSchema.safeParse(JSON.parse(line));
|
|
158
|
+
if (parsed.success)
|
|
159
|
+
msg = parsed.data;
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
// Non-JSON line
|
|
163
|
+
}
|
|
164
|
+
if (msg && awaitingReplayId !== null && msg.id === awaitingReplayId) {
|
|
165
|
+
const resolve = onReplayResponse;
|
|
166
|
+
awaitingReplayId = null;
|
|
167
|
+
onReplayResponse = null;
|
|
168
|
+
resolve?.();
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
if (msg && msg.id !== undefined && (msg.result !== undefined || msg.error !== undefined)) {
|
|
172
|
+
inFlightRequests.delete(msg.id);
|
|
173
|
+
}
|
|
174
|
+
stdout.write(`${line}\n`);
|
|
175
|
+
}
|
|
176
|
+
let socketBuffer = "";
|
|
177
|
+
function onSocketData(chunk) {
|
|
178
|
+
socketBuffer += typeof chunk === "string" ? chunk : chunk.toString("utf8");
|
|
179
|
+
while (true) {
|
|
180
|
+
const idx = socketBuffer.indexOf("\n");
|
|
181
|
+
if (idx === -1)
|
|
182
|
+
break;
|
|
183
|
+
const line = socketBuffer.slice(0, idx).replace(/\r$/, "");
|
|
184
|
+
socketBuffer = socketBuffer.slice(idx + 1);
|
|
185
|
+
if (line.length > 0) {
|
|
186
|
+
handleSocketLine(line);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
async function handleSocketClose() {
|
|
191
|
+
if (shuttingDown || isFailingOver)
|
|
192
|
+
return;
|
|
193
|
+
isFailingOver = true;
|
|
194
|
+
isReconnecting = true;
|
|
195
|
+
try {
|
|
196
|
+
await teardownActive();
|
|
197
|
+
// Answer every in-flight request with host restart error
|
|
198
|
+
for (const id of inFlightRequests.values()) {
|
|
199
|
+
const errorResponse = {
|
|
200
|
+
jsonrpc: "2.0",
|
|
201
|
+
id,
|
|
202
|
+
error: {
|
|
203
|
+
code: -32603,
|
|
204
|
+
message: "ok-fine host restarted; retry the request",
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
stdout.write(`${JSON.stringify(errorResponse)}\n`);
|
|
208
|
+
}
|
|
209
|
+
inFlightRequests.clear();
|
|
210
|
+
const deadline = Date.now() + timeoutMs;
|
|
211
|
+
let connectedSocket = null;
|
|
212
|
+
while (Date.now() < deadline && !shuttingDown) {
|
|
213
|
+
try {
|
|
214
|
+
const socket = await runElection(deadline);
|
|
215
|
+
attachSocket(socket);
|
|
216
|
+
socket.write(`${JSON.stringify({ okFine: 1, identity })}\n`);
|
|
217
|
+
if (storedInitialize) {
|
|
218
|
+
const replayId = `okf-replay-${++replaySeq}`;
|
|
219
|
+
awaitingReplayId = replayId;
|
|
220
|
+
const remainingMs = Math.max(1, deadline - Date.now());
|
|
221
|
+
const replayTimeoutMs = Math.min(timeoutMs, remainingMs);
|
|
222
|
+
const { promise: replayPromise, resolve: resolveReplay, reject: rejectReplay, } = Promise.withResolvers();
|
|
223
|
+
onReplayResponse = resolveReplay;
|
|
224
|
+
const onEarlyClose = () => {
|
|
225
|
+
rejectReplay(new Error("Socket closed while awaiting initialize replay response"));
|
|
226
|
+
};
|
|
227
|
+
const onEarlyError = (err) => {
|
|
228
|
+
rejectReplay(err);
|
|
229
|
+
};
|
|
230
|
+
const timer = setTimeout(() => {
|
|
231
|
+
const timeoutStr = replayTimeoutMs % 1000 === 0 ? `${replayTimeoutMs / 1000}s` : `${replayTimeoutMs}ms`;
|
|
232
|
+
rejectReplay(new Error(`Timed out awaiting initialize replay response after ${timeoutStr}`));
|
|
233
|
+
}, replayTimeoutMs);
|
|
234
|
+
socket.once("close", onEarlyClose);
|
|
235
|
+
socket.once("error", onEarlyError);
|
|
236
|
+
try {
|
|
237
|
+
const replayReq = { ...storedInitialize.parsed, id: replayId };
|
|
238
|
+
socket.write(`${JSON.stringify(replayReq)}\n`);
|
|
239
|
+
await replayPromise;
|
|
240
|
+
if (storedInitialized) {
|
|
241
|
+
socket.write(`${storedInitialized}\n`);
|
|
242
|
+
}
|
|
243
|
+
else {
|
|
244
|
+
socket.write(`${JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" })}\n`);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
finally {
|
|
248
|
+
clearTimeout(timer);
|
|
249
|
+
socket.off("close", onEarlyClose);
|
|
250
|
+
socket.off("error", onEarlyError);
|
|
251
|
+
if (awaitingReplayId === replayId) {
|
|
252
|
+
awaitingReplayId = null;
|
|
253
|
+
onReplayResponse = null;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
connectedSocket = socket;
|
|
258
|
+
break;
|
|
259
|
+
}
|
|
260
|
+
catch (attemptErr) {
|
|
261
|
+
log.debug({ err: attemptErr }, "failover attempt failed, retrying");
|
|
262
|
+
await teardownActive();
|
|
263
|
+
if (Date.now() >= deadline || shuttingDown) {
|
|
264
|
+
throw attemptErr;
|
|
265
|
+
}
|
|
266
|
+
await sleep(50);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
if (!connectedSocket && !shuttingDown) {
|
|
270
|
+
const timeoutStr = timeoutMs % 1000 === 0 ? `${timeoutMs / 1000}s` : `${timeoutMs}ms`;
|
|
271
|
+
throw new Error(`Timed out waiting for ok-fine host on ${config.dataDir} after ${timeoutStr}`);
|
|
272
|
+
}
|
|
273
|
+
isReconnecting = false;
|
|
274
|
+
const queued = pendingStdinLines.splice(0, pendingStdinLines.length);
|
|
275
|
+
for (const q of queued) {
|
|
276
|
+
connectedSocket?.write(`${q}\n`);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
catch (err) {
|
|
280
|
+
log.error({ err }, "proxy failover failed");
|
|
281
|
+
await shutdown();
|
|
282
|
+
}
|
|
283
|
+
finally {
|
|
284
|
+
isFailingOver = false;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
function attachSocket(socket) {
|
|
288
|
+
activeSocket = socket;
|
|
289
|
+
socketBuffer = "";
|
|
290
|
+
socket.on("data", onSocketData);
|
|
291
|
+
socket.on("error", (err) => log.debug({ err }, "proxy socket error"));
|
|
292
|
+
socket.once("close", () => {
|
|
293
|
+
void handleSocketClose();
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
async function shutdown() {
|
|
297
|
+
if (shuttingDown)
|
|
298
|
+
return;
|
|
299
|
+
shuttingDown = true;
|
|
300
|
+
stdin.off("data", onStdinData);
|
|
301
|
+
if (activeSocket) {
|
|
302
|
+
activeSocket.removeAllListeners();
|
|
303
|
+
activeSocket.destroy();
|
|
304
|
+
activeSocket = null;
|
|
305
|
+
}
|
|
306
|
+
if (activeHost) {
|
|
307
|
+
try {
|
|
308
|
+
await activeHost.close();
|
|
309
|
+
}
|
|
310
|
+
catch (err) {
|
|
311
|
+
log.error({ err }, "error closing host on shutdown");
|
|
312
|
+
}
|
|
313
|
+
activeHost = null;
|
|
314
|
+
}
|
|
315
|
+
if (activeLockRelease) {
|
|
316
|
+
try {
|
|
317
|
+
activeLockRelease();
|
|
318
|
+
}
|
|
319
|
+
catch (err) {
|
|
320
|
+
log.error({ err }, "error releasing lock on shutdown");
|
|
321
|
+
}
|
|
322
|
+
activeLockRelease = null;
|
|
323
|
+
}
|
|
324
|
+
resolveDone();
|
|
325
|
+
}
|
|
326
|
+
// Initial election and connection
|
|
327
|
+
const initialSocket = await runElection(Date.now() + timeoutMs);
|
|
328
|
+
attachSocket(initialSocket);
|
|
329
|
+
initialSocket.write(`${JSON.stringify({ okFine: 1, identity })}\n`);
|
|
330
|
+
stdin.on("data", onStdinData);
|
|
331
|
+
stdin.once("end", () => {
|
|
332
|
+
void shutdown();
|
|
333
|
+
});
|
|
334
|
+
stdin.once("close", () => {
|
|
335
|
+
void shutdown();
|
|
336
|
+
});
|
|
337
|
+
if (stdin.readableEnded || stdin.destroyed) {
|
|
338
|
+
void shutdown();
|
|
339
|
+
}
|
|
340
|
+
let closing;
|
|
341
|
+
return {
|
|
342
|
+
done,
|
|
343
|
+
close: () => {
|
|
344
|
+
closing ??= shutdown();
|
|
345
|
+
return closing;
|
|
346
|
+
},
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
//# sourceMappingURL=stdio-proxy.js.map
|
package/dist/version.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export const VERSION = "0.3.
|
|
1
|
+
export const VERSION = "0.3.8";
|
|
2
2
|
//# sourceMappingURL=version.js.map
|